@agent-flow-board/bridge 0.1.0 → 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,163 @@
1
+ // THE ENVIRONMENT OF A PREVIEW. Owner rule: a check never runs against a database URL that came from a .env file, and never sends mail through a real provider.
2
+ // The repo's own .env files are read from the SOURCE repo (the private copy has none) and adopted, so the app runs with the settings it was written for,
3
+ // EXCEPT database and connection variables (stripped) and mail provider variables (stripped, and the mailer is told to only log). Nothing is
4
+ // ever printed here with its value: notes and the stripped list name variables only.
5
+ // -- DEP-MODULES -- //
6
+ import { randomBytes } from 'node:crypto';
7
+ import { existsSync, readFileSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ // -- CONSTANTS -- //
10
+ /** Read in this order, a later file overriding an earlier one (the order a production build of Vite and SvelteKit uses). */
11
+ const FILES = ['.env', '.env.local', '.env.production', '.env.production.local'];
12
+ /** A name that points at a database or another data store. Any such variable is stripped, whatever its value. */
13
+ const DATABASE_NAME = /(DATABASE|DB|POSTGRES|PG|SUPABASE_SERVICE|REDIS|MONGO|MYSQL).*(URL|URI|HOST|PASSWORD|USER|NAME)?/i;
14
+ /** A value that is a connection string: scheme://user@... or a database scheme. */
15
+ const CONNECTION_VALUE = /^[a-z][a-z0-9+.-]*:\/\/[^\s@]*@/i;
16
+ const DATABASE_SCHEME = /^(postgres(ql)?|mysql|mongodb(\+srv)?|redis|rediss):\/\//i;
17
+ /** A name of a mail provider setting. SES must be a whole word (SESSION_SECRET is not mail). */
18
+ const MAIL_NAME = /(MAIL|SMTP|POSTMARK|SENDGRID|MAILGUN|RESEND)|(^|_)SES(_|$)/i;
19
+ /** What the app's own mailer accepts for "log only" (src/env.ts: EMAIL_PROVIDER is log, resend, postmark or smtp). */
20
+ const MAIL_LOG_ONLY = 'log';
21
+ /** Variables that name the app's own address: pointed at the run's preview, because the value in .env is the live address. */
22
+ const SELF_ADDRESS = /^(ORIGIN|BETTER_AUTH_URL|AUTH_URL|NEXTAUTH_URL|APP_URL|SITE_URL|BASE_URL|(PUBLIC_|VITE_|NEXT_PUBLIC_)(APP|SITE|BASE)_URL)$/;
23
+ /** Never taken from a file: the run's own settings. */
24
+ const RUN_OWN = new Set(['PORT', 'HOST', 'NODE_ENV', 'EMAIL_PROVIDER']);
25
+ /** What a package manager and a build need from the machine. Nothing else of the process environment is passed (no AFB_TOKEN, no *_TOKEN, *_SECRET or *_KEY). */
26
+ const SYSTEM = [
27
+ 'PATH',
28
+ 'SYSTEMROOT',
29
+ 'TEMP',
30
+ 'TMP',
31
+ 'HOME',
32
+ 'USERPROFILE',
33
+ 'APPDATA',
34
+ 'LOCALAPPDATA',
35
+ 'COMSPEC',
36
+ 'PATHEXT'
37
+ ];
38
+ // -- FUNCTIONS -- //
39
+ /** NAME=value lines. `export ` and one pair of quotes are allowed; a quoted value may run over several lines. No expansion, no substitution. */
40
+ export function parseEnvText(text) {
41
+ const out = new Map();
42
+ const lines = (text.charCodeAt(0) === 0xfeff ? text.slice(1) : text).split(/\r?\n/);
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const line = (lines[i] ?? '').trim();
45
+ if (!line || line.startsWith('#'))
46
+ continue;
47
+ const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=\s*(.*)$/.exec(line);
48
+ if (!m || m[1] === undefined)
49
+ continue;
50
+ let v = (m[2] ?? '').trim();
51
+ const q = v[0];
52
+ if (q === '"' || q === "'" || q === '`') {
53
+ let body = v.slice(1);
54
+ let end = body.indexOf(q);
55
+ while (end < 0 && i + 1 < lines.length) {
56
+ body += '\n' + (lines[++i] ?? '');
57
+ end = body.indexOf(q);
58
+ }
59
+ v = end < 0 ? body : body.slice(0, end);
60
+ }
61
+ else
62
+ v = v.replace(/\s+#.*$/, '');
63
+ out.set(m[1], v);
64
+ }
65
+ return out;
66
+ }
67
+ const isFile = (f) => {
68
+ try {
69
+ return existsSync(f) && statSync(f).isFile();
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ };
75
+ /** The merged variables of the repo's env files, a later file winning. A missing file is skipped. */
76
+ function readRepoEnv(repo) {
77
+ const merged = new Map();
78
+ for (const f of FILES) {
79
+ const file = join(repo, f);
80
+ if (!isFile(file))
81
+ continue;
82
+ for (const [k, v] of parseEnvText(readFileSync(file, 'utf8')))
83
+ merged.set(k, v);
84
+ }
85
+ return merged;
86
+ }
87
+ const looksLikeConnection = (name, value) => DATABASE_NAME.test(name) || CONNECTION_VALUE.test(value) || DATABASE_SCHEME.test(value);
88
+ /** Fills the recipe placeholders. A placeholder that needs a database that was not made is left empty and named in the notes. */
89
+ function fill(value, vars, name, notes) {
90
+ return value.replace(/\{(baseUrl|port|random|databaseUrl|databaseOwnerUrl)\}/g, (_all, key) => {
91
+ if (key === 'random')
92
+ return randomBytes(24).toString('hex');
93
+ const v = vars[key];
94
+ if (v === undefined) {
95
+ notes.push(`The recipe variable ${name} needs {${key}}, which this run does not have; it is empty.`);
96
+ return '';
97
+ }
98
+ return v;
99
+ });
100
+ }
101
+ export function buildPreviewEnv(input) {
102
+ const notes = [];
103
+ const stripped = [];
104
+ const env = {};
105
+ // -- SYSTEM -- //
106
+ for (const [k, v] of Object.entries(process.env)) {
107
+ if (v !== undefined && SYSTEM.includes(k.toUpperCase()))
108
+ env[k] = v;
109
+ }
110
+ // -- THE REPO'S OWN ENV FILES -- //
111
+ const fromFiles = readRepoEnv(input.repo);
112
+ let adopted = 0;
113
+ let mailStripped = false;
114
+ for (const [name, value] of fromFiles) {
115
+ if (RUN_OWN.has(name))
116
+ continue;
117
+ if (SELF_ADDRESS.test(name) && /^https?:\/\//i.test(value)) {
118
+ env[name] = input.baseUrl;
119
+ adopted++;
120
+ continue;
121
+ }
122
+ if (looksLikeConnection(name, value)) {
123
+ stripped.push(name);
124
+ continue;
125
+ }
126
+ if (MAIL_NAME.test(name)) {
127
+ stripped.push(name);
128
+ mailStripped = true;
129
+ continue;
130
+ }
131
+ env[name] = value;
132
+ adopted++;
133
+ }
134
+ if (fromFiles.size)
135
+ notes.push(`Adopted ${adopted} variable(s) from the repo's env files.`);
136
+ if (stripped.length)
137
+ notes.push(`Stripped ${stripped.length} database or mail variable(s): ${stripped.join(', ')}.`);
138
+ // -- THE RECIPE -- //
139
+ const vars = {
140
+ baseUrl: input.baseUrl,
141
+ port: String(input.port),
142
+ databaseUrl: input.databaseUrl,
143
+ databaseOwnerUrl: input.databaseOwnerUrl
144
+ };
145
+ for (const [name, value] of Object.entries(input.recipeEnv)) {
146
+ if (RUN_OWN.has(name))
147
+ continue;
148
+ env[name] = fill(value, vars, name, notes);
149
+ }
150
+ // -- THE RUN'S OWN SETTINGS -- //
151
+ env.PORT = String(input.port);
152
+ env.HOST = '127.0.0.1';
153
+ env.NODE_ENV = 'production';
154
+ // The app's own address is always the preview's (SvelteKit's node adapter needs ORIGIN for form posts)
155
+ env.ORIGIN = input.baseUrl;
156
+ env.EMAIL_PROVIDER = MAIL_LOG_ONLY;
157
+ notes.push(`EMAIL_PROVIDER is set to ${MAIL_LOG_ONLY}, the value the app accepts for "log only"${mailStripped ? ' (mail provider variables were stripped)' : ''}.`);
158
+ if (input.databaseUrl !== undefined)
159
+ env.DATABASE_URL = input.databaseUrl;
160
+ if (input.databaseOwnerUrl !== undefined)
161
+ env.DATABASE_OWNER_URL = input.databaseOwnerUrl;
162
+ return { env, stripped, notes };
163
+ }
@@ -0,0 +1,188 @@
1
+ // GUARDS: WHAT A PAGE MAY REACH, WHAT IT MAY CHANGE, AND WHAT A CHECK IS HANDED.
2
+ // Layer one is the browser itself: every host except the preview's is unresolvable (see browser.ts), whatever a check does with routes.
3
+ // Layer two counts and stops writes. Layer three hands a check a facade of the page that cannot lead back to the context or the cookies.
4
+ // This file imports nothing at runtime, so the sandboxed child can load it. Playwright is only described here by the few members it calls.
5
+ // -- CONSTANTS -- //
6
+ const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
7
+ const HIDDEN = new Set([
8
+ 'context',
9
+ 'request',
10
+ 'route',
11
+ 'unroute',
12
+ 'unrouteAll',
13
+ 'routeFromHAR',
14
+ 'routeWebSocket',
15
+ 'addInitScript',
16
+ 'exposeFunction',
17
+ 'exposeBinding',
18
+ 'video',
19
+ 'pause',
20
+ 'browser',
21
+ 'page',
22
+ 'mainFrame',
23
+ 'frames',
24
+ 'frame',
25
+ 'parentFrame',
26
+ 'childFrames',
27
+ 'opener',
28
+ 'storageState',
29
+ 'cookies',
30
+ 'addCookies',
31
+ 'clearCookies',
32
+ 'newPage',
33
+ 'newContext',
34
+ 'newCDPSession'
35
+ ]);
36
+ // Only Playwright's own objects are wrapped: plain data (what evaluate returns) is handed over as it is
37
+ const WRAPPED = /^_?(Locator|FrameLocator|Frame|ElementHandle|JSHandle|Response|Request|Keyboard|Mouse|Touchscreen|Dialog|Download|FileChooser|ConsoleMessage|Worker|WebSocket|Page)$/;
38
+ const NODE_MODULES = 'fs|fs/promises|child_process|net|http|https|http2|dgram|dns|tls|worker_threads|vm|os|cluster|module|v8|inspector|repl|readline|stream|zlib|crypto|perf_hooks|async_hooks|wasi|tty|process';
39
+ const UNSAFE = [
40
+ [/\bchild_process\b/, 'child_process'],
41
+ [/node:(net|http|https|http2|dgram|dns|tls|worker_threads|vm)\b/, 'a network or process module'],
42
+ [/\brequire\s*\(/, 'require()'],
43
+ [
44
+ new RegExp(`\\bfrom\\s+['"](?:node:)?(?:${NODE_MODULES})['"]`),
45
+ 'a module of Node other than path and url'
46
+ ],
47
+ [
48
+ new RegExp(`\\bimport\\s+['"](?:node:)?(?:${NODE_MODULES})['"]`),
49
+ 'a module of Node other than path and url'
50
+ ],
51
+ [/\bprocess\.(env|binding|kill|exit|getBuiltinModule)\b/, 'process'],
52
+ [/\bfetch\s*\(/, 'fetch()'],
53
+ [/\bXMLHttpRequest\b/, 'XMLHttpRequest'],
54
+ [/\bWebSocket\b/, 'WebSocket'],
55
+ // A dynamic import is fine only for a file next to the check (a relative name written out in full)
56
+ [/\bimport\s*\(\s*(?!['"]\.{1,2}\/)/, 'a dynamic import of a name that is not a relative file'],
57
+ [/node:fs|\bfrom\s+['"]fs['"]/, 'the file system'],
58
+ [/\bprofiles\b/, 'the profiles folder'],
59
+ [
60
+ /\.(page|context|browser|cookies|storageState|mainFrame|frames|frame|newPage|newContext|route|unroute|routeFromHAR|routeWebSocket|addInitScript|exposeFunction|exposeBinding)\s*\(|\.request\b/,
61
+ 'a way back to the browser context'
62
+ ],
63
+ [
64
+ /\bgetBuiltinModule\b|\bglobalThis\b|\bFunction\s*\(|\beval\s*\(|\[\s*['"]constructor['"]\s*\]/,
65
+ 'a way around the checks'
66
+ ]
67
+ ];
68
+ // -- FUNCTIONS -- //
69
+ export const hostOf = (url) => {
70
+ try {
71
+ return new URL(url).hostname;
72
+ }
73
+ catch {
74
+ return '';
75
+ }
76
+ };
77
+ const sameOrigin = (url, origin) => {
78
+ try {
79
+ return new URL(url).origin === origin;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ };
85
+ /**
86
+ * Stops every write and counts what it stopped. A page that submits a form is answered with nothing so it stays where it is.
87
+ * Writes are only let through when the person said so for this run (allowWrites).
88
+ */
89
+ export async function guardContext(ctx, { origin, allowWrites = false, extraHosts = [], onBlocked = () => { } }) {
90
+ const counts = { writes: 0, outside: 0 };
91
+ await ctx.route('**/*', async (route) => {
92
+ const req = route.request();
93
+ const url = req.url();
94
+ if (url.startsWith('data:') || url.startsWith('blob:'))
95
+ return route.continue();
96
+ if (!sameOrigin(url, origin) && !extraHosts.includes(hostOf(url))) {
97
+ counts.outside++;
98
+ onBlocked(`blocked a request to ${hostOf(url)}`);
99
+ return route.abort();
100
+ }
101
+ if (!READ_METHODS.has(req.method()) && !allowWrites) {
102
+ counts.writes++;
103
+ onBlocked(`stopped a ${req.method()} to ${new URL(url).pathname}`);
104
+ return req.resourceType() === 'document'
105
+ ? route.fulfill({ status: 204, body: '' })
106
+ : route.abort();
107
+ }
108
+ return route.continue();
109
+ });
110
+ return counts;
111
+ }
112
+ const refuse = (prop) => () => {
113
+ throw new Error(`${prop} is not available to a check. Checks drive the page; the tool keeps the session and the network rules.`);
114
+ };
115
+ const isThenable = (v) => typeof v.then === 'function';
116
+ /** Wraps something a page returned (a locator, a response, a promise of one) so it can never lead back to the context. */
117
+ function wrap(value, guard) {
118
+ if (value === null || typeof value !== 'object')
119
+ return value;
120
+ if (isThenable(value))
121
+ return value.then((v) => wrap(v, guard));
122
+ if (Array.isArray(value))
123
+ return value.map((v) => wrap(v, guard));
124
+ const name = value.constructor?.name ?? '';
125
+ if (!WRAPPED.test(name))
126
+ return value;
127
+ return proxy(value, guard);
128
+ }
129
+ const originals = new WeakMap();
130
+ /** The real object behind something a check was handed, for the tool's own use (expect needs the real locator). */
131
+ export const unwrap = (v) => typeof v === 'object' && v !== null ? (originals.get(v) ?? v) : v;
132
+ function proxy(value, guard) {
133
+ const made = new Proxy(value, {
134
+ get(target, prop) {
135
+ if (typeof prop === 'string' && HIDDEN.has(prop))
136
+ return new Proxy(refuse(prop), {
137
+ get: refuse(prop),
138
+ apply: refuse(prop),
139
+ set: refuse(prop)
140
+ });
141
+ // expect() talks to a locator through its internal _expect and nothing else
142
+ if (typeof prop === 'string' && prop.startsWith('_') && prop !== '_expect')
143
+ return undefined;
144
+ const v = Reflect.get(target, prop, target);
145
+ // Symbols and the constructor are handed over as they are, so instanceof checks and expect() still recognise a locator
146
+ if (typeof prop === 'symbol' || prop === 'constructor')
147
+ return v;
148
+ if (typeof v !== 'function')
149
+ return wrap(v, guard);
150
+ return (...args) => {
151
+ if (prop === 'goto')
152
+ guard.assertNavigable(args[0]);
153
+ return wrap(v.apply(target, args), guard);
154
+ };
155
+ }
156
+ });
157
+ originals.set(made, value);
158
+ return made;
159
+ }
160
+ /** What a check receives instead of the real page: no context, no cookies, no request client, no routes, and no way back to them through a frame, a locator or a response. */
161
+ export function pageForCheck(page, { origin, extraHosts = [] } = {}) {
162
+ const guard = {
163
+ assertNavigable(url) {
164
+ const u = String(url ?? '');
165
+ if (u.startsWith('/') && !u.startsWith('//'))
166
+ return;
167
+ let parsed;
168
+ try {
169
+ parsed = new URL(u);
170
+ }
171
+ catch {
172
+ throw new Error(`The check tried to open "${u.slice(0, 60)}", which is not an address of the app under test.`);
173
+ }
174
+ if (!['http:', 'https:'].includes(parsed.protocol) ||
175
+ (origin && parsed.origin !== origin && !extraHosts.includes(parsed.hostname)))
176
+ throw new Error(`The check tried to open ${parsed.protocol}//${parsed.host}, which is not the app under test.`);
177
+ }
178
+ };
179
+ return proxy(page, guard);
180
+ }
181
+ /** A speed bump, not a wall: names what a check does that a check has no business doing. Refused unless the person says --allow-unsafe. */
182
+ export function lintCheck(source) {
183
+ const found = [];
184
+ for (const [re, label] of UNSAFE)
185
+ if (re.test(source) && !found.includes(label))
186
+ found.push(label);
187
+ return found;
188
+ }
@@ -0,0 +1,155 @@
1
+ // THE GUARDRAILS OF THE CHECK COMMANDS. THEY ARE CONSTANTS IN THIS FILE, NEVER DATA: NOTHING IN THE REPO UNDER TEST OR IN A RECIPE CAN WIDEN THEM.
2
+ // WHAT THIS DOES AND DOES NOT PROTECT. The preview runs the code of the repo under test, and the agent writes that code. So no rule here can
3
+ // stop an agent that means harm: it could put anything in the app. The rules stop mistakes and keep real things safe: the database that holds
4
+ // real data, the ports of the servers a person runs, the programs a build is allowed to start, and folders that were never meant to be built.
5
+ // -- DEP-MODULES -- //
6
+ import { existsSync, realpathSync, statSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { dirname, join, parse, resolve, sep } from 'node:path';
9
+ // -- MODULES -- //
10
+ import { configDir } from '../tokenStore.js';
11
+ // -- CONSTANTS -- //
12
+ /** Never used by a check: the dev server, the build preview that is served, and the local Docker PostgreSQL that holds real data. */
13
+ export const FORBIDDEN_PORTS = new Set([5180, 5190, 5433]);
14
+ /** The only flags of a build or start command, besides the script name. Anything else could run a file the agent wrote. */
15
+ const ARG_OK = /^(--port|\{port\}|--host|127\.0\.0\.1|--strictPort|--)$/;
16
+ /** The one word a script may be given besides flags: what a database command should do. */
17
+ const SUBCOMMAND_OK = /^migrate$/;
18
+ const WORD = /^[A-Za-z0-9_:.@-]+$/;
19
+ /**
20
+ * What the body of a package.json script may be: a known build or preview program with a plain command and plain flags. A body that runs a
21
+ * file, chains commands or sets variables is refused, because that is where an arbitrary program would hide.
22
+ */
23
+ const BODY_OK = /^(vite|svelte-kit|next|astro|nuxt|nuxi|remix) [a-z][a-z-]*( --?[A-Za-z][A-Za-z-]*( [A-Za-z0-9_.:{}/-]+)?){0,4}$/;
24
+ /** Folders that hold keys and logins: a build never runs inside or at one of them. */
25
+ const SECRET_FOLDERS = ['.ssh', '.gnupg', '.aws', '.kube', '.docker'];
26
+ // -- FUNCTIONS -- //
27
+ const hasPort = (text, port) => new RegExp(`(^|[^0-9])${port}([^0-9]|$)`).test(text);
28
+ /** Paths compare without case on Windows only. */
29
+ const same = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
30
+ const insideOrSame = (parent, child) => {
31
+ const p = process.platform === 'win32' ? parent.toLowerCase() : parent;
32
+ const c = process.platform === 'win32' ? child.toLowerCase() : child;
33
+ return c === p || c.startsWith(p.endsWith(sep) ? p : p + sep);
34
+ };
35
+ /** One plain sentence when a package.json script is not a plain build or preview command, else null. */
36
+ export function scriptBodyProblem(name, scripts) {
37
+ const body = String(scripts[name] ?? '');
38
+ if (!BODY_OK.test(body))
39
+ return `The script "${name}" runs "${body}", which is not a plain build or preview command (vite, svelte-kit, next, astro, nuxt or remix with plain flags).`;
40
+ // A script body may not name a port a check never uses, nor bind to anything but this machine
41
+ for (const p of FORBIDDEN_PORTS)
42
+ if (hasPort(body, p))
43
+ return `The script "${name}" mentions port ${p}, which a check never uses.`;
44
+ const host = /--host(?:\s+(\S+))?/.exec(body);
45
+ if (host && host[1] !== '127.0.0.1')
46
+ return `The script "${name}" binds the preview to something other than 127.0.0.1.`;
47
+ // A pre or post script runs by itself around it, so it is held to the same rule
48
+ for (const hook of [`pre${name}`, `post${name}`])
49
+ if (Object.prototype.hasOwnProperty.call(scripts, hook) && !BODY_OK.test(String(scripts[hook])))
50
+ return `The script "${hook}" runs by itself around "${name}" and is "${scripts[hook]}", which is not a plain build or preview command.`;
51
+ return null;
52
+ }
53
+ /**
54
+ * One plain sentence when a build or start command is outside the allowed forms, else null. Allowed: `yarn|npm|pnpm [run] <script>` where the
55
+ * script is named in package.json and its body is a plain build or preview command, with only the flags above. No shell characters, no `..`,
56
+ * no other program, and not `node <file>`: a file is where the agent's own program would run.
57
+ */
58
+ export function commandProblem(cmd, scripts, what) {
59
+ const text = String(cmd ?? '').trim();
60
+ if (!text)
61
+ return `${what} is empty.`;
62
+ if (/[&|;<>`$()\\\r\n"'*?%^!]/.test(text) || text.includes('..'))
63
+ return `${what} "${text}" has a character a shell would act on.`;
64
+ const t = text.split(/\s+/);
65
+ if (!['yarn', 'npm', 'pnpm'].includes(t[0]))
66
+ return `${what} "${text}" starts with ${t[0]}. Only yarn, npm or pnpm running a package.json script are allowed.`;
67
+ let i = 1;
68
+ if (t[i] === 'run')
69
+ i++;
70
+ const script = t[i];
71
+ if (!script || !WORD.test(script))
72
+ return `${what} "${text}" names no package.json script.`;
73
+ if (!Object.prototype.hasOwnProperty.call(scripts, script))
74
+ return `${what} "${text}" runs "${script}", which is not a script in package.json.`;
75
+ const body = scriptBodyProblem(script, scripts);
76
+ if (body)
77
+ return `${what} "${text}": ${body}`;
78
+ const rest = t.slice(i + 1);
79
+ for (let j = 0; j < rest.length; j++) {
80
+ if (j === 0 && SUBCOMMAND_OK.test(rest[j]))
81
+ continue;
82
+ if (!ARG_OK.test(rest[j]))
83
+ return `${what} "${text}" has the argument ${rest[j]}, which is not one of --port, --host 127.0.0.1 or --strictPort.`;
84
+ if (rest[j] === '--host' && rest[j + 1] !== '127.0.0.1')
85
+ return `${what} "${text}" binds the preview to something other than 127.0.0.1.`;
86
+ if (rest[j] === '--port' && rest[j + 1] !== '{port}')
87
+ return `${what} "${text}" fixes the port. It must be {port}, so the tool picks a free one.`;
88
+ }
89
+ return null;
90
+ }
91
+ /** True when the folder is a git project or sits inside one (a .git folder or file here or in a parent). */
92
+ function insideGit(folder) {
93
+ let dir = folder;
94
+ for (;;) {
95
+ if (existsSync(join(dir, '.git')))
96
+ return true;
97
+ const up = dirname(dir);
98
+ if (up === dir)
99
+ return false;
100
+ dir = up;
101
+ }
102
+ }
103
+ /** The real path of a folder, or its plain resolved path when it cannot be read. */
104
+ function realOrResolved(folder) {
105
+ try {
106
+ return realpathSync.native(folder);
107
+ }
108
+ catch {
109
+ return resolve(folder);
110
+ }
111
+ }
112
+ /**
113
+ * One plain sentence when the folder may not be built by a check, else null. The rules: it is a folder that exists, a git project (or inside
114
+ * one), not a drive root, the home folder, the bridge home or a folder of keys and logins, it has no link in its path (a symlink or a Windows
115
+ * junction would let the build leave the folder the person named), and it holds a package.json.
116
+ */
117
+ export function checkRepoFolder(folder) {
118
+ if (typeof folder !== 'string' || !folder.trim())
119
+ return 'No folder is given.';
120
+ const resolved = resolve(folder);
121
+ let stat;
122
+ try {
123
+ stat = statSync(resolved);
124
+ }
125
+ catch {
126
+ return `The folder ${resolved} does not exist.`;
127
+ }
128
+ if (!stat.isDirectory())
129
+ return `${resolved} is not a folder.`;
130
+ if (parse(resolved).root === resolved)
131
+ return `${resolved} is the top of a drive. Pick the project's own folder.`;
132
+ // The real path must be the path as written, component by component: any link on the way is refused
133
+ let real;
134
+ try {
135
+ real = realpathSync.native(resolved);
136
+ }
137
+ catch {
138
+ return `The folder ${resolved} cannot be read.`;
139
+ }
140
+ if (!same(real, resolved))
141
+ return `${resolved} goes through a link (it really is ${real}). Give the real folder path.`;
142
+ if (same(resolved, resolve(homedir())))
143
+ return 'That is the home folder. Pick the project folder inside it.';
144
+ if (insideOrSame(realOrResolved(configDir()), real))
145
+ return 'That folder is the bridge home, which holds the sign-in. A check never builds there.';
146
+ for (const part of resolved.split(sep)) {
147
+ if (SECRET_FOLDERS.includes(part.toLowerCase()))
148
+ return `${resolved} is inside a ${part} folder, which holds keys or logins. A check never builds there.`;
149
+ }
150
+ if (!insideGit(resolved))
151
+ return `${resolved} is not a git project (no .git here or above it).`;
152
+ if (!existsSync(join(resolved, 'package.json')))
153
+ return `${resolved} has no package.json.`;
154
+ return null;
155
+ }