@amenophis1er/foreman 0.1.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@amenophis1er/foreman",
3
- "version": "0.1.7",
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`; }
@@ -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
+ }
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));
package/src/deck.ts CHANGED
@@ -759,6 +759,25 @@ async function artifactsFor(folder: string, runId: string, since: number | undef
759
759
  return out.slice(0, ARTIFACT_CAP);
760
760
  }
761
761
 
762
+ /** Listed files per project tree. */
763
+ const TREE_CAP = 2_000;
764
+
765
+ /**
766
+ * The project's working tree as it stands: every file the deck would consider,
767
+ * minus `.foreman`, which is Foreman's own. No baseline, no diff — this is the
768
+ * view for a project between missions, where "what is in this folder" is the
769
+ * question and every run shares the answer.
770
+ */
771
+ export async function projectTree(folder: string): Promise<{ files: DeckArtifact[]; truncated: boolean }> {
772
+ const { files, truncated } = await walk(folder, { skipWork: true, limit: TREE_CAP });
773
+ const out: DeckArtifact[] = [];
774
+ for (const f of files) {
775
+ if (f.rel === '.foreman' || f.rel.startsWith('.foreman/')) continue;
776
+ out.push({ path: f.rel, kind: artifactKind(f.rel), size: f.size, mtimeMs: f.mtimeMs });
777
+ }
778
+ return { files: out, truncated };
779
+ }
780
+
762
781
  /**
763
782
  * Resolve an artifact path for serving, or null if it is not something the
764
783
  * deck may hand out. The jail is the realpath of `folder`: `..`, absolute
@@ -820,30 +839,37 @@ function sendJson(res: ServerResponse, code: number, body: unknown): void {
820
839
  }
821
840
 
822
841
  /**
823
- * `GET /runs/{id}/deck`, `GET /runs/{id}/artifact?path=<rel>` and
824
- * `GET /runs/{id}/preview/<rel>` (sandboxed render, see below). Returns
842
+ * `GET /runs/{id}/deck`, `GET /projects/{id}/tree`, `GET …/artifact?path=<rel>`
843
+ * and `GET …/preview/<rel>` (sandboxed render, see below). Returns
825
844
  * true when the URL was one of ours (whatever the outcome), false so the
826
845
  * caller's router falls through. `lookup` maps a run id to its folder; null
827
846
  * means unknown run, which is a 404 rather than an error.
828
847
  */
829
848
  export async function handleDeckRoute(
830
849
  req: IncomingMessage, res: ServerResponse, url: URL,
831
- lookup: (runId: string) => Promise<{ folder: string } | null>,
850
+ lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string } | null>,
832
851
  ): Promise<boolean> {
833
- const m = url.pathname.match(/^\/runs\/([^/]+)\/(deck|artifact|preview)(?:\/(.*))?$/);
852
+ // The same jail and the same viewer serve two scopes: a run (its deck,
853
+ // relative to a baseline) and a project (its tree as it stands, no baseline).
854
+ const m = url.pathname.match(/^\/(runs|projects)\/([^/]+)\/(deck|tree|artifact|preview)(?:\/(.*))?$/);
834
855
  if (!m) return false;
835
- const [, runId, what, previewRel] = m;
856
+ const [, scope, runId, what, previewRel] = m;
836
857
  if ((what === 'preview') !== (previewRel !== undefined)) return false;
858
+ if ((what === 'deck' && scope !== 'runs') || (what === 'tree' && scope !== 'projects')) return false;
837
859
  try {
838
860
  if (req.method !== 'GET') { sendJson(res, 405, { error: 'method not allowed' }); return true; }
839
861
  if (!RUN_ID_RE.test(runId)) { sendJson(res, 404, { error: 'not found' }); return true; }
840
- const run = await lookup(runId);
862
+ const run = await lookup(scope as 'runs' | 'projects', runId);
841
863
  if (!run) { sendJson(res, 404, { error: 'not found' }); return true; }
842
864
 
843
865
  if (what === 'deck') {
844
866
  sendJson(res, 200, await deckFor(run.folder, runId));
845
867
  return true;
846
868
  }
869
+ if (what === 'tree') {
870
+ sendJson(res, 200, await projectTree(run.folder));
871
+ return true;
872
+ }
847
873
 
848
874
  if (what === 'preview') {
849
875
  // A rendered look at an HTML artifact. Files come out with their real
@@ -152,8 +152,9 @@ that project's planner; otherwise it comes to you.
152
152
  WHAT YOU CAN DO — through the tools, nothing else:
153
153
  - list_projects / project_detail / run_report: answer "how is X doing",
154
154
  "what needs me", "what happened to Y".
155
- - create_project / link_project: a new folder under the projects root, or
156
- an existing one, linked into the fleet.
155
+ - create_project / link_project: a new folder under the projects root, an
156
+ existing one, or a Git repository cloned under the root (link_project
157
+ with the URL), linked into the fleet.
157
158
  - open_planning: hand a request about an EXISTING codebase to that
158
159
  project's planner, which can read the folder. Use this whenever the right
159
160
  mission depends on what is already there. After you call it, that planner
@@ -280,8 +281,8 @@ export async function runFleetTurn(turn: FleetTurn): Promise<FleetResult> {
280
281
  tool('create_project', 'Create a new folder under the projects root and link it as a project. Use when the human wants to start something that has no home yet.',
281
282
  { name: z.string().describe('What to call it; becomes the folder name') },
282
283
  async ({ name }) => text(await safe(() => host.createProject(name)))),
283
- tool('link_project', 'Link an existing folder as a project. The folder must already exist.',
284
- { folder: z.string().describe('Absolute path, or ~/…') },
284
+ tool('link_project', 'Link an existing folder as a project — or clone a Git repository (a GitHub URL, a git@ URL, or owner/repo) under the projects root and link it. Cloning runs as the human with their own git credentials and can take a minute; wait for the result.',
285
+ { folder: z.string().describe('Absolute path, ~/…, or a Git repository URL') },
285
286
  async ({ folder }) => text(await safe(() => host.linkProject(folder)))),
286
287
  tool('open_planning', 'Hand the request to that project\'s planner, which can read the folder and will propose a mission. After this, the planner owns the conversation: tell the human in one line and stop.',
287
288
  {
@@ -0,0 +1,95 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { mkdtemp, writeFile } from 'node:fs/promises';
7
+ import { closeMissionBranch, ensureMissionBranch, gitInfo, missionBranchName, startMissionBranch } from './gitwork.js';
8
+
9
+ const sh = (cwd: string, ...args: string[]) => execFileSync('git', args, { cwd, stdio: 'pipe', env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null' } }).toString();
10
+
11
+ async function repo(): Promise<string> {
12
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'gitwork-'));
13
+ sh(dir, 'init', '-q', '-b', 'main');
14
+ sh(dir, 'config', 'user.email', 'me@example.com');
15
+ sh(dir, 'config', 'user.name', 'Me');
16
+ await writeFile(path.join(dir, 'README.md'), 'hello\n');
17
+ sh(dir, 'add', '-A'); sh(dir, 'commit', '-q', '-m', 'init');
18
+ return dir;
19
+ }
20
+
21
+ test('missionBranchName: readable, bounded, unique per run', () => {
22
+ assert.equal(missionBranchName('Build Tick, a vanilla Pomodoro timer\nmore', '1788713434983-226123af'), 'foreman/build-tick-a-vanilla-pomodoro-timer-23af');
23
+ assert.match(missionBranchName(' \n!!!', 'run-xyz9'), /^foreman\/mission-xyz9$/);
24
+ assert.ok(missionBranchName('a '.repeat(80), 'r1').length < 60);
25
+ });
26
+
27
+ test('gitInfo: a plain folder is not a repo; a repo reports branch, head, dirty, remote', async () => {
28
+ const plain = await mkdtemp(path.join(os.tmpdir(), 'gitwork-plain-'));
29
+ assert.deepEqual(await gitInfo(plain), { repo: false });
30
+ const dir = await repo();
31
+ const info = await gitInfo(dir);
32
+ assert.equal(info.repo, true); assert.equal(info.branch, 'main'); assert.equal(info.dirty, false); assert.ok(info.head);
33
+ sh(dir, 'remote', 'add', 'origin', 'git@github.com:acme/widget.git');
34
+ await writeFile(path.join(dir, 'x.txt'), 'x');
35
+ const again = await gitInfo(dir);
36
+ assert.equal(again.dirty, true); assert.equal(again.remote, 'git@github.com:acme/widget.git');
37
+ });
38
+
39
+ test('a mission gets its own branch from the current one, and closing commits the work on it', async () => {
40
+ const dir = await repo();
41
+ const g = await startMissionBranch(dir, 'Add a footer to the page', 'run-1234abcd');
42
+ assert.ok(!('error' in g), JSON.stringify(g));
43
+ if ('error' in g) return;
44
+ assert.equal(g.branch, 'foreman/add-a-footer-to-the-page-abcd');
45
+ assert.equal(g.base, 'main');
46
+ assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), g.branch);
47
+
48
+ // Nothing to commit yet: no commit, zero commits ahead.
49
+ const idle = await closeMissionBranch(dir, g, 'foreman: nothing');
50
+ assert.equal(idle.committed, false); assert.equal(idle.commits, 0); assert.equal(idle.error, undefined);
51
+
52
+ await writeFile(path.join(dir, 'footer.html'), '<footer/>');
53
+ const closed = await closeMissionBranch(dir, g, 'foreman: Add a footer');
54
+ assert.equal(closed.committed, true); assert.equal(closed.commits, 1); assert.ok(closed.commit);
55
+ assert.equal(sh(dir, 'status', '--porcelain').trim(), '');
56
+ assert.match(sh(dir, 'log', '-1', '--format=%s'), /^foreman: Add a footer/);
57
+ // main is untouched.
58
+ assert.equal(sh(dir, 'rev-list', '--count', 'main').trim(), '1');
59
+ });
60
+
61
+ test('ensureMissionBranch goes back to the branch for a resume, and says why when it cannot', async () => {
62
+ const dir = await repo();
63
+ const g = await startMissionBranch(dir, 'Thing', 'run-1');
64
+ if ('error' in g) throw new Error(g.error);
65
+ sh(dir, 'checkout', '-q', 'main');
66
+ assert.equal(await ensureMissionBranch(dir, g.branch), null);
67
+ assert.equal(sh(dir, 'rev-parse', '--abbrev-ref', 'HEAD').trim(), g.branch);
68
+ assert.match((await ensureMissionBranch(dir, 'foreman/does-not-exist')) ?? '', /checkout/);
69
+ });
70
+
71
+ test('startMissionBranch on a plain folder says so instead of throwing', async () => {
72
+ const plain = await mkdtemp(path.join(os.tmpdir(), 'gitwork-plain2-'));
73
+ assert.deepEqual(await startMissionBranch(plain, 'x', 'r'), { error: 'not a git repository' });
74
+ });
75
+
76
+ test('remoteWebUrl and compareUrl: the three hosts people use, and a plain page elsewhere', async () => {
77
+ const { remoteWebUrl, compareUrl } = await import('./gitwork.js');
78
+ assert.deepEqual(remoteWebUrl('git@github.com:acme/widget.git'), { host: 'github.com', path: 'acme/widget', web: 'https://github.com/acme/widget' });
79
+ assert.deepEqual(remoteWebUrl('https://gitlab.com/group/sub/widget.git'), { host: 'gitlab.com', path: 'group/sub/widget', web: 'https://gitlab.com/group/sub/widget' });
80
+ assert.equal(remoteWebUrl(undefined), null);
81
+ assert.equal(compareUrl('git@github.com:acme/widget.git', 'main', 'foreman/x-1'), 'https://github.com/acme/widget/compare/main...foreman%2Fx-1?expand=1');
82
+ assert.match(compareUrl('https://gitlab.com/g/w.git', 'main', 'foreman/x') ?? '', /merge_requests\/new\?merge_request%5Bsource_branch%5D=foreman%2Fx/);
83
+ assert.match(compareUrl('git@bitbucket.org:t/w.git', 'main', 'foreman/x') ?? '', /pull-requests\/new\?source=foreman%2Fx&dest=main/);
84
+ assert.equal(compareUrl('https://git.example.com/a/b', 'main', 'x'), 'https://git.example.com/a/b');
85
+ });
86
+
87
+ test('prDraft: the run title, the brief, the boxes as the mission left them, and a footer', async () => {
88
+ const { prDraft } = await import('./gitwork.js');
89
+ const d = prDraft({ title: 'Add a footer', mission: 'Add a footer to the page.\nKeep it small.', costUsd: 0.42, costBasis: 'priced', git: { branch: 'foreman/add-a-footer-ab12', base: 'main', baseHead: null } },
90
+ '# Mission\n- [x] footer.html exists\n- [ ] linked from index\n');
91
+ assert.equal(d.title, 'Add a footer');
92
+ assert.match(d.body, /## Mission\n\nAdd a footer to the page\.\nKeep it small\./);
93
+ assert.match(d.body, /## Done when\n\n- \[x\] footer\.html exists\n- \[ \] linked from index/);
94
+ assert.match(d.body, /branch `foreman\/add-a-footer-ab12` from `main` · spend \$0\.42/);
95
+ });