@amenophis1er/foreman 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,6 +69,7 @@ exits. Nothing blocks unless it says so.
69
69
  | Variable | Meaning | Default |
70
70
  |---|---|---|
71
71
  | `PORT` | listen port | `4177` |
72
+ | `FOREMAN_SERVICES_PORT` | the port dev servers the crew exposed are proxied on — their own origin, so a page the crew built cannot call Foreman's API | `PORT` + 1 |
72
73
  | `FOREMAN_HOME` | state directory: runs, settings, logs | `~/.foreman` |
73
74
  | `FOREMAN_BIND` | `auto` (loopback + Tailscale when present), `local`, or `all` | `auto` |
74
75
  | `FOREMAN_BROWSER` | browser for missions: `chrome`, `chromium`, `msedge`, `firefox` | `chrome` |
@@ -94,7 +95,8 @@ exits. Nothing blocks unless it says so.
94
95
  4. **Files.** What the run changed, against a baseline taken at start (git
95
96
  ref or snapshot), with diffs; what it produced — screenshots, logs, work
96
97
  files — viewable in place, arrow keys to step through; any dev server the
97
- crew exposed, one click away. HTML renders in a sandbox with its own
98
+ crew exposed, one click away — on its own port, and so its own origin, so a
99
+ page an agent wrote cannot turn around and call Foreman's API. HTML renders in a sandbox with its own
98
100
  scripts, so a built page is a page, not a source listing.
99
101
  5. **Next.** A finished run is a starting point: **Plan the next step** opens
100
102
  a new planning conversation already seeded with what was built, the
@@ -128,7 +130,11 @@ itself. A local model is `free`. A cloud model with no published price is
128
130
  `unpriced`, and the meter shows what is true instead: tokens in, tokens out,
129
131
  turns. The one exception is a dated table of OpenAI's list prices, visible in
130
132
  `src/openai-prices.ts` with the day it was checked. Budget caps bind on
131
- dollars where dollars are real and on wall clock always.
133
+ dollars where dollars are real and on wall clock always — and, on a run that
134
+ is `free` or `unpriced`, on tokens too: 20M by default across input, output and
135
+ cache, because turns and minutes alone do not notice a director whose turns
136
+ are cheap and enormous. A priced run is never ended by tokens; its budget is
137
+ its cap.
132
138
 
133
139
  Which account pays is printed at startup and shown wherever a mission can be
134
140
  started. Pin it with `FOREMAN_CLAUDE_CONFIG_DIR`; assert it with
@@ -224,7 +230,7 @@ result.
224
230
  ```sh
225
231
  npm ci && npm run setup # dependencies, then the dashboard build
226
232
  npm start # serves http://localhost:4177
227
- npm test # 299 tests, node:test
233
+ npm test # 360 tests, node:test
228
234
  npm run typecheck # server and dashboard
229
235
  npm run dev # API + Vite together
230
236
  scripts/dev-restart.sh # restarts the server only when nothing would be lost
package/bin/foreman.mjs CHANGED
@@ -33,6 +33,7 @@ const USAGE = `foreman ${pkg.version}
33
33
 
34
34
  foreman uninstall Remove the service and the background server; keeps ~/.foreman
