@agent-flow-board/bridge 0.0.0-stage → 0.2.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.
@@ -0,0 +1,183 @@
1
+ // FILE LOCKS FOR THE CHECK COMMANDS: A SHORT EXCLUSIVE LOCK (THE AUDIT CHAIN) AND THE ONE-WORKER-PER-MACHINE LOCK OF `check watch`.
2
+ // Two lessons are built in. A lock file that is created empty and written afterwards looks stale to a second process, so the file is written whole into a
3
+ // temporary file and linked into place in one step. A stale lock that is unlinked and then created again lets two contenders in, so a stale lock is RENAMED
4
+ // aside, what was moved is compared with the stale one that was seen, and a fresh lock that was moved by mistake is put back.
5
+ // -- DEP-MODULES -- //
6
+ import { randomBytes } from 'node:crypto';
7
+ import { linkSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { assertName, ensureDir, locksDir } from './paths.js';
11
+ // -- CONSTANTS -- //
12
+ /** 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
+ const UNREADABLE_GRACE_MS = 2_000;
14
+ /** For a lock that only a dead process may lose, such as the worker lock. */
15
+ export const NEVER_STALE_MS = Number.MAX_SAFE_INTEGER;
16
+ // -- FUNCTIONS -- //
17
+ /** True when a process with this id exists (it may belong to another user, which still counts as alive). */
18
+ export function pidAlive(pid) {
19
+ if (!Number.isInteger(pid) || pid <= 0)
20
+ return false;
21
+ try {
22
+ process.kill(pid, 0);
23
+ return true;
24
+ }
25
+ catch (e) {
26
+ return e.code === 'EPERM';
27
+ }
28
+ }
29
+ const lockPath = (name) => join(locksDir(), `${assertName(name, 'lock name')}.lock`);
30
+ function parseBody(text) {
31
+ try {
32
+ const v = JSON.parse(text);
33
+ if (!v || typeof v !== 'object')
34
+ return null;
35
+ const b = v;
36
+ if (typeof b.pid !== 'number' || typeof b.at !== 'string' || typeof b.token !== 'string')
37
+ return null;
38
+ return { pid: b.pid, at: b.at, token: b.token };
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ function readText(file) {
45
+ try {
46
+ return readFileSync(file, 'utf8');
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ function isStale(text, file, staleMs) {
53
+ const body = text === null ? null : parseBody(text);
54
+ if (body)
55
+ return !pidAlive(body.pid) || Date.now() - Date.parse(body.at) > staleMs;
56
+ // Unreadable or damaged: given a moment, then stale
57
+ try {
58
+ return Date.now() - statSync(file).mtimeMs > UNREADABLE_GRACE_MS;
59
+ }
60
+ catch {
61
+ return false;
62
+ }
63
+ }
64
+ /** Creates the lock file with its whole content at once. False when somebody holds it. */
65
+ function tryCreate(file, text) {
66
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
67
+ writeFileSync(tmp, text, { mode: 0o600 });
68
+ try {
69
+ linkSync(tmp, file);
70
+ return true;
71
+ }
72
+ catch (e) {
73
+ if (e.code === 'EEXIST')
74
+ return false;
75
+ throw e;
76
+ }
77
+ finally {
78
+ rmSync(tmp, { force: true });
79
+ }
80
+ }
81
+ /**
82
+ * Moves the lock file aside under a name of its own. Returns what was moved, or null when somebody else moved it first. When what was moved is not
83
+ * `expected` (somebody made a new lock between the look and the move), it is linked back.
84
+ */
85
+ function moveAside(file, expected, keep) {
86
+ const aside = `${file}.aside.${process.pid}.${randomBytes(4).toString('hex')}`;
87
+ try {
88
+ renameSync(file, aside);
89
+ }
90
+ catch {
91
+ return null;
92
+ }
93
+ const moved = readText(aside);
94
+ if (moved !== null && moved !== expected && keep(moved)) {
95
+ try {
96
+ linkSync(aside, file);
97
+ }
98
+ catch {
99
+ /* A newer lock is already there: the one that was moved is dropped */
100
+ }
101
+ }
102
+ rmSync(aside, { force: true });
103
+ return moved ?? '';
104
+ }
105
+ /**
106
+ * Takes the lock `name` without waiting. Returns null when a live holder has it. A lock whose process is gone, or that is older than `staleMs`,
107
+ * is taken over.
108
+ */
109
+ export function acquireLock(name, { staleMs }) {
110
+ const file = lockPath(name);
111
+ ensureDir(locksDir());
112
+ const token = randomBytes(8).toString('hex');
113
+ const mine = JSON.stringify({ pid: process.pid, at: new Date().toISOString(), token }) +
114
+ '\n';
115
+ for (let attempt = 0; attempt < 6; attempt++) {
116
+ if (tryCreate(file, mine))
117
+ return makeLock(file, token);
118
+ const held = readText(file);
119
+ if (held === null && !statExists(file))
120
+ continue; // Released between the two looks
121
+ if (!isStale(held, file, staleMs))
122
+ return null;
123
+ // Stale: moved aside in one step, so of two contenders only one moves it. A fresh lock that was moved by mistake goes back
124
+ moveAside(file, held, (moved) => !isStale(moved, file, staleMs));
125
+ }
126
+ return null;
127
+ }
128
+ const statExists = (file) => {
129
+ try {
130
+ statSync(file);
131
+ return true;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ };
137
+ function makeLock(file, token) {
138
+ let released = false;
139
+ return {
140
+ file,
141
+ release() {
142
+ if (released)
143
+ return;
144
+ released = true;
145
+ // Moved aside first, so a lock that was taken over from us in the meantime is never removed by mistake
146
+ const text = readText(file);
147
+ if (text === null || parseBody(text)?.token !== token)
148
+ return;
149
+ moveAside(file, text, () => true);
150
+ }
151
+ };
152
+ }
153
+ const nap = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
154
+ /** Runs `fn` while holding the lock, waiting up to `waitMs` for another holder. Throws one plain sentence when it could not get the lock. */
155
+ export function withLockSync(name, fn, { staleMs = 15_000, waitMs = 20_000 } = {}) {
156
+ const until = Date.now() + waitMs;
157
+ let lock = acquireLock(name, { staleMs });
158
+ while (!lock) {
159
+ if (Date.now() > until)
160
+ throw new Error(`Another process kept the "${name}" lock for more than ${Math.round(waitMs / 1000)} seconds.`);
161
+ nap(15);
162
+ lock = acquireLock(name, { staleMs });
163
+ }
164
+ try {
165
+ return fn();
166
+ }
167
+ finally {
168
+ lock.release();
169
+ }
170
+ }
171
+ /** The process id that holds a lock, or null when nobody does. For the plain sentence of a refused second worker. */
172
+ export function lockHolderPid(name) {
173
+ const text = readText(lockPath(name));
174
+ const body = text === null ? null : parseBody(text);
175
+ return body && pidAlive(body.pid) ? body.pid : null;
176
+ }
177
+ /**
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.
180
+ */
181
+ export function machineLock(name) {
182
+ return acquireLock(`machine-${name}`, { staleMs: NEVER_STALE_MS });
183
+ }
@@ -0,0 +1,61 @@
1
+ // WHERE THE CHECK COMMANDS KEEP THEIR STATE: ONE FOLDER UNDER THE BRIDGE HOME, NEVER INSIDE A REPO
2
+ // The bridge home also holds the token store (key and token files). Nothing here ever names those, and the sandboxed child of a check is
3
+ // given read access to the scripts folder, its own run folder and the Playwright runtime only (see sandbox.ts).
4
+ // -- DEP-MODULES -- //
5
+ import { mkdirSync, realpathSync } from 'node:fs';
6
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
7
+ // -- MODULES -- //
8
+ import { configDir } from '../tokenStore.js';
9
+ // -- CONSTANTS -- //
10
+ /** The version of the conversation between the board and the check tool. The board refuses a tool older than its minimum. */
11
+ export const TOOL_PROTOCOL = 2;
12
+ /** Item references and check keys: letters, digits, dash and underscore only, so a name can never leave its folder. */
13
+ export const NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
14
+ /** An organisation slug as the app writes it. */
15
+ export const ORG_SLUG = /^[a-z0-9][a-z0-9-]{1,38}$/;
16
+ // -- FUNCTIONS -- //
17
+ /** The folder of all check state. AFB_BRIDGE_HOME moves it, which is how the tests get a throwaway one. */
18
+ export const checksHome = () => join(configDir(), 'checks');
19
+ /** Check scripts: scripts/<org>/<item>/<key>.mjs. Written by the agent, never fetched and run from the board. */
20
+ export const scriptsDir = () => join(checksHome(), 'scripts');
21
+ /** One folder per run, named by the run id. */
22
+ export const runsDir = () => join(checksHome(), 'runs');
23
+ export const locksDir = () => join(checksHome(), 'locks');
24
+ export const auditFile = () => join(checksHome(), 'audit.log');
25
+ export const bindingsFile = () => join(checksHome(), 'bindings.json');
26
+ /** Private copies of repos, one per run, kept so a crashed run can be swept. */
27
+ export const copiesDir = () => join(checksHome(), 'copies');
28
+ export function ensureDir(dir) {
29
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
30
+ return dir;
31
+ }
32
+ /** Refuses a name that is not a plain name (a path separator, a dot dot, an empty string). */
33
+ export function assertName(value, what) {
34
+ if (!NAME.test(value))
35
+ throw new Error(`${what} "${value.slice(0, 40)}" is not a plain name (letters, digits, dash, underscore).`);
36
+ return value;
37
+ }
38
+ /** Joins parts below a base and proves the result stays below it. Throws when it does not. */
39
+ export function under(base, ...parts) {
40
+ const full = resolve(base, ...parts);
41
+ const rel = relative(resolve(base), full);
42
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
43
+ throw new Error('That path leaves its folder.');
44
+ return full;
45
+ }
46
+ /** The path of a check script. */
47
+ export function scriptPath(org, item, key) {
48
+ if (!ORG_SLUG.test(org))
49
+ throw new Error('The organisation is not a slug.');
50
+ return under(scriptsDir(), org, assertName(item, 'item reference'), `${assertName(key, 'check key')}.mjs`);
51
+ }
52
+ /** True when `child` is `parent` or inside it, compared by real path so a link cannot step outside. */
53
+ export function isInside(parent, child) {
54
+ try {
55
+ const rel = relative(realpathSync(parent), realpathSync(child));
56
+ return (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..')));
57
+ }
58
+ catch {
59
+ return false;
60
+ }
61
+ }
@@ -0,0 +1,350 @@
1
+ // THE PREVIEW OF ONE RUN: A PRIVATE COPY OF THE REPO, BUILT AND SERVED FROM ITS OWN FOLDER ON A FREE PORT, WITH AN ENVIRONMENT THE TOOL BUILT (envPolicy.ts).
2
+ // A run builds and serves from its own copy so it can never take down the preview a person is looking at, and two runs never share a folder. The copy is
3
+ // made from the files git lists (tracked, and untracked that are not ignored), never from .git; env files are not copied, they are read from the source repo.
4
+ // A dependency cache was left out on purpose: a fresh install per run is slower but cannot serve a stale module to a run.
5
+ // -- DEP-MODULES -- //
6
+ import { execFile, spawn } from 'node:child_process';
7
+ import { createHash } from 'node:crypto';
8
+ import { closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ // -- MODULES -- //
11
+ import { buildPreviewEnv } from './envPolicy.js';
12
+ import { FORBIDDEN_PORTS } from './guardrails.js';
13
+ import { copiesDir, ensureDir, runsDir, under } from './paths.js';
14
+ import { freePort, makeThrowawayDatabase } from './provision.js';
15
+ import { killTree, removeTree } from './scope.js';
16
+ // -- CONSTANTS -- //
17
+ /** A file bigger than this is not copied (a build output, a dump, a video). */
18
+ const MAX_COPY_BYTES = 5 * 1024 * 1024;
19
+ const BUILD_TIMEOUT_MS = 10 * 60_000;
20
+ const READY_TIMEOUT_MS = 120_000;
21
+ /** Env files are read from the source repo by envPolicy, never copied. An example file holds no secrets and is copied. */
22
+ const ENV_FILE = /(^|\/)\.env(\.[^/]*)?$/i;
23
+ const ENV_EXAMPLE = /\.(example|sample|template)$/i;
24
+ // -- FUNCTIONS -- //
25
+ /** Runs git with an argument list and no shell. Resolves with the exit status; never throws. */
26
+ function git(cwd, args) {
27
+ return new Promise((resolve) => {
28
+ execFile('git', args, {
29
+ cwd,
30
+ windowsHide: true,
31
+ encoding: 'buffer',
32
+ maxBuffer: 256 * 1024 * 1024,
33
+ timeout: 120_000
34
+ }, (err, stdout) => {
35
+ const code = err
36
+ ? typeof err.code === 'number'
37
+ ? err.code
38
+ : -1
39
+ : 0;
40
+ resolve({ status: code, stdout: stdout });
41
+ });
42
+ });
43
+ }
44
+ /** 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. */
45
+ async function listFiles(repo) {
46
+ const r = await git(repo, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']);
47
+ if (r.status !== 0)
48
+ return null;
49
+ return [...new Set(r.stdout.toString('utf8').split('\0').filter(Boolean))];
50
+ }
51
+ /**
52
+ * Copies the listed files of `repo` into `dest`. A link anywhere in the repo's files (the file itself or a folder above it) is refused by name,
53
+ * because a copy that follows a link could carry files from outside the repo into the build.
54
+ */
55
+ export async function makePrivateCopy(repo, dest, log = () => { }) {
56
+ const files = await listFiles(repo);
57
+ if (!files)
58
+ throw new Error('The project folder is not a git repository, so the tool cannot tell which files belong to it.');
59
+ const checkedDirs = new Set();
60
+ let copied = 0;
61
+ let skipped = 0;
62
+ mkdirSync(dest, { recursive: true, mode: 0o700 });
63
+ for (const rel of files) {
64
+ const parts = rel.split('/');
65
+ if (parts[0] === '.git' || parts.includes('..'))
66
+ throw new Error(`The file "${rel.slice(0, 80)}" is not a file the tool copies.`);
67
+ // Every folder above the file must be a real folder
68
+ for (let i = 1; i < parts.length; i++) {
69
+ const folder = parts.slice(0, i).join('/');
70
+ if (checkedDirs.has(folder))
71
+ continue;
72
+ let st;
73
+ try {
74
+ st = lstatSync(join(repo, folder));
75
+ }
76
+ catch {
77
+ break;
78
+ }
79
+ if (st.isSymbolicLink())
80
+ throw new Error(`"${folder.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
81
+ checkedDirs.add(folder);
82
+ }
83
+ const from = join(repo, ...parts);
84
+ let st;
85
+ try {
86
+ st = lstatSync(from);
87
+ }
88
+ catch {
89
+ continue; // Listed by git but deleted in the working tree
90
+ }
91
+ if (st.isSymbolicLink())
92
+ throw new Error(`"${rel.slice(0, 80)}" is a link. A check does not copy links; remove it or ignore it in git.`);
93
+ if (!st.isFile())
94
+ continue;
95
+ if ((ENV_FILE.test(rel) && !ENV_EXAMPLE.test(rel)) || st.size > MAX_COPY_BYTES) {
96
+ skipped++;
97
+ continue;
98
+ }
99
+ const to = under(dest, ...parts);
100
+ mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
101
+ copyFileSync(from, to);
102
+ copied++;
103
+ }
104
+ if (skipped)
105
+ log(`${skipped} file(s) were not copied (env files and files over 5 MB).`);
106
+ return { files: copied, skipped };
107
+ }
108
+ /** The commit of HEAD, or null when the folder has no commit. */
109
+ async function headCommit(repo) {
110
+ const r = await git(repo, ['rev-parse', 'HEAD']);
111
+ return r.status === 0 ? r.stdout.toString('utf8').trim() || null : null;
112
+ }
113
+ /** A hash over the uncommitted changes (`git diff HEAD`) and the contents of untracked files. Null when the folder is not a git repo. */
114
+ async function hashChanges(repo) {
115
+ const files = await listFiles(repo);
116
+ if (!files)
117
+ return null;
118
+ const h = createHash('sha256');
119
+ const diff = await git(repo, ['diff', 'HEAD']);
120
+ if (diff.status === 0)
121
+ h.update(diff.stdout);
122
+ const others = await git(repo, ['ls-files', '--others', '--exclude-standard', '-z']);
123
+ for (const rel of others.stdout.toString('utf8').split('\0').filter(Boolean).sort()) {
124
+ try {
125
+ const full = join(repo, ...rel.split('/'));
126
+ if (ENV_FILE.test(rel) || !lstatSync(full).isFile())
127
+ continue;
128
+ h.update(`\0${rel}\0`);
129
+ h.update(readFileSync(full));
130
+ }
131
+ catch {
132
+ /* A file that vanished while hashing */
133
+ }
134
+ }
135
+ return h.digest('hex');
136
+ }
137
+ /**
138
+ * Starts a program from an argument list with no shell. On Windows the package managers are .cmd shims, which a plain spawn cannot find (ENOENT) and
139
+ * Node refuses to start directly; only then is the shim run through `cmd.exe /d /s /c`. The arguments came from a command that was already checked
140
+ * (words, flags and a port, no metacharacters), so they are not interpreted by cmd.exe. Resolves once the process exists.
141
+ */
142
+ export function launch(argv, opts) {
143
+ const [file, ...args] = argv;
144
+ if (!file)
145
+ return Promise.reject(new Error('The command is empty.'));
146
+ const base = {
147
+ cwd: opts.cwd,
148
+ env: opts.env,
149
+ stdio: ['ignore', opts.out, opts.out],
150
+ windowsHide: true,
151
+ shell: false,
152
+ detached: opts.detached
153
+ };
154
+ const attempt = (f, a) => new Promise((resolve, reject) => {
155
+ const child = spawn(f, a, base);
156
+ child.once('error', reject);
157
+ child.once('spawn', () => {
158
+ child.off('error', reject);
159
+ resolve(child);
160
+ });
161
+ });
162
+ return attempt(file, args).catch((e) => {
163
+ if (e.code === 'ENOENT' && process.platform === 'win32' && /^(yarn|npm|pnpm|npx)$/.test(file))
164
+ return attempt('cmd.exe', ['/d', '/s', '/c', `${file}.cmd`, ...args]);
165
+ throw e;
166
+ });
167
+ }
168
+ /** Runs a command to its end and resolves with its exit code (or -1 when it timed out). The output goes to the open file `out`. */
169
+ async function runToEnd(argv, opts) {
170
+ const child = await launch(argv, { ...opts, detached: false });
171
+ return new Promise((resolve) => {
172
+ const timer = setTimeout(() => {
173
+ if (child.pid)
174
+ void killTree(child.pid).then(() => resolve(-1));
175
+ else
176
+ resolve(-1);
177
+ }, opts.timeoutMs);
178
+ child.once('exit', (code) => {
179
+ clearTimeout(timer);
180
+ resolve(code ?? -1);
181
+ });
182
+ });
183
+ }
184
+ const words = (command) => command.split(' ').filter(Boolean);
185
+ /** The install command for the copy, from the lock file it holds, or null when there is no lock file (a frozen install needs one). */
186
+ export function installCommand(dir) {
187
+ if (!existsSync(join(dir, 'package.json')))
188
+ return null;
189
+ if (existsSync(join(dir, 'yarn.lock')))
190
+ return ['yarn', 'install', '--immutable'];
191
+ if (existsSync(join(dir, 'pnpm-lock.yaml')))
192
+ return ['pnpm', 'install', '--frozen-lockfile'];
193
+ if (existsSync(join(dir, 'package-lock.json')))
194
+ return ['npm', 'ci'];
195
+ return null;
196
+ }
197
+ /** The last lines of a log file with every value of the preview environment hidden. */
198
+ export function tailOf(file, secrets, lines = 20) {
199
+ let text;
200
+ try {
201
+ text = readFileSync(file, 'utf8');
202
+ }
203
+ catch {
204
+ return '';
205
+ }
206
+ let out = text.split(/\r?\n/).filter(Boolean).slice(-lines).join('\n');
207
+ for (const s of [...secrets].sort((a, b) => b.length - a.length))
208
+ if (s.length >= 4)
209
+ out = out.split(s).join('[hidden]');
210
+ return out;
211
+ }
212
+ /** Waits until GET url answers with a status below 500. Gives up after `timeoutMs`, or at once when `exited` says the process is gone. */
213
+ export async function waitReady(url, opts) {
214
+ const until = Date.now() + opts.timeoutMs;
215
+ while (Date.now() < until) {
216
+ if (opts.exited())
217
+ return 'exited';
218
+ try {
219
+ const res = await fetch(url, { signal: AbortSignal.timeout(2_000), redirect: 'manual' });
220
+ await res.body?.cancel();
221
+ if (res.status < 500)
222
+ return 'ready';
223
+ }
224
+ catch {
225
+ /* Not listening yet */
226
+ }
227
+ await new Promise((r) => setTimeout(r, opts.intervalMs ?? 500));
228
+ }
229
+ return opts.exited() ? 'exited' : 'timeout';
230
+ }
231
+ /** Picks a free port on 127.0.0.1 and refuses a forbidden one. */
232
+ export async function pickPort() {
233
+ const port = await freePort();
234
+ if (FORBIDDEN_PORTS.has(port))
235
+ throw new Error(`Port ${port} is not one a check may use.`);
236
+ return port;
237
+ }
238
+ export async function startPreview(cfg, opts) {
239
+ const { runId, scope, log } = opts;
240
+ // -- 1. THE MARKER COMES FIRST -- //
241
+ scope.start();
242
+ const runDir = ensureDir(under(runsDir(), runId));
243
+ const notes = [];
244
+ const secrets = [];
245
+ // -- 2. A PRIVATE COPY -- //
246
+ const copyDir = under(copiesDir(), runId);
247
+ scope.setInfo({ copyDir });
248
+ scope.add('private copy', () => removeTree(copyDir));
249
+ const made = await makePrivateCopy(cfg.repo, copyDir, log);
250
+ log(`Made a private copy of ${made.files} file(s).`);
251
+ // -- 3. WHAT WAS TESTED -- //
252
+ const commit = await headCommit(cfg.repo);
253
+ const diffHash = await hashChanges(cfg.repo);
254
+ // -- 4. A FREE PORT -- //
255
+ const port = await pickPort();
256
+ const baseUrl = `http://127.0.0.1:${port}`;
257
+ // -- 5. A THROWAWAY DATABASE, ONLY WHEN THE APP NEEDS ONE -- //
258
+ let databaseUrl;
259
+ let ownerUrl;
260
+ if (cfg.needsDatabase) {
261
+ const db = await makeThrowawayDatabase(runId, scope, log, { runtimeRole: cfg.runtimeRole });
262
+ databaseUrl = db.databaseUrl;
263
+ ownerUrl = db.ownerUrl;
264
+ secrets.push(db.databaseUrl, db.ownerUrl);
265
+ notes.push('A throwaway database was made for this run.');
266
+ }
267
+ // -- ENVIRONMENT -- //
268
+ const built = buildPreviewEnv({
269
+ repo: cfg.repo,
270
+ recipeEnv: cfg.env,
271
+ port,
272
+ baseUrl,
273
+ databaseUrl,
274
+ databaseOwnerUrl: ownerUrl
275
+ });
276
+ notes.push(...built.notes);
277
+ const redact = Object.values(built.env).filter((v) => v.length >= 8);
278
+ if (cfg.testUser)
279
+ secrets.push(cfg.testUser.password);
280
+ // -- 6. INSTALL AND BUILD -- //
281
+ const buildLog = openSync(join(runDir, 'build.log'), 'a');
282
+ try {
283
+ const install = installCommand(copyDir);
284
+ if (install) {
285
+ log('Installing dependencies in the private copy.');
286
+ const code = await runToEnd(install, {
287
+ cwd: copyDir,
288
+ env: built.env,
289
+ out: buildLog,
290
+ timeoutMs: BUILD_TIMEOUT_MS
291
+ });
292
+ if (code !== 0)
293
+ throw new Error(`The install failed (exit ${code}). Last lines:\n${tailOf(join(runDir, 'build.log'), redact)}`);
294
+ }
295
+ if (cfg.build) {
296
+ log('Building the project.');
297
+ const code = await runToEnd(words(cfg.build), {
298
+ cwd: copyDir,
299
+ env: built.env,
300
+ out: buildLog,
301
+ timeoutMs: BUILD_TIMEOUT_MS
302
+ });
303
+ if (code !== 0)
304
+ throw new Error(`The build failed (exit ${code}). Last lines:\n${tailOf(join(runDir, 'build.log'), redact)}`);
305
+ }
306
+ }
307
+ finally {
308
+ closeSync(buildLog);
309
+ }
310
+ // -- 7. START -- //
311
+ const startLog = openSync(join(runDir, 'preview.log'), 'a');
312
+ let child;
313
+ try {
314
+ child = await launch(words(cfg.start.split('{port}').join(String(port))), {
315
+ cwd: copyDir,
316
+ env: built.env,
317
+ out: startLog,
318
+ detached: process.platform !== 'win32'
319
+ });
320
+ }
321
+ finally {
322
+ closeSync(startLog);
323
+ }
324
+ let exited = false;
325
+ child.once('exit', () => (exited = true));
326
+ if (child.pid) {
327
+ const pid = child.pid;
328
+ scope.setInfo({ previewPid: pid });
329
+ scope.add('preview process', () => killTree(pid));
330
+ }
331
+ // -- 8. WAIT UNTIL IT ANSWERS -- //
332
+ const readyUrl = baseUrl + (cfg.ready ?? '/');
333
+ const state = await waitReady(readyUrl, { timeoutMs: READY_TIMEOUT_MS, exited: () => exited });
334
+ if (state !== 'ready') {
335
+ const why = state === 'exited'
336
+ ? 'The preview stopped before it answered.'
337
+ : 'The preview did not answer within 120 seconds.';
338
+ throw new Error(`${why} Last lines:\n${tailOf(join(runDir, 'preview.log'), redact)}`);
339
+ }
340
+ log('The preview answers.');
341
+ // -- 9. RESULT -- //
342
+ return {
343
+ baseUrl,
344
+ commit,
345
+ diffHash,
346
+ testUser: cfg.testUser ?? null,
347
+ secrets,
348
+ note: notes.join(' ')
349
+ };
350
+ }