diff --git a/docs/NPM_SHORTCUTS.md b/docs/NPM_SHORTCUTS.md new file mode 100644 index 000000000..1ec354d09 --- /dev/null +++ b/docs/NPM_SHORTCUTS.md @@ -0,0 +1,47 @@ +# Source-checkout npm shortcuts + +Use Node 20+ and install root and frontend dependencies as described in +[CONTRIBUTING.md](../CONTRIBUTING.md). npm is the only command interface; +no Justfile or extra executable is required. + +| Command | Action | +| --- | --- | +| `npm run build:frontend` | Run the existing frontend production build once. | +| `npm run dev:frontend` | Run the existing Vite development server. | +| `npm run app:status` | Read the local backend status and health endpoints. | + +Frontend arguments are forwarded: `npm run dev:frontend -- --host 127.0.0.1` +or `npm run build:frontend -- --mode production`. The frontend scripts retain +control of Vite and its memory settings. Child failures remain nonzero; these +aliases do not load `.env` themselves (Vite still applies its normal env rules). + +Existing commands are unchanged: `npm run build` packages the desktop app, +`npm run dev` runs the backend, and `npm start` launches Electron. A frontend +build neither restarts the backend nor reloads an already-open renderer. +Development still needs two terminals: `npm run dev:frontend` and `npm start`. + +## Backend status + +`npm run app:status -- --json` returns machine-readable output (use +`npm --silent run app:status -- --json` to suppress npm's banner). +Optional `--url http://127.0.0.1:3334` selects another local backend; only plain +IPv4 loopback HTTP origins are accepted. `--timeout-ms 3000` sets the total +request deadline (100–30000 ms). `--help` requires no running backend. + +The helper performs GETs to `/api/system/status` and `/api/health`, sends no +credentials, does not read credential files or `.env`, and does not follow +redirects. It works without Linux `/proc` or a supervisor socket. JSON includes +`success`, `url`, `state`, `pid`, `uptimeMs`, and `healthy`. Errors report +`success: false` and a bounded diagnostic, never the response body. + +Exit 0 means the backend was running and health reported the same PID. Draining, +an unavailable backend, timeout, malformed response or disagreeing health is +exit 1. A drain/restart between the two reads can produce a mismatch; rerun +status to get a new observation. This is a snapshot, not a readiness monitor. +It does not establish checkout ownership, frontend readiness, or ability to +restart. There is no restart command or alternate authorization mechanism in +these shortcuts. + +Tests are part of the existing backend Vitest gate: +`npm test -- scripts/npm-shortcuts.test.js`. They use disposable npm recorders +and loopback HTTP fixtures; they never build, launch or restart the real app. diff --git a/package.json b/package.json index b5ead5d9d..6058a1818 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,9 @@ "eol:check": "node scripts/normalize-eol.mjs --check", "test:e2e": "playwright test", "dev": "node backend/server.js", + "dev:frontend": "npm --prefix frontend run dev --", + "build:frontend": "npm --prefix frontend run build --", + "app:status": "node scripts/app-status.mjs", "logs": "node backend/scripts/logs.js", "generate-icons": "node scripts/generate-icons.js", "prebuild": "npm run generate-icons", diff --git a/scripts/app-status.mjs b/scripts/app-status.mjs new file mode 100644 index 000000000..6bb4d7255 --- /dev/null +++ b/scripts/app-status.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node +/** Read-only status client for a local AGNT backend; no credentials or supervision. */ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HELP = `AGNT backend status (read-only, Node 20+) + npm run app:status -- [--json] [--url http://127.0.0.1:3333] [--timeout-ms 3000] + +Reads /api/system/status and /api/health without credentials. +Exit 0 means running with matching healthy PID; otherwise exit 1. +This does not verify checkout ownership, frontend readiness, or restart capability. +`; + +export function parseOptions(args) { + const options = { url: 'http://127.0.0.1:3333', timeoutMs: 3000, json: false }; + const seen = new Set(); + for (let i = 0; i < args.length; i++) { + const flag = args[i]; + if (seen.has(flag)) throw new Error('Duplicate option; use --help'); + seen.add(flag); + if (flag === '--json') options.json = true; + else if (flag === '--help' || flag === '-h') options.help = true; + else if (flag === '--url') { + const value = args[++i] || ''; + // Validate before URL canonicalization accepts alternate IP spellings. + if (!/^http:\/\/127\.0\.0\.1(?::[0-9]{1,5})?\/?$/.test(value)) throw new Error('Expected http://127.0.0.1[:port]'); + try { + const url = new URL(value); + if (url.port === '0') throw new Error(); + options.url = url.origin; + } catch { throw new Error('Invalid loopback port'); } + } else if (flag === '--timeout-ms') { + const value = args[++i] || ''; + if (!/^\d+$/.test(value) || +value < 100 || +value > 30000) throw new Error('Timeout must be 100-30000 integer milliseconds'); + options.timeoutMs = +value; + } else throw new Error('Unknown option; use --help'); + } + return options; +} + +async function requestJSON(url, route, signal) { + let response; + try { response = await fetch(url + route, { method: 'GET', redirect: 'manual', signal }); } + catch { throw new Error(signal.aborted ? 'Status request timed out' : 'Backend connection unavailable'); } + if (response.status !== 200) { + await response.body?.cancel(); + throw new Error(`${route}: HTTP ${response.status}`); + } + const chunks = []; + let bytes = 0; + try { + for await (const chunk of response.body) { + bytes += chunk.byteLength; + if (bytes > 16384) { + throw new Error('Oversized status response'); + } + chunks.push(chunk); + } + } catch (error) { + if (error.message === 'Oversized status response') throw error; + throw new Error(signal.aborted ? 'Status request timed out' : 'Status response interrupted'); + } + try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { throw new Error('Invalid JSON status response'); } +} + +export async function readStatus(options) { + const signal = AbortSignal.timeout(options.timeoutMs); + const status = await requestJSON(options.url, '/api/system/status', signal); + if (!status || !['running', 'draining'].includes(status.state) || !Number.isSafeInteger(status.pid) || status.pid <= 0 || !Number.isFinite(status.uptimeMs) || status.uptimeMs < 0) { + throw new Error('Invalid system status response'); + } + const health = await requestJSON(options.url, '/api/health', signal); + if (health?.status !== 'OK' || health.pid !== status.pid) throw new Error('Health is unhealthy or disagrees with status PID'); + return { success: status.state === 'running', url: options.url, state: status.state, pid: status.pid, uptimeMs: status.uptimeMs, healthy: true }; +} + +async function main() { + // JSON errors also work when parsing fails, without echoing arbitrary arguments. + const json = process.argv.slice(2).includes('--json'); + try { + const options = parseOptions(process.argv.slice(2)); + if (options.help) { console.log(HELP); return; } + const result = await readStatus(options); + console.log(json ? JSON.stringify(result) : `Backend: ${result.state}, PID ${result.pid}, uptime ${result.uptimeMs} ms\nTarget: ${result.url}\nHealth: OK`); + if (!result.success) process.exitCode = 1; + } catch (error) { + if (json) console.log(JSON.stringify({ success: false, error: error.message })); + else console.error(error.message); + process.exitCode = 1; + } +} +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main(); diff --git a/scripts/npm-shortcuts.test.js b/scripts/npm-shortcuts.test.js new file mode 100644 index 000000000..ca71631d7 --- /dev/null +++ b/scripts/npm-shortcuts.test.js @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn } from 'node:child_process'; +import http from 'node:http'; + +const root = new URL('../', import.meta.url); +const pkg = JSON.parse(await fs.readFile(new URL('package.json', root), 'utf8')); +function run(args, cwd = root) { + return new Promise((resolve, reject) => { + const child = spawn(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['--silent', 'run', ...args], { cwd, env: { ...process.env, AGNT_AUTH_TOKEN: 'must-not-send' }, shell: process.platform === 'win32' }); + let stdout = '', stderr = ''; + child.stdout.on('data', chunk => stdout += chunk); + child.stderr.on('data', chunk => stderr += chunk); + child.on('error', reject); + child.on('close', code => resolve({ code, stdout, stderr })); + }); +} +async function server(handler, test) { + const requests = []; + const app = http.createServer((req, res) => { requests.push({ method: req.method, url: req.url, auth: req.headers.authorization }); handler(req, res); }); + await new Promise(resolve => app.listen(0, '127.0.0.1', resolve)); + try { await test(`http://127.0.0.1:${app.address().port}`, requests); } + finally { await new Promise(resolve => app.close(resolve)); } +} +const reply = (res, value) => { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(value)); }; +describe('npm shortcuts', () => { + it('keeps existing desktop packaging and backend development unchanged', () => { + expect(pkg.scripts.build).toBe('electron-builder'); + expect(pkg.scripts.dev).toBe('node backend/server.js'); + expect(pkg.scripts['restart:backend']).toBeUndefined(); + }); + for (const action of ['build', 'dev']) { + it(`${action}:frontend forwards argv once, without dotenv, and propagates failure`, async () => { + expect(pkg.scripts[`${action}:frontend`]).toBe(`npm --prefix frontend run ${action} --`); + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'agnt-shortcuts-')); + try { + await fs.mkdir(path.join(dir, 'frontend')); + await fs.writeFile(path.join(dir, 'package.json'), JSON.stringify({ scripts: { shortcut: pkg.scripts[`${action}:frontend`] } })); + await fs.writeFile(path.join(dir, 'frontend/package.json'), JSON.stringify({ scripts: { [action]: 'node recorder.cjs' } })); + await fs.writeFile(path.join(dir, '.env'), 'SHORTCUT_SENTINEL=loaded\n'); + await fs.writeFile(path.join(dir, 'frontend/recorder.cjs'), `console.log(JSON.stringify({args:process.argv.slice(2),dotenv:process.env.SHORTCUT_SENTINEL??null}));process.exit(23);`); + const result = await run(['shortcut', '--', 'file with spaces', '$(not-a-command)', '--mode', 'test'], dir); + expect(result.code).toBe(23); + expect(JSON.parse(result.stdout)).toEqual({ args: ['file with spaces', '$(not-a-command)', '--mode', 'test'], dotenv: null }); + } finally { await fs.rm(dir, { recursive: true, force: true }); } + }); + } + it('reports running status and matching health using GET only, without credentials', async () => { + await server((req, res) => reply(res, req.url === '/api/system/status' ? { state: 'running', pid: 123, uptimeMs: 42 } : { status: 'OK', pid: 123 }), async (url, requests) => { + const result = await run(['app:status', '--', '--json', '--url', url]); + expect(result.code, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ success: true, url, state: 'running', pid: 123, uptimeMs: 42, healthy: true }); + expect(requests).toEqual([{ method: 'GET', url: '/api/system/status', auth: undefined }, { method: 'GET', url: '/api/health', auth: undefined }]); + }); + }); + for (const [name, status, health] of [ + ['draining', { state: 'draining', pid: 123, uptimeMs: 1 }, { status: 'OK', pid: 123 }], + ['malformed status', { state: 'running', pid: -1, uptimeMs: 1 }, { status: 'OK', pid: -1 }], + ['PID mismatch', { state: 'running', pid: 123, uptimeMs: 1 }, { status: 'OK', pid: 456 }], + ['unhealthy', { state: 'running', pid: 123, uptimeMs: 1 }, { status: 'bad', pid: 123 }], + ]) it(`returns nonzero for ${name}`, async () => { + await server((req, res) => reply(res, req.url === '/api/system/status' ? status : health), async url => { + const result = await run(['app:status', '--', '--json', '--url', url]); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout).success).toBe(false); + }); + }); + for (const mode of ['redirect', 'invalid JSON', 'oversized', 'timeout', 'HTTP failure']) it(`fails safely on ${mode}`, async () => { + await server((req, res) => { + if (mode === 'timeout') return; + if (mode === 'redirect') { res.writeHead(302, { Location: '/redirect-target' }); res.end(); } + else if (mode === 'HTTP failure') { res.writeHead(503); res.end('private body'); } + else res.end(mode === 'oversized' ? 'x'.repeat(17000) : 'private body'); + }, async (url, requests) => { + const result = await run(['app:status', '--', '--json', '--url', url, '--timeout-ms', '100']); + expect(result.code).toBe(1); + expect(JSON.parse(result.stdout).success).toBe(false); + expect(result.stdout).not.toContain('private body'); + expect(requests).toHaveLength(1); + }); + }); + it('rejects remote, credential-bearing and unknown options before making requests', async () => { + for (const args of [['--url', 'https://example.com'], ['--url', 'http://user:pass@127.0.0.1:3333'], ['--token', 'private'], ['--timeout-ms', '0'], ['--url', 'http://127.0.0.1:0']]) { + const result = await run(['app:status', '--', '--json', ...args]); + expect(result.code).toBe(1); + expect(result.stdout + result.stderr).not.toContain('private'); + } + }); + it('prints help without needing a backend', async () => { + const result = await run(['app:status', '--', '--help']); + expect(result.code).toBe(0); + expect(result.stdout).toContain('read-only'); + }); +});