Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/NPM_SHORTCUTS.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
93 changes: 93 additions & 0 deletions scripts/app-status.mjs
Original file line number Diff line number Diff line change
@@ -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();
96 changes: 96 additions & 0 deletions scripts/npm-shortcuts.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
Loading