@agent-flow-board/bridge 0.1.0 → 0.3.0
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 +29 -1
- package/dist/checks/appdir.js +105 -0
- package/dist/checks/approve.js +213 -0
- package/dist/checks/audit.js +126 -0
- package/dist/checks/bind.js +100 -0
- package/dist/checks/board.js +107 -0
- package/dist/checks/broker.js +439 -0
- package/dist/checks/browser.js +275 -0
- package/dist/checks/browserpool.js +221 -0
- package/dist/checks/cache.js +710 -0
- package/dist/checks/child.js +211 -0
- package/dist/checks/command.js +267 -0
- package/dist/checks/commands.js +219 -0
- package/dist/checks/connect.js +79 -0
- package/dist/checks/connections.js +42 -0
- package/dist/checks/copy.js +320 -0
- package/dist/checks/dbpool.js +710 -0
- package/dist/checks/doctor.js +649 -0
- package/dist/checks/envPolicy.js +212 -0
- package/dist/checks/envrules.js +322 -0
- package/dist/checks/evidence.js +114 -0
- package/dist/checks/fsretry.js +29 -0
- package/dist/checks/guard.js +188 -0
- package/dist/checks/guardrails.js +155 -0
- package/dist/checks/importlegacy.js +300 -0
- package/dist/checks/install.js +150 -0
- package/dist/checks/live.js +76 -0
- package/dist/checks/lock.js +207 -0
- package/dist/checks/login.js +203 -0
- package/dist/checks/mailguard-hook.cjs +216 -0
- package/dist/checks/mailguard.js +98 -0
- package/dist/checks/paths.js +85 -0
- package/dist/checks/preview.js +577 -0
- package/dist/checks/profiles.js +110 -0
- package/dist/checks/provision.js +175 -0
- package/dist/checks/recipe.js +246 -0
- package/dist/checks/redact.js +52 -0
- package/dist/checks/registry.js +548 -0
- package/dist/checks/repoenv.js +162 -0
- package/dist/checks/roots.js +144 -0
- package/dist/checks/routing.js +68 -0
- package/dist/checks/run.js +443 -0
- package/dist/checks/sandbox.js +208 -0
- package/dist/checks/scheduler.js +383 -0
- package/dist/checks/scope.js +383 -0
- package/dist/checks/sealedNames.js +39 -0
- package/dist/checks/secrets.js +452 -0
- package/dist/checks/secretscmd.js +80 -0
- package/dist/checks/service.js +589 -0
- package/dist/checks/signin.js +79 -0
- package/dist/checks/singleflight.js +307 -0
- package/dist/checks/store.js +92 -0
- package/dist/checks/surface.js +124 -0
- package/dist/checks/types.js +2 -0
- package/dist/checks/warm.js +342 -0
- package/dist/checks/watch.js +228 -0
- package/dist/checks/wire.js +87 -0
- package/dist/cli.js +18 -1
- package/dist/hooks/adapters.js +3 -1
- package/dist/hooks/call.js +6 -1
- package/dist/hooks/modVersion.js +26 -0
- package/package.json +2 -2
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// THE COMMANDS `check project`, `check approve` AND `check roots`. Plain handlers: they take the arguments after the command word and a small context, print through it and return
|
|
2
|
+
// an exit code (0 done, 1 refused with the problems listed, 2 could not run). Nothing here reads a terminal. `check approve` only asks whether it IS on a terminal, because a code is
|
|
3
|
+
// made by the owner and never by an agent.
|
|
4
|
+
// -- DEP-MODULES -- //
|
|
5
|
+
import { isatty } from 'node:tty';
|
|
6
|
+
// -- MODULES -- //
|
|
7
|
+
import { APPROVAL_ACTIONS, Refusal, UsageError, isApprovalAction, mintApproval } from './approve.js';
|
|
8
|
+
import { forgetRegistration, hasState, pinRegistration, pinState, readRegistrations, registerProject } from './registry.js';
|
|
9
|
+
import { addRoot, listRoots, readRoots, removeRoot } from './roots.js';
|
|
10
|
+
// -- CONSTANTS -- //
|
|
11
|
+
const BOOLEAN_FLAGS = new Set([
|
|
12
|
+
'--recipe',
|
|
13
|
+
'--remove',
|
|
14
|
+
'--adopt-env',
|
|
15
|
+
'--upload-pictures',
|
|
16
|
+
'--json'
|
|
17
|
+
]);
|
|
18
|
+
// -- FUNCTIONS -- //
|
|
19
|
+
function parse(args) {
|
|
20
|
+
const pos = [];
|
|
21
|
+
const flags = new Map();
|
|
22
|
+
for (let i = 0; i < args.length; i++) {
|
|
23
|
+
const a = args[i] ?? '';
|
|
24
|
+
if (!a.startsWith('--')) {
|
|
25
|
+
pos.push(a);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
const eq = a.indexOf('=');
|
|
29
|
+
if (eq > 0) {
|
|
30
|
+
flags.set(a.slice(0, eq), a.slice(eq + 1));
|
|
31
|
+
}
|
|
32
|
+
else if (BOOLEAN_FLAGS.has(a)) {
|
|
33
|
+
flags.set(a, true);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const next = args[i + 1];
|
|
37
|
+
if (next === undefined || next.startsWith('--'))
|
|
38
|
+
throw new UsageError(`${a} needs a value.`);
|
|
39
|
+
flags.set(a, next);
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { pos, flags };
|
|
44
|
+
}
|
|
45
|
+
const text = (p, name) => {
|
|
46
|
+
const v = p.flags.get(name);
|
|
47
|
+
return typeof v === 'string' ? v : undefined;
|
|
48
|
+
};
|
|
49
|
+
function need(p, name) {
|
|
50
|
+
const v = text(p, name);
|
|
51
|
+
if (!v)
|
|
52
|
+
throw new UsageError(`${name} is needed.`);
|
|
53
|
+
return v;
|
|
54
|
+
}
|
|
55
|
+
/** Runs a handler body and turns what it throws into an exit code and one plain line. */
|
|
56
|
+
async function guarded(ctx, body) {
|
|
57
|
+
try {
|
|
58
|
+
return await body();
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
62
|
+
ctx.log(message);
|
|
63
|
+
return e instanceof Refusal ? 1 : 2;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const show = (ctx, value, lines) => {
|
|
67
|
+
if (ctx.json)
|
|
68
|
+
ctx.out(JSON.stringify(value));
|
|
69
|
+
else
|
|
70
|
+
for (const l of lines)
|
|
71
|
+
ctx.out(l);
|
|
72
|
+
};
|
|
73
|
+
export const PROJECT_USAGE = [
|
|
74
|
+
' project <NAME> --org <slug> --repo <dir> [--app-dir web] [--board-project KEY] [--recipe] [--extra-hosts a,b]',
|
|
75
|
+
' project <NAME> --org <slug> --url https://site [--board-project KEY] [--extra-hosts a,b] --approval <code>',
|
|
76
|
+
' project list [--org <slug>] | project forget <NAME> --org <slug> [--remove] | project pin <NAME> --org <slug>'
|
|
77
|
+
].join('\n');
|
|
78
|
+
/** `check project ...`: register, list, forget or pin a registration. */
|
|
79
|
+
export async function projectCommand(args, ctx) {
|
|
80
|
+
return guarded(ctx, async () => {
|
|
81
|
+
const p = parse(args);
|
|
82
|
+
const json = ctx.json || p.flags.has('--json');
|
|
83
|
+
const c = { ...ctx, json };
|
|
84
|
+
const sub = p.pos[0];
|
|
85
|
+
if (!sub)
|
|
86
|
+
throw new UsageError(`Say what to do.\n${PROJECT_USAGE}`);
|
|
87
|
+
if (sub === 'list') {
|
|
88
|
+
const org = text(p, '--org');
|
|
89
|
+
const { registrations, problems } = readRegistrations(org);
|
|
90
|
+
const rows = registrations.map((r) => ({
|
|
91
|
+
org: r.org,
|
|
92
|
+
name: r.name,
|
|
93
|
+
boardProject: r.boardProject,
|
|
94
|
+
kind: r.url ? 'site' : 'repo',
|
|
95
|
+
repo: r.repo ?? null,
|
|
96
|
+
appDir: r.appDir ?? null,
|
|
97
|
+
url: r.url ?? null,
|
|
98
|
+
pin: pinState(r),
|
|
99
|
+
adoptEnv: r.adoptEnv,
|
|
100
|
+
state: hasState(r.org, r.name)
|
|
101
|
+
}));
|
|
102
|
+
show(c, { registrations: rows, problems }, [
|
|
103
|
+
...rows.map((r) => `${r.org}/${r.name} serves ${r.boardProject} ${r.url ?? `${r.repo}${r.appDir ? ` (${r.appDir})` : ''}`} pin ${r.pin}${r.adoptEnv ? ' adopts .env' : ''}${r.state ? ' holds state' : ''}`),
|
|
104
|
+
...(rows.length ? [] : ['No registrations.']),
|
|
105
|
+
...problems
|
|
106
|
+
]);
|
|
107
|
+
return problems.length ? 1 : 0;
|
|
108
|
+
}
|
|
109
|
+
if (sub === 'forget') {
|
|
110
|
+
const name = p.pos[1];
|
|
111
|
+
if (!name)
|
|
112
|
+
throw new UsageError('forget needs a registration name.');
|
|
113
|
+
const r = forgetRegistration(need(p, '--org'), name, { remove: p.flags.has('--remove') });
|
|
114
|
+
show(c, r, [
|
|
115
|
+
`${r.removed ? 'Removed' : 'Forgot the saved state of'} ${r.name}${r.hadState ? '' : ' (it held none)'}.`
|
|
116
|
+
]);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
if (sub === 'pin') {
|
|
120
|
+
const name = p.pos[1];
|
|
121
|
+
if (!name)
|
|
122
|
+
throw new UsageError('pin needs a registration name.');
|
|
123
|
+
const r = pinRegistration(need(p, '--org'), name);
|
|
124
|
+
show(c, { ...r, before: r.before?.slice(0, 12) ?? null, after: r.after.slice(0, 12) }, [
|
|
125
|
+
r.changed
|
|
126
|
+
? `Pinned ${r.name}: ${r.before ? r.before.slice(0, 12) : 'none'} -> ${r.after.slice(0, 12)} (${r.files.join(', ') || 'no files'}).`
|
|
127
|
+
: `${r.name} is already pinned (${r.after.slice(0, 12)}).`
|
|
128
|
+
]);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
131
|
+
if (p.pos.length > 1)
|
|
132
|
+
throw new UsageError(`Unexpected "${p.pos[1]}".\n${PROJECT_USAGE}`);
|
|
133
|
+
const hosts = text(p, '--extra-hosts');
|
|
134
|
+
const input = {
|
|
135
|
+
org: need(p, '--org'),
|
|
136
|
+
name: sub,
|
|
137
|
+
...(text(p, '--board-project') ? { boardProject: text(p, '--board-project') } : {}),
|
|
138
|
+
...(text(p, '--repo') ? { repo: text(p, '--repo') } : {}),
|
|
139
|
+
...(text(p, '--app-dir') ? { appDir: text(p, '--app-dir') } : {}),
|
|
140
|
+
...(text(p, '--url') ? { url: text(p, '--url') } : {}),
|
|
141
|
+
...(hosts
|
|
142
|
+
? {
|
|
143
|
+
extraHosts: hosts
|
|
144
|
+
.split(',')
|
|
145
|
+
.map((h) => h.trim())
|
|
146
|
+
.filter(Boolean)
|
|
147
|
+
}
|
|
148
|
+
: {}),
|
|
149
|
+
recipe: p.flags.has('--recipe'),
|
|
150
|
+
adoptEnv: p.flags.has('--adopt-env'),
|
|
151
|
+
uploadSignedInPictures: p.flags.has('--upload-pictures')
|
|
152
|
+
};
|
|
153
|
+
const approvals = {
|
|
154
|
+
...(text(p, '--approval') ? { register: text(p, '--approval') } : {}),
|
|
155
|
+
...(text(p, '--adopt-env-approval')
|
|
156
|
+
? { adoptEnv: text(p, '--adopt-env-approval') }
|
|
157
|
+
: {}),
|
|
158
|
+
...(text(p, '--pictures-approval')
|
|
159
|
+
? { pictures: text(p, '--pictures-approval') }
|
|
160
|
+
: {})
|
|
161
|
+
};
|
|
162
|
+
const reg = registerProject(input, approvals);
|
|
163
|
+
show(c, { org: reg.org, name: reg.name, boardProject: reg.boardProject, database: reg.database }, [
|
|
164
|
+
`Registered ${reg.org}/${reg.name} for ${reg.boardProject}${reg.url ? ` (live site ${new URL(reg.url).hostname})` : ''}. The files that decide what runs are pinned by "check project pin ${reg.name} --org ${reg.org}" or the first run in the folder.`
|
|
165
|
+
]);
|
|
166
|
+
return 0;
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* `check approve --action <register|secrets|adopt-env|roots|pictures> --target <name or folder>`: the owner's single-use code. Refused without an interactive terminal.
|
|
171
|
+
* `env.isTTY` is for the tests; the command itself passes the real flag of the terminal.
|
|
172
|
+
*/
|
|
173
|
+
export async function approveCommand(args, ctx, env = {}) {
|
|
174
|
+
return guarded(ctx, () => {
|
|
175
|
+
const p = parse(args);
|
|
176
|
+
const action = need(p, '--action');
|
|
177
|
+
if (!isApprovalAction(action))
|
|
178
|
+
throw new UsageError(`--action is one of ${APPROVAL_ACTIONS.join(', ')}.`);
|
|
179
|
+
const target = need(p, '--target');
|
|
180
|
+
const isTTY = env.isTTY ?? (isatty(0) && isatty(1));
|
|
181
|
+
const made = mintApproval(action, target, { isTTY });
|
|
182
|
+
show(ctx, { action, target, code: made.code, expiresAt: made.expiresAt }, [
|
|
183
|
+
`Approval code for ${action} on ${target}: ${made.code}`,
|
|
184
|
+
`It works once and expires at ${made.expiresAt}. Give it to the command that asked for it, with --approval.`
|
|
185
|
+
]);
|
|
186
|
+
return 0;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
/** `check roots add <dir> --approval <code> | list | remove <dir> --approval <code>`. */
|
|
190
|
+
export async function rootsCommand(args, ctx) {
|
|
191
|
+
return guarded(ctx, () => {
|
|
192
|
+
const p = parse(args);
|
|
193
|
+
const sub = p.pos[0] ?? 'list';
|
|
194
|
+
if (sub === 'list') {
|
|
195
|
+
const { roots, problem } = readRoots();
|
|
196
|
+
show(ctx, { roots, problem }, [
|
|
197
|
+
...(roots.length
|
|
198
|
+
? roots
|
|
199
|
+
: ['No roots. The owner adds one: check roots add <folder> --approval <code>.']),
|
|
200
|
+
...(problem ? [problem] : [])
|
|
201
|
+
]);
|
|
202
|
+
return problem ? 1 : 0;
|
|
203
|
+
}
|
|
204
|
+
const dir = p.pos[1];
|
|
205
|
+
if (!dir)
|
|
206
|
+
throw new UsageError(`${sub} needs a folder.`);
|
|
207
|
+
if (sub === 'add') {
|
|
208
|
+
const real = addRoot(dir, text(p, '--approval'));
|
|
209
|
+
show(ctx, { root: real, roots: listRoots() }, [`Root added: ${real}`]);
|
|
210
|
+
return 0;
|
|
211
|
+
}
|
|
212
|
+
if (sub === 'remove') {
|
|
213
|
+
const real = removeRoot(dir, text(p, '--approval'));
|
|
214
|
+
show(ctx, { root: real, roots: listRoots() }, [`Root removed: ${real}`]);
|
|
215
|
+
return 0;
|
|
216
|
+
}
|
|
217
|
+
throw new UsageError('Use: roots add <dir> --approval <code> | roots list | roots remove <dir> --approval <code>');
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// CONNECTING THIS MACHINE TO A BOARD AS A CHECK RUNNER
|
|
2
|
+
// One device sign in under the client name afb-checks (an agent approves it with tool_connection_approve and the verifier, or a person types the
|
|
3
|
+
// code on the /device page), one call that makes this machine a runner of the organisation, then only the runner token is kept. The personal
|
|
4
|
+
// token is forgotten at once. The runner token has its own entry in the token store, apart from the sign in of the hooks and MCP.
|
|
5
|
+
// -- DEP-MODULES -- //
|
|
6
|
+
import { hostname } from 'node:os';
|
|
7
|
+
// -- MODULES -- //
|
|
8
|
+
import { deviceLogin } from '../device.js';
|
|
9
|
+
import { accountKey } from '../tokenStore.js';
|
|
10
|
+
// -- CONSTANTS -- //
|
|
11
|
+
/** The client name the board approves with the manage permission, used once to make the runner. */
|
|
12
|
+
export const TOOL_CLIENT_NAME = 'afb-checks';
|
|
13
|
+
// -- FUNCTIONS -- //
|
|
14
|
+
/** The token store entry of the runner token for a server and organisation. */
|
|
15
|
+
export const runnerAccount = (server, org) => `${accountKey(server, org)}.checks`;
|
|
16
|
+
/**
|
|
17
|
+
* A name for this machine's runner. A repeat of the same owner and name replaces the old token on the board, so this name is different from the
|
|
18
|
+
* one the standalone afb-checks tool uses on the same machine: connecting the bridge must never cut that tool off.
|
|
19
|
+
*/
|
|
20
|
+
export function runnerName() {
|
|
21
|
+
const host = hostname()
|
|
22
|
+
.replace(/[^A-Za-z0-9 ._-]/g, '')
|
|
23
|
+
.slice(0, 36) || 'this-machine';
|
|
24
|
+
return `${host} agent-flow-board`.slice(0, 60);
|
|
25
|
+
}
|
|
26
|
+
/** The runner token for a server and organisation, or null when this machine has not been connected. AFB_CHECKS_TOKEN overrides it, for tests. */
|
|
27
|
+
export function loadRunnerToken(store, server, org) {
|
|
28
|
+
return process.env.AFB_CHECKS_TOKEN ?? store.get(runnerAccount(server, org));
|
|
29
|
+
}
|
|
30
|
+
/** Makes this machine a runner of the organisation. Never prints a token. */
|
|
31
|
+
export async function connectChecks(options) {
|
|
32
|
+
const url = new URL(options.server);
|
|
33
|
+
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))
|
|
34
|
+
throw new Error('The board address must start with https:// (only an address on this machine may use http).');
|
|
35
|
+
const personal = await deviceLogin(options.server, options.fetch, options.log, {
|
|
36
|
+
name: TOOL_CLIENT_NAME,
|
|
37
|
+
pkce: true,
|
|
38
|
+
wait: options.wait,
|
|
39
|
+
onCode: (c) => {
|
|
40
|
+
if (options.json)
|
|
41
|
+
options.out(JSON.stringify({
|
|
42
|
+
event: 'code',
|
|
43
|
+
userCode: c.userCode,
|
|
44
|
+
verificationUri: c.verificationUri,
|
|
45
|
+
verifier: c.verifier,
|
|
46
|
+
expiresIn: c.expiresInSeconds
|
|
47
|
+
}));
|
|
48
|
+
else
|
|
49
|
+
options.log('On that page choose your organisation and tick "Change project settings" (manage). This is used once, to make this machine a runner.');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
const name = runnerName();
|
|
53
|
+
const projects = options.projects?.length ? options.projects : undefined;
|
|
54
|
+
const res = await options.fetch(`${options.server}/api/v1/orgs/${encodeURIComponent(options.org)}/runners/tool`, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
authorization: `Bearer ${personal.token}`,
|
|
58
|
+
'content-type': 'application/json'
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({ name, ...(projects ? { projects } : {}) }),
|
|
61
|
+
signal: AbortSignal.timeout(30_000)
|
|
62
|
+
});
|
|
63
|
+
const body = (await res.json().catch(() => ({})));
|
|
64
|
+
if (!res.ok || !body.token)
|
|
65
|
+
throw new Error(body.error?.message ?? `The board answered ${res.status} when making this machine a runner.`);
|
|
66
|
+
options.store.set(runnerAccount(options.server, options.org), body.token);
|
|
67
|
+
const served = projects ? projects.join(', ') : 'every project of the organisation you may use';
|
|
68
|
+
if (options.json)
|
|
69
|
+
options.out(JSON.stringify({
|
|
70
|
+
event: 'connected',
|
|
71
|
+
server: options.server,
|
|
72
|
+
org: options.org,
|
|
73
|
+
name,
|
|
74
|
+
projects: projects ?? 'all'
|
|
75
|
+
}));
|
|
76
|
+
else
|
|
77
|
+
options.log(`Connected as "${name}". It serves ${served}.`);
|
|
78
|
+
return { name, served };
|
|
79
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// THE BOARDS THIS MACHINE IS CONNECTED TO FOR CHECKS: SERVER AND ORGANISATION ONLY, NEVER A TOKEN (THE TOKEN IS IN THE TOKEN STORE)
|
|
2
|
+
// `check watch` re-reads this list on every loop, so a `check connect` for a second organisation is picked up without a restart.
|
|
3
|
+
// -- DEP-MODULES -- //
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
// -- MODULES -- //
|
|
7
|
+
import { checksHome, ensureDir, ORG_SLUG } from './paths.js';
|
|
8
|
+
import { renameRetry } from './fsretry.js';
|
|
9
|
+
// -- FUNCTIONS -- //
|
|
10
|
+
const file = () => join(checksHome(), 'connections.json');
|
|
11
|
+
export function listConnections() {
|
|
12
|
+
if (!existsSync(file()))
|
|
13
|
+
return [];
|
|
14
|
+
try {
|
|
15
|
+
const v = JSON.parse(readFileSync(file(), 'utf8'));
|
|
16
|
+
return Array.isArray(v)
|
|
17
|
+
? v.filter((c) => !!c &&
|
|
18
|
+
typeof c.server === 'string' &&
|
|
19
|
+
typeof c.org === 'string' &&
|
|
20
|
+
ORG_SLUG.test(c.org) &&
|
|
21
|
+
typeof c.name === 'string')
|
|
22
|
+
: [];
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Adds or replaces the connection for a server and organisation. Written whole, never half. */
|
|
29
|
+
export function recordConnection(c) {
|
|
30
|
+
const next = [...listConnections().filter((x) => !(x.server === c.server && x.org === c.org)), c];
|
|
31
|
+
ensureDir(checksHome());
|
|
32
|
+
const tmp = `${file()}.${process.pid}.tmp`;
|
|
33
|
+
writeFileSync(tmp, JSON.stringify(next, null, 1), { mode: 0o600 });
|
|
34
|
+
renameRetry(tmp, file());
|
|
35
|
+
}
|
|
36
|
+
export function forgetConnection(server, org) {
|
|
37
|
+
const next = listConnections().filter((x) => !(x.server === server && x.org === org));
|
|
38
|
+
ensureDir(checksHome());
|
|
39
|
+
const tmp = `${file()}.${process.pid}.tmp`;
|
|
40
|
+
writeFileSync(tmp, JSON.stringify(next, null, 1), { mode: 0o600 });
|
|
41
|
+
renameRetry(tmp, file());
|
|
42
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// THE PRIVATE COPY OF A REPO FOR ONE RUN, SO A RUN BUILDS AND SERVES FROM ITS OWN FOLDER AND NEVER FROM THE ONE A PERSON'S PREVIEW SERVES.
|
|
2
|
+
// A git repo with a commit is copied as a local clone (`git clone --local --no-checkout`: nothing is fetched from anywhere else and nothing is
|
|
3
|
+
// written into the repo's own .git, objects are hard linked), with the origin removed and HEAD checked out detached; then only the working-tree
|
|
4
|
+
// changes (tracked and untracked, not ignored) are copied over it, so a run tests what is on disk now. A git repo with no commit is copied from the
|
|
5
|
+
// files git lists; a folder that is not a git repo is copied with caps on files and size. Env files, keys, links and programs named like the
|
|
6
|
+
// package managers are never copied. If HEAD moves while the copy is made, the copy is made once more.
|
|
7
|
+
// -- DEP-MODULES -- //
|
|
8
|
+
import { execFile } from 'node:child_process';
|
|
9
|
+
import { copyFileSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync } from 'node:fs';
|
|
10
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
11
|
+
// -- MODULES -- //
|
|
12
|
+
import { ensureDir, under } from './paths.js';
|
|
13
|
+
// -- CONSTANTS -- //
|
|
14
|
+
/** A file bigger than this is not copied (a build output, a dump, a video). */
|
|
15
|
+
export const MAX_COPY_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
/** A folder that is not a git repo is copied whole, so it may not be a whole disk. */
|
|
17
|
+
export const MAX_PLAIN_FILES = 20_000;
|
|
18
|
+
export const MAX_PLAIN_BYTES = 400 * 1024 * 1024;
|
|
19
|
+
/** Source and document extensions: a file named secrets.ts or credentials.json is code or text a project owns, not a credentials dump, so it is copied. */
|
|
20
|
+
const SOURCE_EXT = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs|svelte|json|md)$/i;
|
|
21
|
+
/**
|
|
22
|
+
* Files that are never copied, by name: secrets, keys, and a program named like the ones a shell would look up. The credentials rule only applies to a file
|
|
23
|
+
* without a source extension, and the program rule only to a real program (a script file such as lib/git.js is source). A path git tracks is never skipped
|
|
24
|
+
* by these (it is already in the commit); env files are the exception, they have their own rule.
|
|
25
|
+
*/
|
|
26
|
+
const NEVER = [
|
|
27
|
+
{ re: /(^|\/)\.env(\.[^/]*)?$/i, why: 'an env file' },
|
|
28
|
+
{ re: /\.(pem|key|p12|pfx|jks|keystore)$/i, why: 'a key file' },
|
|
29
|
+
{ re: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i, why: 'an ssh key' },
|
|
30
|
+
{ re: /(^|\/)(credentials?|secrets?)(\.[^/]*)?$/i, why: 'a credentials file' },
|
|
31
|
+
{
|
|
32
|
+
re: /(^|\/)(yarn|npm|npx|pnpm|node|git)\.(cmd|bat|exe|com|ps1|vbs)$/i,
|
|
33
|
+
why: 'a program named like a tool it would be run as'
|
|
34
|
+
}
|
|
35
|
+
];
|
|
36
|
+
/** An example env file holds no secrets and is copied. */
|
|
37
|
+
const ENV_EXAMPLE = /\.(example|sample|template)$/i;
|
|
38
|
+
/** The reason a path is never copied, or null. Exported for the tests. */
|
|
39
|
+
export function neverCopy(rel, tracked) {
|
|
40
|
+
for (const n of NEVER) {
|
|
41
|
+
if (!n.re.test(rel))
|
|
42
|
+
continue;
|
|
43
|
+
if (n.why === 'an env file') {
|
|
44
|
+
if (ENV_EXAMPLE.test(rel))
|
|
45
|
+
continue;
|
|
46
|
+
return n.why;
|
|
47
|
+
}
|
|
48
|
+
if (tracked)
|
|
49
|
+
continue;
|
|
50
|
+
if (n.why === 'a credentials file' && SOURCE_EXT.test(rel))
|
|
51
|
+
continue;
|
|
52
|
+
return n.why;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const SKIP_DIR = /(^|\/)(\.git|node_modules)(\/|$)/;
|
|
57
|
+
/** Env files a build tool loads on its own in a production build. A committed one is taken out of the copy as well. */
|
|
58
|
+
const TRACKED_ENV = /(^|\/)\.env(\.local|\.production|\.production\.local)?$/i;
|
|
59
|
+
// -- FUNCTIONS -- //
|
|
60
|
+
/** Runs git with an argument list and no shell. Resolves with the exit status; never throws. */
|
|
61
|
+
export function git(cwd, args) {
|
|
62
|
+
return new Promise((resolveResult) => {
|
|
63
|
+
execFile('git', args, {
|
|
64
|
+
cwd,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
encoding: 'buffer',
|
|
67
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
68
|
+
timeout: 10 * 60_000
|
|
69
|
+
}, (err, stdout, stderr) => {
|
|
70
|
+
const code = err
|
|
71
|
+
? typeof err.code === 'number'
|
|
72
|
+
? err.code
|
|
73
|
+
: -1
|
|
74
|
+
: 0;
|
|
75
|
+
resolveResult({
|
|
76
|
+
status: code,
|
|
77
|
+
stdout: stdout,
|
|
78
|
+
stderr: String(stderr ?? '')
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const must = (r, what) => {
|
|
84
|
+
if (r.status !== 0)
|
|
85
|
+
throw new Error(`${what} failed: ${(r.stderr || r.stdout.toString('utf8')).trim().slice(-300)}`);
|
|
86
|
+
return r.stdout;
|
|
87
|
+
};
|
|
88
|
+
/** The commit of HEAD, or null when the folder has no commit (or is not a git repo). */
|
|
89
|
+
export async function headCommit(repo) {
|
|
90
|
+
const r = await git(repo, ['rev-parse', '--verify', 'HEAD']);
|
|
91
|
+
return r.status === 0 ? r.stdout.toString('utf8').trim() || null : null;
|
|
92
|
+
}
|
|
93
|
+
/** The top folder of the git repo `folder` is in, or null when it is in none. */
|
|
94
|
+
export async function gitRoot(folder) {
|
|
95
|
+
const r = await git(folder, ['rev-parse', '--show-toplevel']);
|
|
96
|
+
if (r.status !== 0)
|
|
97
|
+
return null;
|
|
98
|
+
const out = r.stdout.toString('utf8').trim();
|
|
99
|
+
return out ? resolve(out) : null;
|
|
100
|
+
}
|
|
101
|
+
/** The tracked and the untracked-not-ignored files of a repo, as paths relative to it. Null when the folder is not a git repo. */
|
|
102
|
+
export async function listFiles(repo) {
|
|
103
|
+
const r = await git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
|
|
104
|
+
if (r.status !== 0)
|
|
105
|
+
return null;
|
|
106
|
+
return [...new Set(r.stdout.toString('utf8').split('\0').filter(Boolean))];
|
|
107
|
+
}
|
|
108
|
+
/** The changes of a working tree as { deleted, changed } paths relative to the git root, from git itself (NUL separated, so any file name works). */
|
|
109
|
+
export async function workingTreeChanges(root) {
|
|
110
|
+
const out = must(await git(root, ['status', '--porcelain=v1', '-z', '--untracked-files=all']), 'git status').toString('utf8');
|
|
111
|
+
const parts = out.split('\0');
|
|
112
|
+
if (parts.at(-1) === '')
|
|
113
|
+
parts.pop();
|
|
114
|
+
const deleted = [];
|
|
115
|
+
const changed = [];
|
|
116
|
+
for (let i = 0; i < parts.length; i++) {
|
|
117
|
+
const entry = parts[i];
|
|
118
|
+
if (entry.length < 4)
|
|
119
|
+
continue;
|
|
120
|
+
const x = entry[0];
|
|
121
|
+
const y = entry[1];
|
|
122
|
+
const file = entry.slice(3);
|
|
123
|
+
if (x === 'R' || x === 'C') {
|
|
124
|
+
// A rename lists the new name, then the old one
|
|
125
|
+
const from = parts[++i];
|
|
126
|
+
if (x === 'R')
|
|
127
|
+
deleted.push(from);
|
|
128
|
+
changed.push(file);
|
|
129
|
+
}
|
|
130
|
+
else if (x === 'D' || y === 'D')
|
|
131
|
+
deleted.push(file);
|
|
132
|
+
else
|
|
133
|
+
changed.push(file);
|
|
134
|
+
}
|
|
135
|
+
return { deleted, changed };
|
|
136
|
+
}
|
|
137
|
+
/** The real path of a folder (so 8.3 names and case do not matter when two paths are compared), or its resolved path. */
|
|
138
|
+
const realOf = (folder) => {
|
|
139
|
+
try {
|
|
140
|
+
return realpathSync.native(folder);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return resolve(folder);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
const safeRel = (rel) => !!rel && !isAbsolute(rel) && !rel.split(/[\\/]/).includes('..') && !rel.includes('\0');
|
|
147
|
+
/**
|
|
148
|
+
* Copies one file of the repo into the copy. Returns true when it was copied. A link is refused by name (a copy that followed it could carry files
|
|
149
|
+
* from outside the repo into the build); the other reasons to leave a file out are listed in `skipped`.
|
|
150
|
+
*/
|
|
151
|
+
function copyOne(ctx, rel) {
|
|
152
|
+
if (!safeRel(rel))
|
|
153
|
+
throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
|
|
154
|
+
const parts = rel.split('/');
|
|
155
|
+
if (parts[0] === '.git')
|
|
156
|
+
throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
|
|
157
|
+
if (SKIP_DIR.test(rel))
|
|
158
|
+
return false;
|
|
159
|
+
// Every folder above the file must be a real folder
|
|
160
|
+
for (let i = 1; i < parts.length; i++) {
|
|
161
|
+
const folder = parts.slice(0, i).join('/');
|
|
162
|
+
let st;
|
|
163
|
+
try {
|
|
164
|
+
st = lstatSync(join(ctx.src, folder));
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
if (st.isSymbolicLink())
|
|
170
|
+
throw new Error(`"${folder.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
171
|
+
}
|
|
172
|
+
const from = join(ctx.src, ...parts);
|
|
173
|
+
let st;
|
|
174
|
+
try {
|
|
175
|
+
st = lstatSync(from);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return false; // Listed by git but deleted in the working tree
|
|
179
|
+
}
|
|
180
|
+
if (st.isSymbolicLink())
|
|
181
|
+
throw new Error(`"${rel.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
182
|
+
if (!st.isFile())
|
|
183
|
+
return false;
|
|
184
|
+
const never = neverCopy(rel, ctx.tracked.has(rel));
|
|
185
|
+
if (never) {
|
|
186
|
+
ctx.skipped.push({ path: rel, why: never });
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (st.size > MAX_COPY_BYTES) {
|
|
190
|
+
ctx.skipped.push({ path: rel, why: 'larger than 5 MB' });
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const to = under(ctx.dest, ...parts);
|
|
194
|
+
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
|
195
|
+
copyFileSync(from, to);
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
/** Every file of a folder that is not a git repo, as relative paths, with the limits that keep it from being a whole disk. */
|
|
199
|
+
function plainFiles(repo, limits = {}) {
|
|
200
|
+
const maxFiles = limits.files ?? MAX_PLAIN_FILES;
|
|
201
|
+
const maxBytes = limits.bytes ?? MAX_PLAIN_BYTES;
|
|
202
|
+
const out = [];
|
|
203
|
+
let bytes = 0;
|
|
204
|
+
const walk = (rel) => {
|
|
205
|
+
for (const e of readdirSync(join(repo, rel), { withFileTypes: true })) {
|
|
206
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
207
|
+
if (SKIP_DIR.test(`${r}/`))
|
|
208
|
+
continue;
|
|
209
|
+
if (e.isSymbolicLink())
|
|
210
|
+
throw new Error(`"${r.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
|
|
211
|
+
if (e.isDirectory())
|
|
212
|
+
walk(r);
|
|
213
|
+
else if (e.isFile()) {
|
|
214
|
+
out.push(r);
|
|
215
|
+
bytes += lstatSync(join(repo, r)).size;
|
|
216
|
+
if (out.length > maxFiles || bytes > maxBytes)
|
|
217
|
+
throw new Error(`${repo} is not a git repo and holds more than ${maxFiles} files or ${Math.round(maxBytes / 1024 / 1024)} MB, which is too much to copy for a run. Make it a git repo, or point the project at a smaller folder.`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
walk('');
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
/** The tracked links of a checked-out clone, by name: a link in the commit would reach outside the copy. */
|
|
225
|
+
async function trackedLinks(dir) {
|
|
226
|
+
const out = must(await git(dir, ['ls-files', '-s', '-z']), 'git ls-files').toString('utf8');
|
|
227
|
+
const links = [];
|
|
228
|
+
for (const entry of out.split('\0').filter(Boolean)) {
|
|
229
|
+
const tab = entry.indexOf('\t');
|
|
230
|
+
if (tab > 0 && entry.startsWith('120000 '))
|
|
231
|
+
links.push(entry.slice(tab + 1));
|
|
232
|
+
}
|
|
233
|
+
return links;
|
|
234
|
+
}
|
|
235
|
+
async function cloneCopy(root, dest, head, ctx) {
|
|
236
|
+
ensureDir(dirname(dest));
|
|
237
|
+
must(await git(dirname(dest), ['clone', '--local', '--no-checkout', '--quiet', root, dest]), 'git clone');
|
|
238
|
+
await git(dest, ['remote', 'remove', 'origin']);
|
|
239
|
+
must(await git(dest, ['checkout', '--quiet', '--force', '--detach', head]), 'git checkout');
|
|
240
|
+
const links = await trackedLinks(dest);
|
|
241
|
+
if (links.length)
|
|
242
|
+
throw new Error(`"${links[0].slice(0, 80)}" is a link in the commit. A check does not copy links; remove it from git.`);
|
|
243
|
+
const tracked = must(await git(dest, ['ls-files', '-z']), 'git ls-files').toString('utf8');
|
|
244
|
+
for (const f of tracked.split('\0').filter(Boolean)) {
|
|
245
|
+
ctx.tracked.add(f);
|
|
246
|
+
if (TRACKED_ENV.test(f))
|
|
247
|
+
rmSync(under(dest, f), { force: true });
|
|
248
|
+
}
|
|
249
|
+
const { deleted, changed } = await workingTreeChanges(root);
|
|
250
|
+
for (const rel of deleted) {
|
|
251
|
+
if (!safeRel(rel) || SKIP_DIR.test(rel))
|
|
252
|
+
continue;
|
|
253
|
+
try {
|
|
254
|
+
rmSync(under(dest, rel), { force: true });
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
ctx.skipped.push({ path: rel, why: 'could not remove it' });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let copied = 0;
|
|
261
|
+
for (const rel of changed)
|
|
262
|
+
if (copyOne(ctx, rel))
|
|
263
|
+
copied++;
|
|
264
|
+
return copied;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Makes one run's private copy of `repo` in `dest` (which must not exist yet). The copy is made from the git root that holds `repo`;
|
|
268
|
+
* `repoInCopy` says where `repo` is inside it. A link anywhere in what is copied is refused by name.
|
|
269
|
+
*/
|
|
270
|
+
export async function makePrivateCopy(repo, dest, opts = {}) {
|
|
271
|
+
const log = opts.log ?? (() => { });
|
|
272
|
+
const root = (await gitRoot(repo)) ?? resolve(repo);
|
|
273
|
+
for (let attempt = 1;; attempt++) {
|
|
274
|
+
const ctx = { src: root, dest, skipped: [], tracked: new Set() };
|
|
275
|
+
const head = await headCommit(root);
|
|
276
|
+
let files = 0;
|
|
277
|
+
let mode;
|
|
278
|
+
if (head) {
|
|
279
|
+
mode = 'clone';
|
|
280
|
+
files = await cloneCopy(root, dest, head, ctx);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
284
|
+
const listed = await listFiles(root);
|
|
285
|
+
mode = listed ? 'listed' : 'plain';
|
|
286
|
+
if (listed) {
|
|
287
|
+
const cached = await git(root, ['ls-files', '--cached', '-z']);
|
|
288
|
+
if (cached.status === 0)
|
|
289
|
+
for (const f of cached.stdout.toString('utf8').split('\0').filter(Boolean))
|
|
290
|
+
ctx.tracked.add(f);
|
|
291
|
+
}
|
|
292
|
+
for (const rel of listed ?? plainFiles(root, opts.limits))
|
|
293
|
+
if (copyOne(ctx, rel))
|
|
294
|
+
files++;
|
|
295
|
+
}
|
|
296
|
+
await opts.afterCopy?.(attempt);
|
|
297
|
+
// Another commit made in the repo while it was being copied would leave a tree that is neither commit: it is copied again, once
|
|
298
|
+
if (head && attempt === 1 && (await headCommit(root)) !== head) {
|
|
299
|
+
log('The repo moved to another commit while it was being copied; copying again.');
|
|
300
|
+
rmSync(dest, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
if (ctx.skipped.length) {
|
|
304
|
+
log(`${ctx.skipped.length} file(s) were not copied (env files, keys, files over 5 MB).`);
|
|
305
|
+
// Names only, never content
|
|
306
|
+
for (const k of ctx.skipped)
|
|
307
|
+
log(` not copied: ${k.path.slice(0, 120)} (${k.why})`);
|
|
308
|
+
}
|
|
309
|
+
const rel = relative(realOf(root), realOf(repo));
|
|
310
|
+
return {
|
|
311
|
+
files,
|
|
312
|
+
skipped: ctx.skipped.length,
|
|
313
|
+
skippedList: ctx.skipped,
|
|
314
|
+
head,
|
|
315
|
+
sourceRoot: root,
|
|
316
|
+
repoInCopy: rel && !rel.startsWith('..') ? join(dest, rel) : dest,
|
|
317
|
+
mode
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
}
|