35
35
  foreman uninstall --purge --yes …and delete ~/.foreman (every run's history) too
36
+ foreman completion install Tab-completion for these commands (zsh, bash, fish); or "completion zsh" to print it
36
37
  foreman --version | --help
37
38
 
38
39
  Environment:
@@ -55,7 +56,7 @@ if (command === '--help' || command === '-h' || command === 'help') {
55
56
  } else if (command === 'start') {
56
57
  register();
57
58
  await import(new URL('../src/server.ts', import.meta.url).href);
58
- } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update'].includes(command)) {
59
+ } else if (['doctor', 'open', 'service', 'up', 'down', 'stop', 'restart', 'status', 'logs', 'uninstall', 'update', 'completion'].includes(command)) {
59
60
  register();
60
61
  const { runCli } = await import(new URL('../src/cli.ts', import.meta.url).href);
61
62
  process.exitCode = await runCli(command, rest, { version: pkg.version, bin: new URL(import.meta.url) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Autonomous mission runner on the Claude Agent SDK: a director plans, delegates to workers, verifies, and reports — from one dashboard, your phone, or the CLI.",
5
5
  "keywords": [
6
6
  "claude",
package/src/browser.ts ADDED
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The browser missions get, and how one is installed when there is none.
3
+ *
4
+ * The crew's browser is Playwright's MCP in headless mode with its own
5
+ * profile — never the person's Chrome. Which executable it drives used to be
6
+ * a fixed channel: Chrome unless FOREMAN_BROWSER said otherwise. On a machine
7
+ * with no Chrome that failed even after a director had downloaded Playwright's
8
+ * own Chromium, because nothing looked for it. Now the choice is detected:
9
+ * FOREMAN_BROWSER when set, else the machine's Chrome, else the Chromium
10
+ * Playwright installs — and that last one can be installed from the setup
11
+ * page, as the user, into Playwright's own cache, no root needed.
12
+ */
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import { access } from 'node:fs/promises';
16
+ import { constants } from 'node:fs';
17
+ import { execFile, spawn } from 'node:child_process';
18
+ import { createRequire } from 'node:module';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ /** The Playwright MCP's entry point, shipped as Foreman's own dependency. */
22
+ export const PLAYWRIGHT_MCP_CLI = fileURLToPath(new URL('../node_modules/@playwright/mcp/cli.js', import.meta.url));
23
+
24
+ export type BrowserChannel = 'chrome' | 'msedge' | 'firefox' | 'chromium';
25
+
26
+ export interface BrowserFound {
27
+ channel: BrowserChannel;
28
+ path: string;
29
+ /** What to call it in a sentence. */
30
+ label: string;
31
+ }
32
+
33
+ const exists = (p: string) => access(p, constants.F_OK).then(() => true, () => false);
34
+
35
+ /** The playwright-core the MCP itself uses — the one whose Chromium build the MCP can drive. */
36
+ function playwrightCore(): { dir: string; chromiumPath: string | null } | null {
37
+ try {
38
+ const req = createRequire(PLAYWRIGHT_MCP_CLI);
39
+ const dir = path.dirname(req.resolve('playwright-core/package.json'));
40
+ let chromiumPath: string | null = null;
41
+ try {
42
+ chromiumPath = (req('playwright-core') as { chromium: { executablePath(): string } }).chromium.executablePath();
43
+ } catch { /* no build registered */ }
44
+ return { dir, chromiumPath };
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function candidates(channel: Exclude<BrowserChannel, 'chromium'>): string[] {
51
+ const mac = process.platform === 'darwin';
52
+ const win = process.platform === 'win32';
53
+ switch (channel) {
54
+ case 'chrome':
55
+ return mac
56
+ ? ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', path.join(os.homedir(), 'Applications/Google Chrome.app/Contents/MacOS/Google Chrome')]
57
+ : win
58
+ ? [
59
+ path.join(process.env.ProgramFiles ?? 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
60
+ path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
61
+ path.join(process.env.LOCALAPPDATA ?? '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
62
+ ]
63
+ : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/opt/google/chrome/chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium'];
64
+ case 'msedge':
65
+ return mac ? ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge']
66
+ : win ? [path.join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Microsoft', 'Edge', 'Application', 'msedge.exe')]
67
+ : ['/usr/bin/microsoft-edge', '/usr/bin/microsoft-edge-stable'];
68
+ case 'firefox':
69
+ return mac ? ['/Applications/Firefox.app/Contents/MacOS/firefox'] : ['/usr/bin/firefox'];
70
+ }
71
+ }
72
+
73
+ const LABEL: Record<BrowserChannel, string> = { chrome: 'Google Chrome', msedge: 'Microsoft Edge', firefox: 'Firefox', chromium: 'Playwright Chromium' };
74
+
75
+ async function find(channel: BrowserChannel): Promise<BrowserFound | null> {
76
+ if (channel === 'chromium') {
77
+ const p = playwrightCore()?.chromiumPath ?? null;
78
+ return p && (await exists(p)) ? { channel, path: p, label: LABEL.chromium } : null;
79
+ }
80
+ for (const p of candidates(channel)) if (await exists(p)) return { channel, path: p, label: LABEL[channel] };
81
+ return null;
82
+ }
83
+
84
+ /**
85
+ * The browser missions will run on, or null when there is none. Honours
86
+ * FOREMAN_BROWSER as a pin (an explicit choice is never silently replaced);
87
+ * otherwise Chrome, then Playwright's Chromium, in that order — Chrome needs
88
+ * no download and is what most machines have.
89
+ */
90
+ export async function detectBrowser(): Promise<BrowserFound | null> {
91
+ const pinned = (process.env.FOREMAN_BROWSER || '').toLowerCase() as BrowserChannel | '';
92
+ if (pinned) return (['chrome', 'msedge', 'firefox', 'chromium'] as BrowserChannel[]).includes(pinned) ? find(pinned) : null;
93
+ return (await find('chrome')) ?? (await find('chromium'));
94
+ }
95
+
96
+ /**
97
+ * Can the executable actually start? On Linux a downloaded Chromium is a
98
+ * file that exists and a browser that does not: the system libraries it links
99
+ * against (glib, nss, …) are packages, and a slim machine lacks them. `--version`
100
+ * exits at once either way and fails with the loader's own message when they
101
+ * are missing. Only asked on Linux; elsewhere the download is the browser.
102
+ */
103
+ export function browserStarts(executable: string): Promise<string | null> {
104
+ if (process.platform !== 'linux') return Promise.resolve(null);
105
+ return new Promise((resolve) => {
106
+ execFile(executable, ['--version'], { timeout: 8000 }, (err, _stdout, stderr) => {
107
+ if (!err) return resolve(null);
108
+ const text = String(stderr || err.message).trim().split('\n').pop() ?? '';
109
+ const lib = /error while loading shared libraries: ([^:]+)/.exec(text)?.[1];
110
+ resolve(lib ? `missing system library ${lib}` : text || 'it did not start');
111
+ });
112
+ });
113
+ }
114
+
115
+ /** The preflight row for the browser, from the same detection the crew gets. */
116
+ export async function browserCheck(): Promise<{ status: 'ok' | 'warn'; detail: string; fix?: string }> {
117
+ const pinned = process.env.FOREMAN_BROWSER;
118
+ const found = await detectBrowser();
119
+ if (found) {
120
+ const broken = await browserStarts(found.path);
121
+ if (!broken) return { status: 'ok', detail: `${found.label} — ${found.path}` };
122
+ return {
123
+ status: 'warn',
124
+ detail: `${found.label} is installed but cannot start: ${broken}`,
125
+ // Root, once, and not something Foreman will do itself.
126
+ fix: 'The libraries are system packages and need root once: sudo npx playwright install-deps chromium',
127
+ };
128
+ }
129
+ return {
130
+ status: 'warn',
131
+ detail: pinned
132
+ ? `FOREMAN_BROWSER=${pinned} but no such browser was found; missions with browser on will fail`
133
+ : 'no Google Chrome or Playwright Chromium found; missions with browser on will fail',
134
+ fix: pinned
135
+ ? 'Install it, or unset FOREMAN_BROWSER to let Foreman pick'
136
+ : 'Install Google Chrome, or install Playwright Chromium from the dashboard\'s setup page (or: npx playwright install chromium)',
137
+ };
138
+ }
139
+
140
+ /**
141
+ * Installs Playwright's Chromium with playwright-core's own CLI — the same
142
+ * package the MCP loads, so the build it downloads is the one it can drive.
143
+ * Runs as the user, into Playwright's cache under the home directory; no
144
+ * root. Resolves to null on success or one sentence on failure. Progress
145
+ * lines (download percentages) are passed on as they arrive.
146
+ *
147
+ * On Linux the download can succeed while the system libraries Chromium
148
+ * needs are missing; those need root (`playwright install-deps`), which this
149
+ * never asks for — the message says so when it looks that way.
150
+ */
151
+ export function installChromium(onProgress?: (line: string) => void, timeoutMs = 10 * 60_000): Promise<string | null> {
152
+ const core = playwrightCore();
153
+ if (!core) return Promise.resolve('Playwright is not available in this install of Foreman.');
154
+ const cli = path.join(core.dir, 'cli.js');
155
+ return new Promise((resolve) => {
156
+ let out = '';
157
+ let settled = false;
158
+ const done = (v: string | null) => { if (!settled) { settled = true; resolve(v); } };
159
+ let child;
160
+ try {
161
+ child = spawn(process.execPath, [cli, 'install', 'chromium'], { stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
162
+ } catch (err) {
163
+ return done(`Could not start the installer: ${err instanceof Error ? err.message : String(err)}`);
164
+ }
165
+ const timer = setTimeout(() => { child.kill('SIGKILL'); done(`The download took longer than ${Math.round(timeoutMs / 60_000)} minutes and was stopped.`); }, timeoutMs);
166
+ const onData = (buf: Buffer) => {
167
+ const text = buf.toString();
168
+ out += text;
169
+ if (out.length > 64_000) out = out.slice(-32_000);
170
+ for (const piece of text.split(/[\r\n]+/)) {
171
+ const line = piece.trim();
172
+ if (line) onProgress?.(line);
173
+ }
174
+ };
175
+ child.stdout.on('data', onData);
176
+ child.stderr.on('data', onData);
177
+ child.on('error', (err) => { clearTimeout(timer); done(`Could not run the installer: ${err.message}`); });
178
+ child.on('close', (code) => {
179
+ clearTimeout(timer);
180
+ if (code === 0) return done(null);
181
+ const last = out.trim().split('\n').filter(Boolean).pop() ?? '';
182
+ const hint = process.platform === 'linux' && /install-deps|missing dependencies|libnss|shared libraries/i.test(out)
183
+ ? ' Chromium is downloaded but some system libraries are missing; they need root: sudo npx playwright install-deps chromium.'
184
+ : '';
185
+ done(`The installer exited with code ${code}${last ? `: ${last}` : ''}.${hint}`);
186
+ });
187
+ });
188
+ }
package/src/cli.ts CHANGED
@@ -23,6 +23,11 @@ import { detectTailscale } from './tailscale.js';
23
23
  import { PACKAGE, checkForUpdate, currentVersion } from './update.js';
24
24
 
25
25
  const PORT = Number(process.env.PORT ?? 4177);
26
+ /** The second listener, for exposed dev servers — same default as the server's. */
27
+ const SERVICES_PORT = (() => {
28
+ const n = Number(process.env.FOREMAN_SERVICES_PORT);
29
+ return Number.isInteger(n) && n >= 1 && n <= 65535 ? n : PORT + 1;
30
+ })();
26
31
  const HOME_DIR = process.env.FOREMAN_HOME || path.join(os.homedir(), '.foreman');
27
32
  const LABEL = 'dev.foreman.server';
28
33
  const PID_FILE = path.join(HOME_DIR, 'foreman.pid');
@@ -321,7 +326,7 @@ async function update(bin: string, flags: string[]): Promise<number> {
321
326
  async function doctor(): Promise<number> {
322
327
  const tailnet = await detectTailscale(PORT);
323
328
  const distDir = fileURLToPath(new URL('../ui/dist', import.meta.url));
324
- const checks = await preflight({ port: PORT, foremanHome: HOME_DIR, distDir, tailnet });
329
+ const checks = await preflight({ port: PORT, servicesPort: SERVICES_PORT, foremanHome: HOME_DIR, distDir, tailnet });
325
330
  // Port-in-use is an error for `start` and a fact for `doctor`: it usually means Foreman is already up.
326
331
  for (const c of checks) {
327
332
  if (c.name.startsWith('Port') && c.status === 'error') { c.status = 'warn'; c.detail = 'in use — Foreman is probably already running'; c.fix = `foreman open · or PORT=${PORT + 1} foreman`; }
@@ -493,6 +498,19 @@ export async function runCli(command: string, rest: string[], ctx: { version: st
493
498
  case 'uninstall': return uninstall(rest);
494
499
  case 'status': return status();
495
500
  case 'doctor': return doctor();
501
+ case 'completion': {
502
+ const { completionScript, detectShell, installCompletion } = await import('./completion.js');
503
+ const arg = rest[0];
504
+ if (arg === 'zsh' || arg === 'bash' || arg === 'fish') { process.stdout.write(completionScript(arg)); return 0; }
505
+ if (arg === 'install') {
506
+ const shell = (rest[1] === 'zsh' || rest[1] === 'bash' || rest[1] === 'fish') ? rest[1] : detectShell();
507
+ if (!shell) { console.error('Could not tell your shell from $SHELL. Say which: foreman completion install zsh|bash|fish'); return 1; }
508
+ console.log(await installCompletion(shell));
509
+ return 0;
510
+ }
511
+ console.error('Usage: foreman completion zsh|bash|fish (prints the script)\n foreman completion install [zsh|bash|fish] (adds it to your shell rc, once)');
512
+ return 1;
513
+ }
496
514
  case 'open': return open();
497
515
  case 'service': {
498
516
  const sub = rest[0];
@@ -0,0 +1,35 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { explainGitFailure, looksLikeRepoUrl, parseRepoUrl } from './clone.js';
4
+
5
+ test('parseRepoUrl: the forms people paste all name the same repository', () => {
6
+ for (const s of ['https://github.com/acme/widget', 'https://github.com/acme/widget.git', 'https://github.com/acme/widget/', 'https://github.com/acme/widget/tree/main/src', 'acme/widget']) {
7
+ assert.deepEqual(parseRepoUrl(s), { url: 'https://github.com/acme/widget.git', name: 'widget', host: 'github.com' }, s);
8
+ }
9
+ assert.deepEqual(parseRepoUrl('git@github.com:acme/widget.git'), { url: 'git@github.com:acme/widget.git', name: 'widget', host: 'github.com' });
10
+ assert.deepEqual(parseRepoUrl('git@gitlab.com:group/sub/widget'), { url: 'git@gitlab.com:group/sub/widget.git', name: 'widget', host: 'gitlab.com' });
11
+ assert.deepEqual(parseRepoUrl('ssh://git@bitbucket.org:7999/proj/widget.git'), { url: 'ssh://git@bitbucket.org:7999/proj/widget.git', name: 'widget', host: 'bitbucket.org' });
12
+ });
13
+
14
+ test('parseRepoUrl: not a repository', () => {
15
+ for (const s of ['', 'https://github.com/acme', '/Users/me/Projects/x', '~/Projects/x', './x', 'just words here', 'https://example.com']) {
16
+ assert.equal(parseRepoUrl(s), null, s);
17
+ }
18
+ });
19
+
20
+ test('looksLikeRepoUrl separates repositories from folder paths for the shared link verbs', () => {
21
+ assert.equal(looksLikeRepoUrl('https://github.com/acme/widget'), true);
22
+ assert.equal(looksLikeRepoUrl('git@github.com:acme/widget.git'), true);
23
+ assert.equal(looksLikeRepoUrl('acme/widget'), true);
24
+ assert.equal(looksLikeRepoUrl('~/Projects/widget'), false);
25
+ assert.equal(looksLikeRepoUrl('/Users/me/widget'), false);
26
+ assert.equal(looksLikeRepoUrl('./widget'), false);
27
+ });
28
+
29
+ test('explainGitFailure turns stderr into one sentence a person can act on', () => {
30
+ const ref = { url: 'https://github.com/acme/secret.git', name: 'secret', host: 'github.com' };
31
+ assert.match(explainGitFailure('Cloning into...\nfatal: could not read Username for https://github.com: terminal prompts disabled', ref), /could not authenticate to github.com/);
32
+ assert.match(explainGitFailure('ERROR: Repository not found.\nfatal: Could not read from remote repository.', ref), /not found/);
33
+ assert.match(explainGitFailure("fatal: unable to access 'x': Could not resolve host: github.com", ref), /Could not reach github.com/);
34
+ assert.match(explainGitFailure('fatal: something odd', ref), /git clone failed: something odd/);
35
+ });
package/src/clone.ts ADDED
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Linking a project that is not on this machine yet: a Git URL becomes a
3
+ * folder under the projects root, then the folder is linked like any other.
4
+ *
5
+ * Foreman runs `git clone` as the user — their SSH keys, credential helper
6
+ * and `gh auth` all apply, and Foreman never sees, asks for or stores a
7
+ * token. Prompts are disabled so a private repository with no credentials
8
+ * fails in seconds with git's own message instead of hanging on a password
9
+ * question nobody can see.
10
+ */
11
+ import { spawn } from 'node:child_process';
12
+ import path from 'node:path';
13
+
14
+ export interface RepoRef {
15
+ /** The URL git will be given, normalised. */
16
+ url: string;
17
+ /** Folder name under the projects root: the repository's name. */
18
+ name: string;
19
+ host: string;
20
+ }
21
+
22
+ /**
23
+ * Accepts the forms people paste: `https://github.com/o/r`, with or without
24
+ * `.git` or a trailing slash, `git@github.com:o/r.git`, `ssh://git@host/o/r`,
25
+ * and the shorthand `owner/repo`, which means GitHub. Anything else is null.
26
+ */
27
+ export function parseRepoUrl(input: string): RepoRef | null {
28
+ const s = input.trim();
29
+ if (!s || /\s/.test(s)) return null;
30
+ let m = /^(?:https?:\/\/)([^/\s]+)\/(.+?)(?:\.git)?\/?$/i.exec(s);
31
+ if (m) {
32
+ const host = m[1].toLowerCase();
33
+ const segs = m[2].split('/').filter(Boolean);
34
+ if (segs.length < 2) return null;
35
+ // A web URL to a page inside the repo (tree/…, blob/…) still names the repo.
36
+ const repo = segs.slice(0, 2).join('/');
37
+ return { url: `https://${host}/${repo}.git`, name: safeName(segs[1]), host };
38
+ }
39
+ m = /^git@([^:\s]+):(.+?)(?:\.git)?\/?$/i.exec(s);
40
+ if (m) {
41
+ const segs = m[2].split('/').filter(Boolean);
42
+ if (segs.length < 1) return null;
43
+ return { url: s.endsWith('.git') ? s : `${s.replace(/\/$/, '')}.git`, name: safeName(segs[segs.length - 1]), host: m[1].toLowerCase() };
44
+ }
45
+ m = /^ssh:\/\/(?:[^@/\s]+@)?([^/\s:]+)(?::\d+)?\/(.+?)(?:\.git)?\/?$/i.exec(s);
46
+ if (m) {
47
+ const segs = m[2].split('/').filter(Boolean);
48
+ if (segs.length < 1) return null;
49
+ return { url: s, name: safeName(segs[segs.length - 1]), host: m[1].toLowerCase() };
50
+ }
51
+ m = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(s);
52
+ if (m && !s.startsWith('.') && !s.startsWith('/')) {
53
+ return { url: `https://github.com/${m[1]}/${m[2]}.git`, name: safeName(m[2]), host: 'github.com' };
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /** Does this look like a repository rather than a folder path? Cheap gate for the shared "link" verbs. */
59
+ export function looksLikeRepoUrl(input: string): boolean {
60
+ const s = input.trim();
61
+ return /^(https?:\/\/|git@|ssh:\/\/)/i.test(s) || (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(s) && !s.startsWith('.') && !s.startsWith('~'));
62
+ }
63
+
64
+ function safeName(raw: string): string {
65
+ return raw.replace(/\.git$/i, '').replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^[-.]+|[-.]+$/g, '').slice(0, 80) || 'repo';
66
+ }
67
+
68
+ /** Git's stderr, reduced to the one line a person needs. */
69
+ export function explainGitFailure(stderr: string, ref: RepoRef): string {
70
+ const t = stderr.trim();
71
+ if (/could not read Username|Authentication failed|terminal prompts disabled|Permission denied \(publickey\)|could not read Password/i.test(t)) {
72
+ return `Git could not authenticate to ${ref.host}. For a private repository, set up a credential helper or gh auth, or use the SSH URL with a key this machine has.`;
73
+ }
74
+ if (/Repository not found|not found|does not exist/i.test(t)) return `Git says the repository was not found at ${ref.url}. Check the URL — or it is private and this machine cannot see it.`;
75
+ if (/Could not resolve host|unable to access/i.test(t)) return `Could not reach ${ref.host}: ${t.split('\n').pop() ?? t}`;
76
+ if (/Remote branch .* not found/i.test(t)) return t.split('\n').find((l) => /Remote branch/.test(l)) ?? t;
77
+ const last = t.split('\n').filter((l) => l.trim() && !/^Cloning into/.test(l)).pop();
78
+ return last ? `git clone failed: ${last.replace(/^fatal:\s*/i, '')}` : 'git clone failed.';
79
+ }
80
+
81
+ export interface CloneOptions {
82
+ ref: RepoRef;
83
+ dest: string;
84
+ branch?: string;
85
+ /** Git's progress lines ("Receiving objects: 42%"), as they arrive. */
86
+ onProgress?: (line: string) => void;
87
+ timeoutMs?: number;
88
+ }
89
+
90
+ /**
91
+ * `git clone --progress [-b branch] url dest`, as the user. Resolves to null
92
+ * on success or to one explanatory sentence on failure. Never throws.
93
+ */
94
+ export function cloneRepo(opts: CloneOptions): Promise<string | null> {
95
+ const { ref, dest, branch, onProgress, timeoutMs = 15 * 60_000 } = opts;
96
+ if (branch && !/^[A-Za-z0-9._\/-]{1,200}$/.test(branch)) return Promise.resolve('That branch name is not one git accepts.');
97
+ const args = ['clone', '--progress', ...(branch ? ['--branch', branch] : []), '--', ref.url, dest];
98
+ return new Promise((resolve) => {
99
+ let stderr = '';
100
+ let settled = false;
101
+ const done = (v: string | null) => { if (!settled) { settled = true; resolve(v); } };
102
+ let child;
103
+ try {
104
+ child = spawn('git', args, {
105
+ cwd: path.dirname(dest),
106
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', GIT_ASKPASS: 'echo', SSH_ASKPASS: 'echo', GIT_SSH_COMMAND: process.env.GIT_SSH_COMMAND ?? 'ssh -o BatchMode=yes' },
107
+ stdio: ['ignore', 'ignore', 'pipe'],
108
+ });
109
+ } catch (err) {
110
+ return done(`Could not start git: ${err instanceof Error ? err.message : String(err)}`);
111
+ }
112
+ const timer = setTimeout(() => { child.kill('SIGKILL'); done(`git clone took longer than ${Math.round(timeoutMs / 60_000)} minutes and was stopped.`); }, timeoutMs);
113
+ child.stderr.on('data', (buf: Buffer) => {
114
+ const text = buf.toString();
115
+ stderr += text;
116
+ if (stderr.length > 64_000) stderr = stderr.slice(-32_000);
117
+ // Progress arrives as carriage-return-separated updates on one line.
118
+ for (const piece of text.split(/[\r\n]+/)) {
119
+ const line = piece.trim();
120
+ if (line && /(objects|deltas|Cloning|Updating|Checking)/i.test(line)) onProgress?.(line);
121
+ }
122
+ });
123
+ child.on('error', (err) => { clearTimeout(timer); done(`Could not run git: ${err.message}. Is git installed?`); });
124
+ child.on('close', (code) => {
125
+ clearTimeout(timer);
126
+ done(code === 0 ? null : explainGitFailure(stderr, ref));
127
+ });
128
+ });
129
+ }
@@ -0,0 +1,30 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { COMMANDS, SERVICE_COMMANDS, completionScript, detectShell, installTarget } from './completion.js';
4
+
5
+ test('every shell script names every command and the service verbs', () => {
6
+ for (const shell of ['zsh', 'bash', 'fish'] as const) {
7
+ const s = completionScript(shell);
8
+ for (const [c] of COMMANDS) assert.ok(s.includes(c), `${shell} lacks ${c}`);
9
+ for (const c of SERVICE_COMMANDS) assert.ok(s.includes(c), `${shell} lacks service ${c}`);
10
+ const flags = shell === 'fish' ? ['-l force', '-l purge'] : ['--force', '--purge'];
11
+ for (const f of flags) assert.ok(s.includes(f), `${shell} lacks flag ${f}`);
12
+ }
13
+ assert.match(completionScript('zsh'), /^#compdef foreman\n/);
14
+ assert.match(completionScript('zsh'), /compdef _foreman foreman\n$/);
15
+ assert.match(completionScript('bash'), /complete -F _foreman foreman\n$/);
16
+ assert.match(completionScript('fish'), /^complete -c foreman -f\n/);
17
+ });
18
+
19
+ test('detectShell reads $SHELL and ignores anything else', () => {
20
+ assert.equal(detectShell({ SHELL: '/bin/zsh' }), 'zsh');
21
+ assert.equal(detectShell({ SHELL: '/opt/homebrew/bin/fish' }), 'fish');
22
+ assert.equal(detectShell({ SHELL: '/bin/tcsh' }), null);
23
+ assert.equal(detectShell({}), null);
24
+ });
25
+
26
+ test('installTarget: rc line for zsh and bash, a completions file for fish', () => {
27
+ assert.deepEqual(installTarget('zsh', '/home/u'), { file: '/home/u/.zshrc', line: 'eval "$(foreman completion zsh)" # foreman completion' });
28
+ assert.equal(installTarget('bash', '/home/u').file, '/home/u/.bashrc');
29
+ assert.deepEqual(installTarget('fish', '/home/u'), { file: '/home/u/.config/fish/completions/foreman.fish' });
30
+ });
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Shell completion for the `foreman` command.
3
+ *
4
+ * `foreman completion <shell>` prints a script; `foreman completion install`
5
+ * wires it into the shell's rc file, once, behind a marker — the only file
6
+ * outside ~/.foreman the CLI ever writes, and only when asked by name.
7
+ */
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
11
+
12
+ export type Shell = 'zsh' | 'bash' | 'fish';
13
+
14
+ /** Every top-level command with the one line the menu shows for it. Keep in step with bin/foreman.mjs USAGE. */
15
+ export const COMMANDS: Array<[string, string]> = [
16
+ ['start', 'Start in this terminal'],
17
+ ['up', 'Start in the background'],
18
+ ['stop', 'Stop it, however it was started'],
19
+ ['restart', 'Stop and start it again the same way'],
20
+ ['status', 'Is a server up, on which port, started how'],
21
+ ['logs', 'Tail the log'],
22
+ ['open', 'Open the dashboard in your browser'],
23
+ ['doctor', 'Check credentials, providers, browser, port, Tailscale'],
24
+ ['update', 'Install the latest version and restart'],
25
+ ['service', 'Keep Foreman running at login'],
26
+ ['uninstall', 'Remove the service and the background server'],
27
+ ['completion', 'Shell completion: zsh, bash, fish, or install'],
28
+ ['help', 'Show usage'],
29
+ ['version', 'Print the version'],
30
+ ];
31
+ export const SERVICE_COMMANDS = ['install', 'uninstall', 'start', 'stop', 'restart', 'status', 'logs'];
32
+ export const COMPLETION_ARGS = ['zsh', 'bash', 'fish', 'install'];
33
+ const FLAGS: Record<string, string[]> = { update: ['--force'], uninstall: ['--purge', '--yes'] };
34
+
35
+ const q = (s: string) => `'${s.replace(/'/g, "'\\''")}'`;
36
+
37
+ export function completionScript(shell: Shell): string {
38
+ const names = COMMANDS.map(([c]) => c).join(' ');
39
+ if (shell === 'zsh') {
40
+ return [
41
+ '#compdef foreman',
42
+ '_foreman() {',
43
+ ' local -a cmds',
44
+ ` cmds=(${COMMANDS.map(([c, d]) => q(`${c}:${d}`)).join(' ')})`,
45
+ ' if (( CURRENT == 2 )); then _describe -t commands "foreman command" cmds; return; fi',
46
+ ' case "${words[2]}" in',
47
+ ` service) local -a svc; svc=(${SERVICE_COMMANDS.map(q).join(' ')}); _describe -t commands "service command" svc ;;`,
48
+ ` completion) local -a sh; sh=(${COMPLETION_ARGS.map(q).join(' ')}); _describe -t commands "shell" sh ;;`,
49
+ ...Object.entries(FLAGS).map(([c, f]) => ` ${c}) local -a fl; fl=(${f.map(q).join(' ')}); _describe -t options "flag" fl ;;`),
50
+ ' esac',
51
+ '}',
52
+ 'compdef _foreman foreman',
53
+ '',
54
+ ].join('\n');
55
+ }
56
+ if (shell === 'bash') {
57
+ return [
58
+ '_foreman() {',
59
+ ' local cur prev',
60
+ ' cur="${COMP_WORDS[COMP_CWORD]}"',
61
+ ' prev="${COMP_WORDS[COMP_CWORD-1]}"',
62
+ ` if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "${names}" -- "$cur") ); return; fi`,
63
+ ' case "$prev" in',
64
+ ` service) COMPREPLY=( $(compgen -W "${SERVICE_COMMANDS.join(' ')}" -- "$cur") ) ;;`,
65
+ ` completion) COMPREPLY=( $(compgen -W "${COMPLETION_ARGS.join(' ')}" -- "$cur") ) ;;`,
66
+ ...Object.entries(FLAGS).map(([c, f]) => ` ${c}) COMPREPLY=( $(compgen -W "${f.join(' ')}" -- "$cur") ) ;;`),
67
+ ' esac',
68
+ '}',
69
+ 'complete -F _foreman foreman',
70
+ '',
71
+ ].join('\n');
72
+ }
73
+ return [
74
+ 'complete -c foreman -f',
75
+ ...COMMANDS.map(([c, d]) => `complete -c foreman -n __fish_use_subcommand -a ${c} -d ${q(d)}`),
76
+ `complete -c foreman -n '__fish_seen_subcommand_from service' -a '${SERVICE_COMMANDS.join(' ')}'`,
77
+ `complete -c foreman -n '__fish_seen_subcommand_from completion' -a '${COMPLETION_ARGS.join(' ')}'`,
78
+ ...Object.entries(FLAGS).flatMap(([c, f]) => f.map((flag) => `complete -c foreman -n '__fish_seen_subcommand_from ${c}' -l ${flag.replace(/^--/, '')}`)),
79
+ '',
80
+ ].join('\n');
81
+ }
82
+
83
+ /** The user's shell from $SHELL, or null when it is none of the three. */
84
+ export function detectShell(env: NodeJS.ProcessEnv = process.env): Shell | null {
85
+ const name = path.basename(env.SHELL ?? '');
86
+ return name === 'zsh' || name === 'bash' || name === 'fish' ? name : null;
87
+ }
88
+
89
+ const MARKER = '# foreman completion';
90
+
91
+ /**
92
+ * Where the hook goes, and what it is. zsh and bash source the script at
93
+ * shell start; fish loads a file from its completions directory on demand.
94
+ */
95
+ export function installTarget(shell: Shell, home = os.homedir()): { file: string; line?: string } {
96
+ if (shell === 'fish') return { file: path.join(home, '.config', 'fish', 'completions', 'foreman.fish') };
97
+ const file = shell === 'zsh' ? path.join(home, '.zshrc') : path.join(home, '.bashrc');
98
+ return { file, line: `eval "$(foreman completion ${shell})" ${MARKER}` };
99
+ }
100
+
101
+ /** Idempotent: a second install finds the marker and changes nothing. Returns what happened. */
102
+ export async function installCompletion(shell: Shell, home = os.homedir()): Promise<string> {
103
+ const target = installTarget(shell, home);
104
+ if (!target.line) {
105
+ await mkdir(path.dirname(target.file), { recursive: true });
106
+ await writeFile(target.file, completionScript('fish'));
107
+ return `Wrote ${target.file}. Open a new fish shell.`;
108
+ }
109
+ const current = await readFile(target.file, 'utf8').catch(() => '');
110
+ if (current.includes(MARKER)) return `Already installed in ${target.file}.`;
111
+ await appendFile(target.file, `${current.endsWith('\n') || !current ? '' : '\n'}${target.line}\n`);
112
+ return `Added one line to ${target.file}. Open a new shell, or run: source ${target.file}`;
113
+ }
package/src/deck.test.ts CHANGED
@@ -306,7 +306,7 @@ test('handleDeckRoute: matches only its two routes, 404s unknown runs and bad pa
306
306
  const runs: Record<string, { folder: string }> = { 'run-1': { folder } };
307
307
  const server = http.createServer(async (req, res) => {
308
308
  const url = new URL(req.url ?? '/', 'http://localhost');
309
- const handled = await handleDeckRoute(req, res, url, async (id) => runs[id] ?? null);
309
+ const handled = await handleDeckRoute(req, res, url, async (_scope, id) => runs[id] ?? null);
310
310
  // A status no deck response ever uses, so the test can see "fell through".
311
311
  if (!handled) { res.writeHead(418); res.end(); }
312
312
  });
@@ -372,7 +372,7 @@ test('preview route: real types inside a CSP sandbox, still jailed', async () =>
372
372
  await writeFile(path.join(dir, 'styles.css'), 'body{margin:0}');
373
373
  await writeFile(path.join(dir, 'app.js'), 'fetch("data.json")');
374
374
  const server = http.createServer((req, res) => {
375
- void handleDeckRoute(req, res, new URL(req.url ?? '/', 'http://x'), async (id) => id === 'run-1' ? { folder: dir } : null)
375
+ void handleDeckRoute(req, res, new URL(req.url ?? '/', 'http://x'), async (_scope, id) => id === 'run-1' ? { folder: dir } : null)
376
376
  .then((handled) => { if (!handled) { res.statusCode = 404; res.end(); } });
377
377
  });
378
378
  await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));