@agent-flow-board/bridge 0.2.0 → 0.4.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.
Files changed (54) hide show
  1. package/README.md +26 -0
  2. package/dist/checks/appdir.js +105 -0
  3. package/dist/checks/approve.js +274 -0
  4. package/dist/checks/askBoard.js +143 -0
  5. package/dist/checks/audit.js +3 -2
  6. package/dist/checks/bind.js +3 -2
  7. package/dist/checks/board.js +30 -0
  8. package/dist/checks/broker.js +439 -0
  9. package/dist/checks/browser.js +137 -32
  10. package/dist/checks/browserpool.js +221 -0
  11. package/dist/checks/cache.js +710 -0
  12. package/dist/checks/command.js +126 -87
  13. package/dist/checks/commands.js +315 -0
  14. package/dist/checks/connections.js +4 -3
  15. package/dist/checks/copy.js +320 -0
  16. package/dist/checks/dbpool.js +710 -0
  17. package/dist/checks/doctor.js +655 -0
  18. package/dist/checks/envPolicy.js +50 -1
  19. package/dist/checks/envrules.js +322 -0
  20. package/dist/checks/evidence.js +114 -0
  21. package/dist/checks/fsretry.js +29 -0
  22. package/dist/checks/importlegacy.js +300 -0
  23. package/dist/checks/install.js +150 -0
  24. package/dist/checks/live.js +76 -0
  25. package/dist/checks/lock.js +28 -4
  26. package/dist/checks/login.js +307 -0
  27. package/dist/checks/mailguard-hook.cjs +216 -0
  28. package/dist/checks/mailguard.js +98 -0
  29. package/dist/checks/paths.js +24 -0
  30. package/dist/checks/preview.js +418 -191
  31. package/dist/checks/profiles.js +110 -0
  32. package/dist/checks/provision.js +1 -1
  33. package/dist/checks/recipe.js +19 -5
  34. package/dist/checks/registry.js +548 -0
  35. package/dist/checks/repoenv.js +162 -0
  36. package/dist/checks/roots.js +144 -0
  37. package/dist/checks/routing.js +68 -0
  38. package/dist/checks/run.js +243 -53
  39. package/dist/checks/sandbox.js +32 -10
  40. package/dist/checks/scheduler.js +383 -0
  41. package/dist/checks/scope.js +119 -30
  42. package/dist/checks/sealedNames.js +39 -0
  43. package/dist/checks/secrets.js +454 -0
  44. package/dist/checks/secretscmd.js +80 -0
  45. package/dist/checks/service.js +589 -0
  46. package/dist/checks/signin.js +7 -1
  47. package/dist/checks/signinKey.js +104 -0
  48. package/dist/checks/singleflight.js +307 -0
  49. package/dist/checks/store.js +92 -0
  50. package/dist/checks/surface.js +124 -0
  51. package/dist/checks/warm.js +342 -0
  52. package/dist/checks/watch.js +190 -63
  53. package/dist/checks/wire.js +87 -0
  54. package/package.json +1 -1
