@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 +9 -3
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +6 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -0
- package/src/deck.test.ts +2 -2
- package/src/deck.ts +32 -6
- package/src/fleet-planner.ts +5 -4
- package/src/gitwork.test.ts +95 -0
- package/src/gitwork.ts +239 -0
- package/src/guard.test.ts +104 -0
- package/src/guard.ts +131 -0
- package/src/http-body.test.ts +42 -0
- package/src/http-body.ts +57 -0
- package/src/memory.test.ts +46 -0
- package/src/memory.ts +107 -0
- package/src/notify/commands.ts +1 -1
- package/src/notify/telegram.ts +1 -1
- package/src/notify.ts +5 -0
- package/src/orchestrator.test.ts +104 -0
- package/src/orchestrator.ts +109 -47
- package/src/planner.ts +7 -2
- package/src/preflight.ts +29 -43
- package/src/role-provider.test.ts +55 -0
- package/src/role-provider.ts +65 -0
- package/src/server.ts +548 -63
- package/src/services.test.ts +36 -1
- package/src/services.ts +60 -3
- package/src/store.test.ts +18 -0
- package/src/store.ts +16 -1
- package/src/track-record.test.ts +46 -0
- package/src/track-record.ts +149 -0
- package/src/types.ts +17 -0
- package/ui/dist/assets/index-h0osI3Nn.js +68 -0
- package/ui/dist/index.html +1 -1
- package/ui/dist/assets/index-BPFc7O5V.js +0 -67
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
|
|
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 #
|
|
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.
|
|
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 /
|
|
824
|
-
* `GET
|
|
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: (
|
|
850
|
+
lookup: (scope: 'runs' | 'projects', id: string) => Promise<{ folder: string } | null>,
|
|
832
851
|
): Promise<boolean> {
|
|
833
|
-
|
|
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
|
package/src/fleet-planner.ts
CHANGED
|
@@ -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,
|
|
156
|
-
|
|
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.
|
|
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
|
+
});
|