@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 +9 -3
- package/bin/foreman.mjs +2 -1
- package/package.json +1 -1
- package/src/browser.ts +188 -0
- package/src/cli.ts +19 -1
- package/src/clone.test.ts +35 -0
- package/src/clone.ts +129 -0
- package/src/completion.test.ts +30 -0
- package/src/completion.ts +113 -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 +569 -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-btSPOnoZ.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/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.
|
|
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));
|