@@ -0,0 +1,300 @@
1
+ // `check import-legacy --from <afb-checks folder> --org <slug> [--dry-run] [--json]`: MOVES THE SETUP OF THE STANDALONE afb-checks TOOL INTO THE BRIDGE, READ ONLY ON THE OLD FOLDER.
2
+ // It reads ONLY <from>/data/projects/<org>/*.json (the project definitions: repo, appDir, boardProject, recipe, url, extraHosts) and the check scripts
3
+ // <from>/checks/<org>/<item>/<key>.mjs. It never lists or reads anything else under the old folder: no profiles, no secrets, no keys, no sign-ins.
4
+ // Each project is registered again through registry.ts with adoption OFF and no pin; a repo registration is made only when its folder is inside an owner-approved
5
+ // root (otherwise it is listed with the exact command the owner needs). Each script is checked with lintCheck and copied unchanged (headers kept) into the scripts
6
+ // folder; a script that fails the lint is not copied, and one that differs from an existing script is a conflict and is left as it is.
7
+ // -- DEP-MODULES -- //
8
+ import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
9
+ import { basename, dirname, join, resolve } from 'node:path';
10
+ // -- MODULES -- //
11
+ import { Refusal, UsageError } from './approve.js';
12
+ import { appendAudit } from './audit.js';
13
+ import { checkRepoFolder } from './guardrails.js';
14
+ import { lintCheck } from './guard.js';
15
+ import { NAME, ORG_SLUG, scriptPath } from './paths.js';
16
+ import { PROJECT_KEY, REGISTRATION_NAME, findRegistration, registerProject } from './registry.js';
17
+ import { rootHolding } from './roots.js';
18
+ // -- CONSTANTS -- //
19
+ /** A script larger than this is not a check. */
20
+ const MAX_SCRIPT_BYTES = 1_000_000;
21
+ const MAX_PROJECT_BYTES = 200_000;
22
+ // -- FUNCTIONS: SMALL HELPERS -- //
23
+ const isRecord = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
24
+ const text = (v) => (typeof v === 'string' && v ? v : undefined);
25
+ /** A plain file (not a link) as bytes, or null. Refuses anything larger than `max`. */
26
+ function readPlain(file, max) {
27
+ try {
28
+ const st = lstatSync(file);
29
+ if (!st.isFile() || st.size > max)
30
+ return null;
31
+ return readFileSync(file);
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ /** The names in a folder that pass `ok`, sorted. A folder that is a link is not entered. */
38
+ function listNames(dir, ok) {
39
+ try {
40
+ if (lstatSync(dir).isSymbolicLink())
41
+ return [];
42
+ return readdirSync(dir).filter(ok).sort();
43
+ }
44
+ catch {
45
+ return [];
46
+ }
47
+ }
48
+ function parseProject(raw) {
49
+ if (!raw)
50
+ return null;
51
+ try {
52
+ const v = JSON.parse(raw.toString('utf8'));
53
+ if (!isRecord(v))
54
+ return null;
55
+ return {
56
+ repo: text(v.repo),
57
+ appDir: text(v.appDir),
58
+ boardProject: text(v.boardProject),
59
+ recipe: v.recipe === true,
60
+ database: v.database,
61
+ url: text(v.url),
62
+ extraHosts: Array.isArray(v.extraHosts)
63
+ ? v.extraHosts.filter((h) => typeof h === 'string')
64
+ : undefined,
65
+ build: text(v.build),
66
+ start: text(v.start),
67
+ ready: text(v.ready)
68
+ };
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ }
74
+ // -- FUNCTIONS: PROJECTS -- //
75
+ function importProject(org, name, old, dryRun) {
76
+ const skip = (reason, next) => ({
77
+ kind: 'skipped',
78
+ skip: { name, reason, ...(next ? { next } : {}) }
79
+ });
80
+ const boardProject = old.boardProject ?? name;
81
+ if (!PROJECT_KEY.test(boardProject))
82
+ return skip(`its board project "${boardProject.slice(0, 20)}" is not a project key`);
83
+ const existing = findRegistration(org, name);
84
+ if (existing)
85
+ return { kind: 'unchanged' };
86
+ // A live site needs the owner's code; it is never made here
87
+ if (old.url) {
88
+ const hosts = old.extraHosts?.length ? ` --extra-hosts ${old.extraHosts.join(',')}` : '';
89
+ return skip('a live site is registered only with a code from the owner', `check approve --request --action register --target ${name}, then check project ${name} --org ${org} --board-project ${boardProject} --url ${old.url}${hosts} --approval <code>`);
90
+ }
91
+ if (!old.repo)
92
+ return skip('the project file names neither a repo folder nor a live site');
93
+ const folder = resolve(old.repo);
94
+ if (!existsSync(folder))
95
+ return skip('its repo folder is not on this machine');
96
+ const root = rootHolding(folder);
97
+ if (!root) {
98
+ const parent = dirname(folder);
99
+ return skip('its repo folder is not inside a folder the owner approved', `check approve --request --action roots --target "${parent}", then check roots add "${parent}" --approval <code>, then import again`);
100
+ }
101
+ const input = {
102
+ org,
103
+ name,
104
+ boardProject,
105
+ repo: folder,
106
+ ...(old.appDir ? { appDir: old.appDir } : {}),
107
+ recipe: old.recipe === true,
108
+ adoptEnv: false,
109
+ uploadSignedInPictures: false
110
+ };
111
+ if (dryRun) {
112
+ const problem = checkRepoFolder(old.appDir ? join(folder, old.appDir) : folder);
113
+ return problem ? skip(problem) : { kind: 'registered' };
114
+ }
115
+ try {
116
+ registerProject(input);
117
+ return { kind: 'registered' };
118
+ }
119
+ catch (e) {
120
+ if (e instanceof Refusal)
121
+ return skip(e.message);
122
+ throw e;
123
+ }
124
+ }
125
+ // -- FUNCTIONS: SCRIPTS -- //
126
+ function importScripts(from, org, dryRun) {
127
+ const out = {
128
+ found: 0,
129
+ copied: 0,
130
+ same: 0,
131
+ conflicts: [],
132
+ rejected: []
133
+ };
134
+ const base = join(from, 'checks', org);
135
+ for (const item of listNames(base, (n) => NAME.test(n))) {
136
+ const itemDir = join(base, item);
137
+ for (const file of listNames(itemDir, (n) => n.endsWith('.mjs'))) {
138
+ const key = file.slice(0, -'.mjs'.length);
139
+ if (!NAME.test(key))
140
+ continue;
141
+ const bytes = readPlain(join(itemDir, file), MAX_SCRIPT_BYTES);
142
+ if (!bytes)
143
+ continue;
144
+ out.found++;
145
+ const reasons = lintCheck(bytes.toString('utf8'));
146
+ if (reasons.length) {
147
+ out.rejected.push({ item, key, reasons });
148
+ continue;
149
+ }
150
+ const target = scriptPath(org, item, key);
151
+ if (existsSync(target)) {
152
+ const have = readPlain(target, MAX_SCRIPT_BYTES);
153
+ if (have && have.equals(bytes))
154
+ out.same++;
155
+ else
156
+ out.conflicts.push(`${item}/${key}`);
157
+ continue;
158
+ }
159
+ if (!dryRun) {
160
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
161
+ // 'wx': never replaces a script that appeared in the meantime
162
+ try {
163
+ writeFileSync(target, bytes, { mode: 0o600, flag: 'wx' });
164
+ }
165
+ catch (e) {
166
+ if (e.code === 'EEXIST') {
167
+ out.conflicts.push(`${item}/${key}`);
168
+ continue;
169
+ }
170
+ throw e;
171
+ }
172
+ }
173
+ out.copied++;
174
+ }
175
+ }
176
+ return out;
177
+ }
178
+ // -- FUNCTIONS: THE IMPORT -- //
179
+ /** Reads the old folder (project files and scripts only) and brings both into the bridge. Throws a Refusal or UsageError for input it cannot use. */
180
+ export function importLegacy(opts) {
181
+ const dryRun = opts.dryRun === true;
182
+ const org = opts.org;
183
+ if (!ORG_SLUG.test(org))
184
+ throw new UsageError('--org takes an organisation slug.');
185
+ const from = resolve(opts.from);
186
+ try {
187
+ if (!lstatSync(from).isDirectory())
188
+ throw new Error('not a folder');
189
+ }
190
+ catch {
191
+ throw new Refusal(`${basename(from)} is not a folder that exists.`);
192
+ }
193
+ const projectsDir = join(from, 'data', 'projects', org);
194
+ const names = listNames(projectsDir, (n) => n.endsWith('.json')).map((n) => n.slice(0, -5));
195
+ const projects = {
196
+ found: 0,
197
+ registered: [],
198
+ unchanged: [],
199
+ skipped: []
200
+ };
201
+ const notes = [];
202
+ for (const name of names) {
203
+ projects.found++;
204
+ if (!REGISTRATION_NAME.test(name)) {
205
+ projects.skipped.push({
206
+ name: name.slice(0, 40),
207
+ reason: 'its name is not a registration name'
208
+ });
209
+ continue;
210
+ }
211
+ const old = parseProject(readPlain(join(projectsDir, `${name}.json`), MAX_PROJECT_BYTES));
212
+ if (!old) {
213
+ projects.skipped.push({ name, reason: 'its project file cannot be read' });
214
+ continue;
215
+ }
216
+ if (!old.recipe && (old.build || old.start))
217
+ notes.push(`${name}: the old build and start commands are not copied; the bridge reads the package scripts or a recipe (.afb-checks.json).`);
218
+ const r = importProject(org, name, old, dryRun);
219
+ if (r.kind === 'skipped')
220
+ projects.skipped.push(r.skip);
221
+ else if (r.kind === 'registered')
222
+ projects.registered.push(name);
223
+ else
224
+ projects.unchanged.push(name);
225
+ }
226
+ const scripts = importScripts(from, org, dryRun);
227
+ const todo = [];
228
+ for (const s of projects.skipped)
229
+ if (s.next)
230
+ todo.push(`${s.name}: ${s.next}`);
231
+ if (projects.registered.length || projects.unchanged.length) {
232
+ todo.push(`Secrets are not moved (they are sealed with the old key). Import them again with an approval code: ${[...projects.registered, ...projects.unchanged].map((n) => `check approve --request --action secrets --target ${n}`).join('; ')}, then check secrets import ... --approval <code>.`);
233
+ todo.push(`Make the sign-ins again: ${[...projects.registered, ...projects.unchanged].map((n) => `check login ${n} --org ${org}`).join('; ')}.`);
234
+ }
235
+ if (scripts.conflicts.length)
236
+ todo.push('Compare the conflicting scripts with the ones in the bridge scripts folder and keep the right one by hand; nothing was overwritten.');
237
+ todo.push('When the bridge has taken over, stop the old worker and its service (the owner does this).');
238
+ const summary = {
239
+ ok: !projects.skipped.length && !scripts.conflicts.length && !scripts.rejected.length,
240
+ dryRun,
241
+ org,
242
+ projects,
243
+ scripts,
244
+ notes,
245
+ todo
246
+ };
247
+ if (!dryRun)
248
+ appendAudit('import-legacy', {
249
+ org,
250
+ projects: projects.found,
251
+ registered: projects.registered.length,
252
+ skipped: projects.skipped.length,
253
+ scripts: scripts.found,
254
+ copied: scripts.copied,
255
+ conflicts: scripts.conflicts.length,
256
+ rejected: scripts.rejected.length
257
+ });
258
+ return summary;
259
+ }
260
+ /** `check import-legacy --from <folder> --org <slug> [--dry-run] [--json]`. Exit 0 all imported, 1 something was skipped or needs the owner, 2 could not run. */
261
+ export async function importLegacyCommand(args, ctx) {
262
+ const json = ctx.json ?? args.includes('--json');
263
+ const list = args[0] === 'import-legacy' ? args.slice(1) : args;
264
+ const flag = (name) => {
265
+ const i = list.indexOf(name);
266
+ const v = i >= 0 ? list[i + 1] : undefined;
267
+ return v === undefined || v.startsWith('--') ? undefined : v;
268
+ };
269
+ try {
270
+ const from = flag('--from');
271
+ const org = flag('--org');
272
+ if (!from || !org)
273
+ throw new UsageError('Usage: agent-flow-board check import-legacy --from <afb-checks folder> --org <slug> [--dry-run] [--json]');
274
+ const s = importLegacy({ from, org, dryRun: list.includes('--dry-run') });
275
+ if (json)
276
+ ctx.out(JSON.stringify(s));
277
+ else {
278
+ ctx.log(`${s.dryRun ? 'Would import' : 'Imported'} for ${s.org}: ${s.projects.registered.length} of ${s.projects.found} project(s) registered (${s.projects.unchanged.length} already there), ${s.scripts.copied} of ${s.scripts.found} script(s) copied (${s.scripts.same} already the same).`);
279
+ for (const p of s.projects.skipped)
280
+ ctx.log(`Skipped ${p.name}: ${p.reason}.${p.next ? ` Needs: ${p.next}` : ''}`);
281
+ for (const c of s.scripts.conflicts)
282
+ ctx.log(`Conflict (left as it is): ${c}`);
283
+ for (const r of s.scripts.rejected)
284
+ ctx.log(`Not copied ${r.item}/${r.key}: ${r.reasons.join(', ')}`);
285
+ for (const n of s.notes)
286
+ ctx.log(`Note: ${n}`);
287
+ for (const t of s.todo)
288
+ ctx.log(`To do (owner): ${t}`);
289
+ }
290
+ return s.ok ? 0 : 1;
291
+ }
292
+ catch (e) {
293
+ const message = e instanceof Error ? e.message : String(e);
294
+ if (json)
295
+ ctx.out(JSON.stringify({ ok: false, event: 'error', error: message }));
296
+ else
297
+ ctx.log(message);
298
+ return e instanceof Refusal ? 1 : 2;
299
+ }
300
+ }
@@ -0,0 +1,150 @@
1
+ // THE INSTALL OF A PRIVATE COPY: WHICH COMMAND, WHETHER IT MAY BE CACHED, AND HOW A PACKAGE MANAGER IS STARTED ON WINDOWS.
2
+ // The commands are constants: nothing in the repo can change them. They install exactly what the lock file says and, where the package manager
3
+ // allows it, with the package scripts switched off (a postinstall script would run a program the agent wrote).
4
+ // -- DEP-MODULES -- //
5
+ import { execFile } from 'node:child_process';
6
+ import { createHash } from 'node:crypto';
7
+ import { existsSync, readFileSync } from 'node:fs';
8
+ import { delimiter, dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
+ // -- CONSTANTS -- //
10
+ /** The programs that are .cmd shims on Windows. */
11
+ const SHIMMED = /^(yarn|npm|pnpm|npx)$/;
12
+ /** The files an install depends on. */
13
+ const LOCK_INPUTS = [
14
+ 'yarn.lock',
15
+ 'package-lock.json',
16
+ 'pnpm-lock.yaml',
17
+ 'pnpm-workspace.yaml',
18
+ '.pnpmfile.cjs',
19
+ '.yarnrc.yml'
20
+ ];
21
+ // -- FUNCTIONS -- //
22
+ const has = (dir, file) => existsSync(join(dir, file));
23
+ /** Which package manager the lock file in `dir` names, or null when there is none (a frozen install needs one) or no package.json. */
24
+ export function packageManagerOf(dir) {
25
+ if (!has(dir, 'package.json'))
26
+ return null;
27
+ if (has(dir, 'yarn.lock'))
28
+ return 'yarn';
29
+ if (has(dir, 'package-lock.json'))
30
+ return 'npm';
31
+ if (has(dir, 'pnpm-lock.yaml'))
32
+ return 'pnpm';
33
+ return null;
34
+ }
35
+ /**
36
+ * The frozen install command for `dir`, or null when it has no lock file. Yarn with a .yarnrc.yml is Yarn 4, whose own `--immutable` is the frozen
37
+ * form; the others get --ignore-scripts.
38
+ */
39
+ export function installCommand(dir) {
40
+ switch (packageManagerOf(dir)) {
41
+ case 'yarn':
42
+ return has(dir, '.yarnrc.yml')
43
+ ? ['yarn', 'install', '--immutable']
44
+ : ['yarn', 'install', '--frozen-lockfile', '--ignore-scripts'];
45
+ case 'npm':
46
+ return ['npm', 'ci', '--ignore-scripts'];
47
+ case 'pnpm':
48
+ return ['pnpm', 'install', '--frozen-lockfile', '--ignore-scripts'];
49
+ default:
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * True when the finished install of `dir` may be kept and copied into another folder. A pnpm install never is: pnpm writes the absolute path of its
55
+ * store into node_modules/.modules.yaml, and its links point into that store, so a copy in another folder is refused by the next pnpm run.
56
+ */
57
+ export function installCacheable(dir) {
58
+ const manager = packageManagerOf(dir);
59
+ if (!manager || manager === 'pnpm')
60
+ return false;
61
+ // A node_modules that pnpm made is never cached whatever lock file sits beside it
62
+ if (has(dir, join('node_modules', '.modules.yaml')) || has(dir, '.modules.yaml'))
63
+ return false;
64
+ return true;
65
+ }
66
+ /** A hash of the files an install depends on: what the installed dependencies depend on. */
67
+ export function lockHash(dir) {
68
+ const h = createHash('sha1');
69
+ for (const f of LOCK_INPUTS)
70
+ if (has(dir, f))
71
+ h.update(f).update(readFileSync(join(dir, f)));
72
+ return h.digest('hex');
73
+ }
74
+ /** The first file on PATH named `name` with one of the executable extensions, or null. Case-insensitive on Windows. */
75
+ export function findOnPath(name, pathEnv, exts, exists = existsSync) {
76
+ for (const dir of pathEnv.split(delimiter).filter(Boolean))
77
+ for (const ext of exts) {
78
+ const file = join(dir, name + ext);
79
+ if (exists(file))
80
+ return file;
81
+ }
82
+ return null;
83
+ }
84
+ /**
85
+ * What to start for a command. On Windows yarn, npm and pnpm are usually .cmd shims, which a plain spawn cannot find and Node refuses to start
86
+ * directly (EINVAL): they go through `cmd.exe /d /s /c name.cmd`, unless a real .exe of that name is first on PATH. The arguments came from a command
87
+ * that was already checked (words, flags and a port, no shell characters), so cmd.exe has nothing to interpret. Elsewhere the argv is unchanged.
88
+ */
89
+ export function programArgv(argv, opts = {}) {
90
+ const [file, ...args] = argv;
91
+ if (!file || (opts.platform ?? process.platform) !== 'win32' || !SHIMMED.test(file))
92
+ return argv;
93
+ const found = findOnPath(file, opts.pathEnv ?? '', ['.exe', '.cmd', '.bat'], opts.exists);
94
+ if (found && found.toLowerCase().endsWith('.exe'))
95
+ return [found, ...args];
96
+ return ['cmd.exe', '/d', '/s', '/c', `${file}.cmd`, ...args];
97
+ }
98
+ /** The PATH of an environment object, whatever its case (Windows calls it Path). */
99
+ export function pathOf(env) {
100
+ for (const k of Object.keys(env))
101
+ if (k.toLowerCase() === 'path')
102
+ return env[k] ?? '';
103
+ return '';
104
+ }
105
+ /**
106
+ * The folder an install runs in: `appCwd` when it holds a lock file, else the nearest folder above it, up to `stopAt` (the root of the private copy), that does.
107
+ * A monorepo keeps one lock file at the top and the apps below it. Null when there is none, and the install is skipped.
108
+ */
109
+ export function installDirFor(appCwd, stopAt) {
110
+ const top = resolve(stopAt);
111
+ let dir = resolve(appCwd);
112
+ for (let i = 0; i < 32; i++) {
113
+ if (packageManagerOf(dir))
114
+ return dir;
115
+ if (dir === top)
116
+ return null;
117
+ const up = dirname(dir);
118
+ if (up === dir)
119
+ return null;
120
+ // Never above the copy
121
+ const rel = relative(top, up);
122
+ if (rel.startsWith('..') || isAbsolute(rel))
123
+ return null;
124
+ dir = up;
125
+ }
126
+ return null;
127
+ }
128
+ /** How a version is asked for. A test replaces it. */
129
+ export const versionIo = {
130
+ run: (argv, opts) => new Promise((resolveRun) => {
131
+ const [file, ...args] = programArgv(argv, { pathEnv: pathOf(opts.env) });
132
+ if (!file)
133
+ return resolveRun('');
134
+ execFile(file, args, { cwd: opts.cwd, env: opts.env, windowsHide: true, timeout: 30_000 }, (err, stdout) => resolveRun(err ? '' : (String(stdout).trim().split(/\r?\n/)[0] ?? '')));
135
+ })
136
+ };
137
+ const versions = new Map();
138
+ /** The version of a package manager as `<pm> --version` prints it, asked once per process. An empty string when it cannot be asked (the cache key then differs from every real version). */
139
+ export async function packageManagerVersion(pm, cwd, env = process.env) {
140
+ const known = versions.get(pm);
141
+ if (known !== undefined)
142
+ return known;
143
+ const version = (await versionIo.run([pm, '--version'], { cwd, env })).slice(0, 40);
144
+ // A failed ask is not remembered: the next run asks again
145
+ if (version)
146
+ versions.set(pm, version);
147
+ return version;
148
+ }
149
+ /** Forgets the versions asked so far (tests). */
150
+ export const forgetPackageManagerVersions = () => versions.clear();
@@ -0,0 +1,76 @@
1
+ // LIVE-SITE REGISTRATIONS: A REGISTRATION WITH A URL AND NO REPO. NOTHING IS BUILT OR STARTED; THE RUN IS UNVERIFIED AND NEVER WRITES.
2
+ // This file only judges the settings (url and extraHosts) and hands the guard its options. Writes are never allowed on a live site: a check
3
+ // there drives pages and reads, and the guard stops every POST, PUT, PATCH and DELETE.
4
+ // -- MODULES -- //
5
+ import { FORBIDDEN_PORTS } from './guardrails.js';
6
+ // -- CONSTANTS -- //
7
+ const MAX_EXTRA_HOSTS = 16;
8
+ /** A plain DNS name: dot separated labels of letters, digits and dashes. No wildcard, port, path or underscore. */
9
+ const HOST_NAME = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
10
+ const LOCAL_HOSTS = new Set([
11
+ 'localhost',
12
+ '127.0.0.1',
13
+ '[::1]',
14
+ '::1',
15
+ '0.0.0.0',
16
+ 'host.docker.internal'
17
+ ]);
18
+ // -- FUNCTIONS -- //
19
+ const isLoopbackName = (h) => LOCAL_HOSTS.has(h) || h.endsWith('.localhost') || /^127\./.test(h) || h === '[::]';
20
+ /** True for a dotted IPv4 address (also the short and number forms a browser accepts) or anything holding a colon (IPv6). */
21
+ const isIpLiteral = (h) => h.includes(':') || h.startsWith('[') || /^[0-9.]+$/.test(h) || /^0x[0-9a-f]+$/i.test(h);
22
+ /** One plain sentence per problem with the live-site settings. An empty list means they are acceptable. */
23
+ export function liveSiteProblems(cfg) {
24
+ const problems = [];
25
+ if (cfg.url !== undefined) {
26
+ let u = null;
27
+ try {
28
+ u = new URL(String(cfg.url));
29
+ }
30
+ catch {
31
+ problems.push('The site address is not a web address.');
32
+ }
33
+ if (u) {
34
+ if (u.protocol !== 'http:' && u.protocol !== 'https:')
35
+ problems.push('The site address must start with http:// or https://.');
36
+ else {
37
+ if (u.username || u.password)
38
+ problems.push('The site address may not hold a user name or password.');
39
+ const host = u.hostname.toLowerCase();
40
+ if (isLoopbackName(host)) {
41
+ const port = Number(u.port || (u.protocol === 'https:' ? 443 : 80));
42
+ if (FORBIDDEN_PORTS.has(port))
43
+ problems.push(`The site address uses port ${port}, which a check never uses.`);
44
+ }
45
+ }
46
+ }
47
+ }
48
+ const extra = cfg.extraHosts ?? [];
49
+ if (!Array.isArray(extra))
50
+ return [...problems, 'The extra hosts must be a list of host names.'];
51
+ if (extra.length > MAX_EXTRA_HOSTS)
52
+ problems.push(`At most ${MAX_EXTRA_HOSTS} extra hosts are allowed.`);
53
+ for (const raw of extra) {
54
+ const h = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
55
+ const shown = typeof raw === 'string' ? raw.slice(0, 60) : String(typeof raw);
56
+ if (!h || (!HOST_NAME.test(h) && !isIpLiteral(h)))
57
+ problems.push(`The extra host "${shown}" is not an exact host name (no wildcard, port, path or address).`);
58
+ else if (isIpLiteral(h))
59
+ problems.push(`The extra host "${shown}" is an IP address. Only host names are allowed.`);
60
+ else if (isLoopbackName(h))
61
+ problems.push(`The extra host "${shown}" is this machine, which is never an extra host.`);
62
+ }
63
+ return problems;
64
+ }
65
+ /** What the child's guard is given for a live site: writes never allowed, and only the exact extra hosts reachable besides the site. */
66
+ export function liveSiteGuardOptions(cfg) {
67
+ const bad = liveSiteProblems(cfg);
68
+ if (bad.length)
69
+ throw new Error(bad[0]);
70
+ return {
71
+ allowWrites: false,
72
+ extraHosts: [...new Set((cfg.extraHosts ?? []).map((h) => h.trim().toLowerCase()))]
73
+ };
74
+ }
75
+ /** True when the settings describe a live site (a url and no repo to build). */
76
+ export const isLiveSite = (cfg) => typeof cfg.url === 'string' && cfg.url !== '';
@@ -7,12 +7,18 @@ import { randomBytes } from 'node:crypto';
7
7
  import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  // -- MODULES -- //
10
+ import { renameRetry } from './fsretry.js';
10
11
  import { assertName, ensureDir, locksDir } from './paths.js';
11
12
  // -- CONSTANTS -- //
12
13
  /** A lock whose content cannot be read is only treated as stale after this long (it is created whole, so this means a damaged file). */
13
14
  const UNREADABLE_GRACE_MS = 2_000;
14
- /** For a lock that only a dead process may lose, such as the worker lock. */
15
+ /** For a lock that only a dead process may lose. */
15
16
  export const NEVER_STALE_MS = Number.MAX_SAFE_INTEGER;
17
+ /**
18
+ * The worker lock is lost by a dead process, and also by a heartbeat that stopped: a crashed worker whose process id was handed to another program would otherwise lock
19
+ * out `check watch` for ever. The worker touches its lock every few seconds (watch.ts), so this is long enough to survive a busy machine.
20
+ */
21
+ export const WORKER_STALE_MS = 3 * 60_000;
16
22
  // -- FUNCTIONS -- //
17
23
  /** True when a process with this id exists (it may belong to another user, which still counts as alive). */
18
24
  export function pidAlive(pid) {
@@ -138,6 +144,24 @@ function makeLock(file, token) {
138
144
  let released = false;
139
145
  return {
140
146
  file,
147
+ touch() {
148
+ if (released)
149
+ return;
150
+ const text = readText(file);
151
+ const body = text === null ? null : parseBody(text);
152
+ if (!body || body.token !== token)
153
+ return;
154
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
155
+ try {
156
+ const next = { ...body, at: new Date().toISOString() };
157
+ writeFileSync(tmp, JSON.stringify(next) + '\n', { mode: 0o600 });
158
+ renameRetry(tmp, file);
159
+ }
160
+ catch {
161
+ // The next touch tries again
162
+ rmSync(tmp, { force: true });
163
+ }
164
+ },
141
165
  release() {
142
166
  if (released)
143
167
  return;
@@ -175,9 +199,9 @@ export function lockHolderPid(name) {
175
199
  return body && pidAlive(body.pid) ? body.pid : null;
176
200
  }
177
201
  /**
178
- * The one-worker-per-machine lock of `check watch`. Only a dead process loses it (the work inside runs for hours), so the stale age is never reached.
179
- * Returns null when another worker holds it; the caller says so in one plain line.
202
+ * The one-worker-per-machine lock of `check watch`. A dead process loses it, and so does one whose heartbeat stopped for three minutes (a reused process id).
203
+ * The worker calls `touch()` on its lock regularly. Returns null when another worker holds it; the caller says so in one plain line.
180
204
  */
181
205
  export function machineLock(name) {
182
- return acquireLock(`machine-${name}`, { staleMs: NEVER_STALE_MS });
206
+ return acquireLock(`machine-${name}`, { staleMs: WORKER_STALE_MS });
183
207
  }