CLI runs on Node 20+: npx storebound (bunx keeps working)
- cli/lib/rt.ts: the runtime layer on node: modules (spawn / spawnSync with Bun's defaults, exit codes and "Executable not found", which with PATHEXT, sleep, serve, Glob, hasher, gunzip, stdin, SQLite, packageRoot, config imports with a type-stripping fallback for Node 20); every Bun.* use in cli/ goes through it - tools/gen-embedded.ts -> cli/lib/embedded.ts: templates/, cli/shots/*.html and Play's Data safety form embedded (replaces Bun text imports; the bundle and the compiled sidecar carry them), checked fresh by bun run ci - package.json: bin dist/storebound.mjs (Node ESM bundle, tools/build-node.ts, prepack), engines node >=20 - bun run ci: node bundle + node smoke (real node: --version, help, check same as Bun, scan, release plan --help, init, MCP handshake + tool call); Forgejo CI sets up Node 20; tests/node-compat.test.ts guards cli/ - install writes npx MCP commands when run with Node; bin/storebound falls back to Node - docs: npx storebound init (Node 20+) or bunx storebound init
This commit is contained in:
parent
614c32bcba
commit
36a8bf9625
73 changed files with 1195 additions and 251 deletions
|
|
@ -2,6 +2,7 @@
|
|||
// do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional.
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import * as rt from './rt';
|
||||
import { validateCapture, validateRecord } from '../shots/devices';
|
||||
import { type AppleDataTypeId, type AppleTypeAnswer, validateAppPrivacyTypes } from './app-privacy';
|
||||
import { type AppEventDef, type CustomProductPageDef, validateCpp, validateEvents } from './cpp-events';
|
||||
|
|
@ -1171,7 +1172,7 @@ export async function loadConfig(explicit?: string, start = process.cwd()): Prom
|
|||
}
|
||||
}
|
||||
if (!path || !existsSync(path)) throw new Error('no storebound.config.ts found (run `storebound init` or pass --config)');
|
||||
const mod = await import(path);
|
||||
const mod = await rt.importConfigModule(path);
|
||||
const config = (mod.default ?? mod) as ShipConfig;
|
||||
const errors = validateConfig(config);
|
||||
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
// App content > Data safety > Export to CSV with nothing answered, saved over that file unchanged (docs/pitfalls.md 21).
|
||||
import type { AccountsConfig, DataSafety, PlayPurpose } from './config';
|
||||
import { csvCell, parseCsv } from './csv';
|
||||
import FORM_TEXT from '../data/play-data-safety-form.csv' with { type: 'text' };
|
||||
import { PLAY_DATA_SAFETY_FORM as FORM_TEXT } from './embedded';
|
||||
|
||||
export { csvCell };
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ const pairKey = (q: string, r: string) => `${q}\u0000${r}`;
|
|||
export function playForm(): Map<string, PlayFormRow> {
|
||||
if (formCache) return formCache;
|
||||
const m = new Map<string, PlayFormRow>();
|
||||
for (const r of parseCsv(FORM_TEXT as unknown as string).slice(1)) {
|
||||
for (const r of parseCsv(FORM_TEXT).slice(1)) {
|
||||
const [question = '', response = '', , requirement = '', label = ''] = r;
|
||||
m.set(pairKey(question, response), { question, response, requirement, label: label.trim() });
|
||||
}
|
||||
|
|
|
|||
48
cli/lib/embedded.ts
Normal file
48
cli/lib/embedded.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,6 +4,7 @@
|
|||
// hashes differ in few bits look alike to a person (and to a reviewer comparing store pages).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { inflateSync } from 'node:zlib';
|
||||
import * as rt from './rt';
|
||||
|
||||
export interface Gray { w: number; h: number; px: Float64Array }
|
||||
|
||||
|
|
@ -128,9 +129,9 @@ export function hashFile(path: string): { hash: ImageHash | null; reason?: strin
|
|||
try {
|
||||
const buf = readFileSync(path);
|
||||
if (buf.length > 8 && buf.readUInt32BE(0) === 0x89504e47) return { hash: hashGray(decodePng(buf)) };
|
||||
const ffmpeg = Bun.which('ffmpeg');
|
||||
const ffmpeg = rt.which('ffmpeg');
|
||||
if (!ffmpeg) return { hash: null, reason: 'not a PNG and ffmpeg is not installed' };
|
||||
const r = Bun.spawnSync([ffmpeg, '-v', 'error', '-i', path, '-frames:v', '1', '-vf', 'scale=32:32:flags=area,format=gray', '-f', 'rawvideo', '-'], { stdout: 'pipe', stderr: 'pipe', timeout: 20_000 });
|
||||
const r = rt.spawnSync([ffmpeg, '-v', 'error', '-i', path, '-frames:v', '1', '-vf', 'scale=32:32:flags=area,format=gray', '-f', 'rawvideo', '-'], { stdout: 'pipe', stderr: 'pipe', timeout: 20_000 });
|
||||
const px = r.stdout;
|
||||
if (r.exitCode !== 0 || px.length !== 1024) return { hash: null, reason: `ffmpeg could not read it (${r.stderr.toString().trim().split('\n')[0]?.slice(0, 120) ?? r.exitCode})` };
|
||||
return { hash: hashGray({ w: 32, h: 32, px: Float64Array.from(px) }) };
|
||||
|
|
|
|||
465
cli/lib/rt.ts
Normal file
465
cli/lib/rt.ts
Normal file
|
|
@ -0,0 +1,465 @@
|
|||
// The runtime layer: everything the CLI needs from its JavaScript runtime, written against Node's own modules so the
|
||||
// same code runs on Node 20+ (`npx storebound`, the dist/storebound.mjs bundle) and on Bun (`bunx storebound`, the
|
||||
// sources, the compiled desktop sidecar). The CLI never touches the `Bun` global directly; a test
|
||||
// (tests/node-compat.test.ts) keeps it that way. The behaviour follows Bun's where callers rely on it: spawn's
|
||||
// defaults (stdin ignored, stdout piped, stderr inherited), `exited` resolving to the exit code or 128 + the signal
|
||||
// number, a missing executable throwing "Executable not found in $PATH" right away, spawnSync never limiting output.
|
||||
import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type StdioOptions } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import * as nodeModule from 'node:module';
|
||||
import { constants } from 'node:os';
|
||||
import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path';
|
||||
import type { Readable } from 'node:stream';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { gunzipSync as zGunzipSync } from 'node:zlib';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const BUN: any = (globalThis as any).Bun;
|
||||
|
||||
/** True under Bun (sources, bunx, the compiled sidecar); false under Node (the npm bundle). */
|
||||
export const isBun = BUN !== undefined;
|
||||
|
||||
/** Runtime name and version, for `doctor` and `system.version`. */
|
||||
export const runtime = { name: isBun ? 'bun' : 'node', version: isBun ? String(BUN.version) : process.versions.node } as const;
|
||||
|
||||
/** Bun's version, or null under Node. */
|
||||
export const bunVersion: string | null = isBun ? String(BUN.version) : null;
|
||||
|
||||
/** The entry script the runtime started (Bun.main; under Node the script in argv[1]). */
|
||||
export const mainPath = (): string => (isBun ? String(BUN.main) : process.argv[1] ?? '');
|
||||
|
||||
// ---- time ----
|
||||
|
||||
export const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, Math.max(0, ms)));
|
||||
|
||||
/** Blocks the thread (only for short retries in cleanup code). */
|
||||
export function sleepSync(ms: number): void {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(0, ms));
|
||||
}
|
||||
|
||||
// ---- files and paths ----
|
||||
|
||||
/** Folder of a module: `here(import.meta.url)` (Bun's import.meta.dir, which Node does not have). */
|
||||
export const here = (url: string): string => dirname(fileURLToPath(url));
|
||||
|
||||
let rootCache: string | null = null;
|
||||
|
||||
/**
|
||||
* The storebound package folder (package.json, templates/, skills/, cli/): found upwards from this module, which is
|
||||
* cli/lib/rt.ts in the sources and dist/storebound.mjs in the npm bundle. In the compiled sidecar (no package on
|
||||
* disk) it is the folder two levels up, as before.
|
||||
*/
|
||||
export function packageRoot(): string {
|
||||
if (rootCache) return rootCache;
|
||||
const start = here(import.meta.url);
|
||||
for (let d = start; ; d = dirname(d)) {
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')) as { name?: string };
|
||||
if (pkg.name === 'storebound') return (rootCache = d);
|
||||
} catch { /* keep looking */ }
|
||||
if (dirname(d) === d) break;
|
||||
}
|
||||
return (rootCache = resolve(start, '..', '..'));
|
||||
}
|
||||
|
||||
/** `require.resolve(spec)` from the folder `dir` (Bun.resolveSync(spec, dir)); throws when it is not found. */
|
||||
export function resolveFrom(spec: string, dir: string): string {
|
||||
return nodeModule.createRequire(join(resolve(dir), '__storebound_resolve__.js')).resolve(spec);
|
||||
}
|
||||
|
||||
/** Imports a module by file path (absolute paths become file: URLs, which Node needs on Windows). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const importPath = (path: string): Promise<any> => import(pathToFileURL(path).href);
|
||||
|
||||
/**
|
||||
* Imports a config module (storebound.config.ts / .js). Bun runs TypeScript itself; so does Node 22.18+ / 23.6+.
|
||||
* Older Node (20, 22.x) refuses `.ts` (ERR_UNKNOWN_FILE_EXTENSION): the types are stripped (node:module's
|
||||
* stripTypeScriptTypes when it exists, else the small stripper below, enough for a config file) and the result is
|
||||
* imported from a data: URL.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export async function importConfigModule(path: string): Promise<any> {
|
||||
if (isBun || !/\.[cm]?ts$/.test(path)) return importPath(path);
|
||||
try {
|
||||
return await withoutTypeStripWarning(() => importPath(path));
|
||||
} catch (e) {
|
||||
const code = (e as { code?: string }).code;
|
||||
if (code !== 'ERR_UNKNOWN_FILE_EXTENSION' && code !== 'ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING' && !/Unknown file extension/.test(String(e))) throw e;
|
||||
}
|
||||
const src = readFileSync(path, 'utf8');
|
||||
let js: string;
|
||||
const strip = (nodeModule as unknown as { stripTypeScriptTypes?: (s: string) => string }).stripTypeScriptTypes;
|
||||
try { js = typeof strip === 'function' ? withoutTypeStripWarningSync(() => strip(src)) : stripTypes(src); } catch { js = stripTypes(src); }
|
||||
if (/^\s*import\s[^'"]*['"]\.{1,2}\//m.test(js)) throw new Error(`${path}: this Node (${process.versions.node}) cannot load a TypeScript config that imports other files; use Node 22.18+ or Bun, or a storebound.config.js`);
|
||||
return import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`);
|
||||
}
|
||||
|
||||
/** Node 22.18+ prints an ExperimentalWarning the first time it strips types; storebound's output stays clean. */
|
||||
async function withoutTypeStripWarning<T>(f: () => Promise<T>): Promise<T> {
|
||||
const emit = process.emitWarning;
|
||||
process.emitWarning = ((w: string | Error, ...rest: unknown[]) => {
|
||||
if (/Type Stripping|type stripping/i.test(String(typeof w === 'string' ? w : w.message))) return;
|
||||
return (emit as (...a: unknown[]) => void).call(process, w, ...rest);
|
||||
}) as typeof process.emitWarning;
|
||||
try { return await f(); } finally { process.emitWarning = emit; }
|
||||
}
|
||||
|
||||
function withoutTypeStripWarningSync<T>(f: () => T): T {
|
||||
const emit = process.emitWarning;
|
||||
process.emitWarning = (() => undefined) as typeof process.emitWarning;
|
||||
try { return f(); } finally { process.emitWarning = emit; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the TypeScript a storebound config uses (type-only imports and exports, `: Type` on a declaration,
|
||||
* `satisfies T`, `as T`) outside strings and comments. Not a TypeScript compiler: a fallback for Node versions
|
||||
* without type stripping.
|
||||
*/
|
||||
export function stripTypes(src: string): string {
|
||||
const parts: { code: boolean; text: string }[] = [];
|
||||
let i = 0, start = 0;
|
||||
const push = (code: boolean, end: number) => { if (end > start) parts.push({ code, text: src.slice(start, end) }); start = end; };
|
||||
while (i < src.length) {
|
||||
const c = src[i]!, n = src[i + 1];
|
||||
if (c === '/' && n === '/') { push(true, i); const e = src.indexOf('\n', i); i = e < 0 ? src.length : e; push(false, i); continue; }
|
||||
if (c === '/' && n === '*') { push(true, i); const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; push(false, i); continue; }
|
||||
if (c === '"' || c === "'" || c === '`') {
|
||||
push(true, i);
|
||||
for (i++; i < src.length && src[i] !== c; i++) if (src[i] === '\\') i++;
|
||||
i++; push(false, i); continue;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
push(true, src.length);
|
||||
const TYPE = String.raw`[A-Za-z_$][\w$.]*(?:<[^;=(){}]*?>)?(?:\[\])*(?:\s*\|\s*[A-Za-z_$][\w$.]*(?:<[^;=(){}]*?>)?(?:\[\])*)*`;
|
||||
const out = parts.map(p => !p.code ? p.text : p.text
|
||||
.replace(new RegExp(String.raw`\b(const|let|var)(\s+[A-Za-z_$][\w$]*)\s*:\s*${TYPE}\s*=`, 'g'), '$1$2 =')
|
||||
.replace(new RegExp(String.raw`\s+satisfies\s+${TYPE}`, 'g'), '')
|
||||
.replace(new RegExp(String.raw`\s+as\s+(?:const\b|${TYPE})`, 'g'), '')
|
||||
.replace(/\{\s*type\s+/g, '{ ')
|
||||
.replace(/,\s*type\s+(?=[A-Za-z_$])/g, ', ')).join('');
|
||||
// type-only imports and exports, whose specifier string sits in its own (non-code) part
|
||||
return out
|
||||
.replace(/^\s*import\s+type\s[^;]*?from\s*(['"])[^'"]*\1\s*;?[ \t]*$/gm, '')
|
||||
.replace(/^\s*export\s+type\s+\{[^}]*\}(\s*from\s*(['"])[^'"]*\2)?\s*;?[ \t]*$/gm, '')
|
||||
.replace(/^\s*(export\s+)?(type\s+[A-Za-z_$][\w$]*(<[^>]*>)?\s*=[^;]*;|interface\s+[A-Za-z_$][\w$]*[^{]*\{[^}]*\})/gm, '');
|
||||
}
|
||||
|
||||
// ---- hashing, compression ----
|
||||
|
||||
/** Incremental hash (Bun.CryptoHasher): `hasher('md5').update(buf).digest('hex')`. */
|
||||
export const hasher = (algorithm: 'md5' | 'sha1' | 'sha256' | 'sha512') => createHash(algorithm);
|
||||
|
||||
export const gunzipSync = (data: Uint8Array): Uint8Array => new Uint8Array(zGunzipSync(data));
|
||||
|
||||
// ---- PATH lookup ----
|
||||
|
||||
/**
|
||||
* Bun.which: the full path of `cmd` on PATH (`opts.PATH` instead of process.env.PATH), or null. A name with a path
|
||||
* separator is checked as is. On Windows every PATHEXT extension is tried.
|
||||
*/
|
||||
export function which(cmd: string, opts: { PATH?: string; cwd?: string } = {}): string | null {
|
||||
if (!cmd) return null;
|
||||
const win = process.platform === 'win32';
|
||||
const exts = win ? ['', ...(process.env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean).map(e => e.toLowerCase())] : [''];
|
||||
const ok = (p: string): boolean => {
|
||||
try {
|
||||
const s = statSync(p);
|
||||
if (!s.isFile()) return false;
|
||||
return win || (s.mode & 0o111) !== 0;
|
||||
} catch { return false; }
|
||||
};
|
||||
const tryExts = (base: string): string | null => {
|
||||
for (const e of exts) {
|
||||
if (win && e === '' && !/\.[^\\/.]+$/.test(base)) continue;
|
||||
const p = base + e;
|
||||
if (ok(p)) return p;
|
||||
if (win && e && ok(base + e.toUpperCase())) return base + e.toUpperCase();
|
||||
}
|
||||
return null;
|
||||
};
|
||||
if (/[\\/]/.test(cmd)) return tryExts(isAbsolute(cmd) ? cmd : resolve(opts.cwd ?? process.cwd(), cmd));
|
||||
const PATH = opts.PATH ?? process.env.PATH ?? (win ? process.env.Path : undefined) ?? '';
|
||||
for (const dir of PATH.split(delimiter)) {
|
||||
if (!dir) continue;
|
||||
const hit = tryExts(join(dir.replace(/^"(.*)"$/, '$1'), cmd));
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- processes ----
|
||||
|
||||
type Io = 'pipe' | 'inherit' | 'ignore';
|
||||
|
||||
export interface SpawnOptions {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
stdin?: Io | null;
|
||||
stdout?: Io | null;
|
||||
stderr?: Io | null;
|
||||
/** [stdin, stdout, stderr]; wins over the single options. */
|
||||
stdio?: [Io | null, Io | null, Io | null];
|
||||
}
|
||||
|
||||
/** A running child, shaped like Bun's Subprocess (the parts storebound uses). */
|
||||
export interface Subprocess {
|
||||
readonly pid: number | undefined;
|
||||
/** The piped output (an empty, closed stream when not piped). */
|
||||
readonly stdout: ReadableStream<Uint8Array>;
|
||||
readonly stderr: ReadableStream<Uint8Array>;
|
||||
/** The exit code; 128 + the signal number when a signal ended it. Never rejects. */
|
||||
readonly exited: Promise<number>;
|
||||
readonly exitCode: number | null;
|
||||
readonly signalCode: string | null;
|
||||
readonly killed: boolean;
|
||||
kill(signal?: NodeJS.Signals | number): void;
|
||||
/** Lets this process exit while the child runs on. */
|
||||
unref(): void;
|
||||
}
|
||||
|
||||
const notFound = (cmd: string): Error => Object.assign(new Error(`Executable not found in $PATH: "${cmd}"`), { code: 'ENOENT', path: cmd });
|
||||
|
||||
/** Throws like Bun when the executable does not exist (Node would only emit an asynchronous 'error'). */
|
||||
function checkExecutable(cmd: string | undefined, o: { cwd?: string; env?: Record<string, string | undefined> }): string {
|
||||
if (!cmd) throw new Error('spawn: empty command');
|
||||
const PATH = o.env ? (o.env.PATH ?? o.env.Path ?? '') : undefined;
|
||||
if (!which(cmd, { ...(PATH !== undefined ? { PATH } : {}), ...(o.cwd ? { cwd: o.cwd } : {}) })) throw notFound(cmd);
|
||||
return cmd;
|
||||
}
|
||||
|
||||
const io = (v: Io | null | undefined, dflt: Io): Io => (v === null ? 'ignore' : v ?? dflt);
|
||||
|
||||
const envOf = (env: Record<string, string | undefined> | undefined): NodeJS.ProcessEnv | undefined =>
|
||||
env ? Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)) : undefined;
|
||||
|
||||
/** A node Readable as a web ReadableStream of bytes (or an empty, closed stream for null). */
|
||||
function webStream(r: Readable | null | undefined): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(c) {
|
||||
if (!r) { c.close(); return; }
|
||||
let ended = false;
|
||||
const end = () => { if (!ended) { ended = true; try { c.close(); } catch { /* closed */ } } };
|
||||
r.on('data', (d: Buffer | string) => { if (!ended) c.enqueue(typeof d === 'string' ? new TextEncoder().encode(d) : new Uint8Array(d)); });
|
||||
r.on('end', end);
|
||||
r.on('close', end);
|
||||
r.on('error', () => end());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const signalNumber = (s: NodeJS.Signals | null): number => (s ? (constants.signals as Record<string, number>)[s] ?? 1 : 0);
|
||||
|
||||
/** Bun.spawn: argv[0] on PATH (opts.env.PATH when given), Bun's stdio defaults (stdin ignore, stdout pipe, stderr inherit). */
|
||||
export function spawn(argv: string[], o: SpawnOptions = {}): Subprocess {
|
||||
const cmd = checkExecutable(argv[0], o);
|
||||
const [i0, o0, e0] = o.stdio ?? [o.stdin, o.stdout, o.stderr];
|
||||
const stdio: StdioOptions = [io(i0, 'ignore'), io(o0, 'pipe'), io(e0, 'inherit')];
|
||||
const child = nodeSpawn(cmd, argv.slice(1), { cwd: o.cwd, env: envOf(o.env) ?? process.env, stdio, windowsHide: true });
|
||||
let exitCode: number | null = null, signalCode: string | null = null;
|
||||
const exited = new Promise<number>(res => {
|
||||
let done = false;
|
||||
const finish = (code: number | null, sig: NodeJS.Signals | null) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
exitCode = code; signalCode = sig;
|
||||
res(code ?? (sig ? 128 + signalNumber(sig) : 1));
|
||||
};
|
||||
child.on('exit', finish);
|
||||
child.on('error', () => { if (child.exitCode === null && child.signalCode === null && child.pid === undefined) finish(1, null); });
|
||||
});
|
||||
const stdout = webStream(child.stdout), stderr = webStream(child.stderr);
|
||||
return {
|
||||
get pid() { return child.pid; },
|
||||
stdout, stderr, exited,
|
||||
get exitCode() { return exitCode; },
|
||||
get signalCode() { return signalCode; },
|
||||
get killed() { return child.killed; },
|
||||
kill(signal?: NodeJS.Signals | number) { try { child.kill(signal ?? 'SIGTERM'); } catch { /* gone */ } },
|
||||
unref() {
|
||||
child.unref();
|
||||
for (const s of [child.stdin, child.stdout, child.stderr]) (s as unknown as { unref?: () => void } | null)?.unref?.();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface SpawnSyncOptions {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Bytes or text written to the child's stdin (then closed), or an stdio mode. */
|
||||
stdin?: Uint8Array | string | Io | null;
|
||||
stdout?: Io | null;
|
||||
stderr?: Io | null;
|
||||
/** Milliseconds; the child gets SIGTERM after it (exitCode null, signalCode 'SIGTERM'). */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface SyncResult { exitCode: number | null; signalCode: string | null; success: boolean; stdout: Buffer; stderr: Buffer }
|
||||
|
||||
/** Bun.spawnSync: never throws for a nonzero exit or a timeout, throws when the executable does not exist. */
|
||||
export function spawnSync(argv: string[], o: SpawnSyncOptions = {}): SyncResult {
|
||||
const cmd = checkExecutable(argv[0], o);
|
||||
const input = typeof o.stdin === 'string' && !['pipe', 'inherit', 'ignore'].includes(o.stdin) ? o.stdin : o.stdin instanceof Uint8Array ? o.stdin : undefined;
|
||||
const stdinMode: Io = input !== undefined ? 'pipe' : io(o.stdin as Io | null | undefined, 'ignore');
|
||||
const r = nodeSpawnSync(cmd, argv.slice(1), {
|
||||
cwd: o.cwd, env: envOf(o.env) ?? process.env, input, windowsHide: true,
|
||||
stdio: [stdinMode, io(o.stdout, 'pipe'), io(o.stderr, 'pipe')], maxBuffer: 1 << 30,
|
||||
...(o.timeout ? { timeout: o.timeout, killSignal: 'SIGTERM' as const } : {}),
|
||||
});
|
||||
const err = r.error as (Error & { code?: string }) | undefined;
|
||||
if (err && err.code === 'ENOENT') throw notFound(argv[0]!);
|
||||
if (err && err.code !== 'ETIMEDOUT' && r.status === null && r.signal === null) throw err;
|
||||
const buf = (b: Buffer | string | null | undefined): Buffer => (b == null ? Buffer.alloc(0) : typeof b === 'string' ? Buffer.from(b) : b);
|
||||
return { exitCode: r.status, signalCode: r.signal, success: r.status === 0, stdout: buf(r.stdout), stderr: buf(r.stderr) };
|
||||
}
|
||||
|
||||
// ---- stdin ----
|
||||
|
||||
/** This process's stdin as a web stream (Bun.stdin.stream()). */
|
||||
export const stdinStream = (): ReadableStream<Uint8Array> => webStream(process.stdin);
|
||||
|
||||
/** All of stdin as text (Bun.stdin.text()). */
|
||||
export const stdinText = (): Promise<string> => new Response(stdinStream()).text();
|
||||
|
||||
// ---- a small HTTP server (Bun.serve) ----
|
||||
|
||||
export interface Server { readonly port: number; readonly hostname: string; stop(closeActive?: boolean): void }
|
||||
|
||||
/**
|
||||
* Bun.serve on node:http: `fetch(Request) => Response` for local pages (GET and HEAD; the body is read whole).
|
||||
* Resolves once listening; rejects when the port is taken.
|
||||
*/
|
||||
export function serve(o: { port?: number; hostname?: string; fetch(req: Request): Response | Promise<Response> }): Promise<Server> {
|
||||
const hostname = o.hostname ?? '127.0.0.1';
|
||||
const server = createServer((req, res) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [k, v] of Object.entries(req.headers)) if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(', ') : v);
|
||||
const r = await o.fetch(new Request(`http://${req.headers.host ?? `${hostname}:${port}`}${req.url ?? '/'}`, { method: req.method ?? 'GET', headers }));
|
||||
const body = req.method === 'HEAD' ? null : Buffer.from(await r.arrayBuffer());
|
||||
const h: Record<string, string> = {};
|
||||
r.headers.forEach((v, k) => { h[k] = v; });
|
||||
if (body) h['content-length'] = String(body.length);
|
||||
res.writeHead(r.status, h);
|
||||
res.end(body ?? undefined);
|
||||
} catch (e) {
|
||||
if (!res.headersSent) res.writeHead(500, { 'content-type': 'text/plain' });
|
||||
res.end(String((e as Error).message ?? e));
|
||||
}
|
||||
})();
|
||||
});
|
||||
let port = o.port ?? 0;
|
||||
return new Promise((res, rej) => {
|
||||
server.once('error', rej);
|
||||
server.listen(port, hostname, () => {
|
||||
server.off('error', rej);
|
||||
const a = server.address();
|
||||
if (a && typeof a === 'object') port = a.port;
|
||||
res({
|
||||
port, hostname,
|
||||
stop(closeActive = false) { if (closeActive) server.closeAllConnections?.(); server.close(); },
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** A file as a Response body, or null when it does not exist (Bun.file + exists()). */
|
||||
export function fileResponse(path: string, headers: Record<string, string> = {}): Response | null {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return null;
|
||||
return new Response(readFileSync(path), { headers });
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// ---- globs (Bun.Glob) ----
|
||||
|
||||
/** A glob pattern: `*` and `?` within one path segment, `**` across segments, `[abc]`, `{a,b}`. */
|
||||
export class Glob {
|
||||
readonly re: RegExp;
|
||||
constructor(readonly pattern: string) { this.re = globRegExp(pattern); }
|
||||
match(path: string): boolean { return this.re.test(path.replace(/\\/g, '/')); }
|
||||
/** The matching paths under `cwd`, relative with forward slashes; dot entries only with `dot`; symlinked folders are not entered. */
|
||||
*scanSync(o: { cwd?: string; onlyFiles?: boolean; dot?: boolean } = {}): Generator<string> {
|
||||
const cwd = o.cwd ?? process.cwd();
|
||||
const onlyFiles = o.onlyFiles ?? true;
|
||||
const segs = this.pattern.replace(/\\/g, '/').split('/');
|
||||
const deep = segs.includes('**') || segs.some(s => s.includes('**'));
|
||||
// the literal leading segments: no need to walk anything above them
|
||||
let fixed = 0;
|
||||
while (fixed < segs.length - 1 && !/[*?[{]/.test(segs[fixed]!)) fixed++;
|
||||
const prefix = segs.slice(0, fixed).join('/');
|
||||
const maxDepth = deep ? Infinity : segs.length - fixed;
|
||||
const self = this;
|
||||
function* walk(rel: string, depth: number): Generator<string> {
|
||||
let entries;
|
||||
try { entries = readdirSync(join(cwd, rel), { withFileTypes: true }); } catch { return; }
|
||||
for (const e of entries) {
|
||||
if (!o.dot && e.name.startsWith('.')) continue;
|
||||
const p = rel ? `${rel}/${e.name}` : e.name;
|
||||
const dir = e.isDirectory();
|
||||
if ((!onlyFiles || !dir) && self.re.test(p)) yield p;
|
||||
if (dir && depth + 1 < maxDepth) yield* walk(p, depth + 1);
|
||||
}
|
||||
}
|
||||
if (prefix && !existsSync(join(cwd, prefix))) return;
|
||||
yield* walk(prefix, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export function globRegExp(pattern: string): RegExp {
|
||||
const p = pattern.replace(/\\/g, '/');
|
||||
let re = '';
|
||||
let braces = 0;
|
||||
for (let i = 0; i < p.length; i++) {
|
||||
const c = p[i]!;
|
||||
if (c === '*') {
|
||||
if (p[i + 1] === '*') {
|
||||
const atStart = i === 0 || p[i - 1] === '/';
|
||||
i++;
|
||||
if (atStart && p[i + 1] === '/') { re += '(?:[^/]*/)*'; i++; }
|
||||
else re += '.*';
|
||||
} else re += '[^/]*';
|
||||
} else if (c === '?') re += '[^/]';
|
||||
else if (c === '[') {
|
||||
const end = p.indexOf(']', i + 1);
|
||||
if (end < 0) { re += '\\['; continue; }
|
||||
let body = p.slice(i + 1, end);
|
||||
if (body.startsWith('!')) body = '^' + body.slice(1);
|
||||
re += `[${body.replace(/\\/g, '\\\\')}]`;
|
||||
i = end;
|
||||
} else if (c === '{') { braces++; re += '(?:'; }
|
||||
else if (c === '}' && braces) { braces--; re += ')'; }
|
||||
else if (c === ',' && braces) re += '|';
|
||||
else re += c.replace(/[.+^$()|\\]/g, '\\$&');
|
||||
}
|
||||
return new RegExp(`^${re}$`);
|
||||
}
|
||||
|
||||
// ---- SQLite (bun:sqlite under Bun, node:sqlite on Node 22.5+) ----
|
||||
|
||||
export interface SqliteDb {
|
||||
all<T>(sql: string, ...params: unknown[]): T[];
|
||||
get<T>(sql: string, ...params: unknown[]): T | undefined;
|
||||
run(sql: string, ...params: unknown[]): void;
|
||||
exec(sql: string): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** Opens a SQLite file; throws when this runtime has no SQLite (Node before 22.5). */
|
||||
export async function openSqlite(path: string): Promise<SqliteDb> {
|
||||
const spec = isBun ? 'bun:sqlite' : 'node:sqlite';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mod: any = await import(spec);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db: any = isBun ? new mod.Database(path) : new mod.DatabaseSync(path);
|
||||
return {
|
||||
all: (sql, ...p) => db.prepare(sql).all(...p),
|
||||
get: (sql, ...p) => db.prepare(sql).get(...p) ?? undefined,
|
||||
run: (sql, ...p) => { db.prepare(sql).run(...p); },
|
||||
exec: sql => { db.exec(sql); },
|
||||
close: () => db.close(),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,16 +1,21 @@
|
|||
// Runs one storebound command as a child process: the shared core of `storebound mcp` and `storebound serve --stdio`.
|
||||
// Works from source (`bun cli/index.ts ...`) and from a `bun build --compile` binary (the desktop sidecar), where the
|
||||
// binary re-executes itself. Output is streamed line by line (progress) and the `--json` result line is picked out.
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { RESULT_MARK } from './log';
|
||||
import { toolPath } from './tools';
|
||||
import * as rt from './rt';
|
||||
|
||||
/** True inside a `bun build --compile` executable (the entry module lives in Bun's embedded file system). */
|
||||
export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(Bun.main);
|
||||
export const compiled = (): boolean => /^(\/\$bunfs\/|[A-Z]:[\\/]~BUN[\\/])/.test(rt.mainPath());
|
||||
|
||||
/** Command prefix that starts this storebound: the compiled binary itself, or bun + cli/index.ts. */
|
||||
export function selfCommand(): string[] {
|
||||
return compiled() ? [process.execPath] : [process.execPath, join(import.meta.dir, '..', 'index.ts')];
|
||||
if (compiled()) return [process.execPath];
|
||||
// from the sources (cli/lib/runner.ts): bun cli/index.ts; from the npm bundle (dist/storebound.mjs): node <bundle>
|
||||
const self = fileURLToPath(import.meta.url);
|
||||
return [process.execPath, /\.ts$/.test(self) ? join(dirname(self), '..', 'index.ts') : self];
|
||||
}
|
||||
|
||||
export interface RunOptions {
|
||||
|
|
@ -62,7 +67,7 @@ export async function pump(stream: ReadableStream<Uint8Array>, kind: 'stdout' |
|
|||
}
|
||||
|
||||
export function runCommand(argv: string[], o: RunOptions): Running {
|
||||
const p = Bun.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) });
|
||||
const p = rt.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) });
|
||||
let cancelled = false;
|
||||
const lines: string[] = [];
|
||||
let data: unknown;
|
||||
|
|
|
|||
|
|
@ -1,65 +1,4 @@
|
|||
// Every file under templates/, embedded as text so `storebound init` also works from the compiled desktop sidecar
|
||||
// (a `bun build --compile` binary has no templates folder next to it). tests/apps.test.ts checks this list against the
|
||||
// folder: add a line here when a template is added.
|
||||
import t0 from '../../templates/app-privacy.md' with { type: 'text' };
|
||||
import t1 from '../../templates/appstore.md' with { type: 'text' };
|
||||
import t2 from '../../templates/credentials.example.json' with { type: 'text' };
|
||||
import t3 from '../../templates/data-safety.csv' with { type: 'text' };
|
||||
import t4 from '../../templates/declarations.md' with { type: 'text' };
|
||||
import t5 from '../../templates/listing.md' with { type: 'text' };
|
||||
import t6 from '../../templates/privacy-policy.html' with { type: 'text' };
|
||||
import t7 from '../../templates/profiles/app/app-privacy.md' with { type: 'text' };
|
||||
import t8 from '../../templates/profiles/app/appstore.md' with { type: 'text' };
|
||||
import t9 from '../../templates/profiles/app/declarations.md' with { type: 'text' };
|
||||
import t10 from '../../templates/profiles/app/listing.md' with { type: 'text' };
|
||||
import t11 from '../../templates/profiles/app/privacy-policy.html' with { type: 'text' };
|
||||
import t12 from '../../templates/profiles/app/shots-copy.json' with { type: 'text' };
|
||||
import t13 from '../../templates/profiles/app/storebound.config.ts' with { type: 'text' };
|
||||
import t14 from '../../templates/profiles/subscription-app/app-privacy.md' with { type: 'text' };
|
||||
import t15 from '../../templates/profiles/subscription-app/appstore.md' with { type: 'text' };
|
||||
import t16 from '../../templates/profiles/subscription-app/declarations.md' with { type: 'text' };
|
||||
import t17 from '../../templates/profiles/subscription-app/listing.md' with { type: 'text' };
|
||||
import t18 from '../../templates/profiles/subscription-app/storebound.config.ts' with { type: 'text' };
|
||||
import t19 from '../../templates/shots-copy.json' with { type: 'text' };
|
||||
import t20 from '../../templates/storebound.config.ts' with { type: 'text' };
|
||||
import t21 from '../../templates/testers.csv' with { type: 'text' };
|
||||
import tWebAppstore from '../../templates/web/appstore.svg' with { type: 'text' };
|
||||
import tWebGoogleplay from '../../templates/web/googleplay.svg' with { type: 'text' };
|
||||
import tWebLanding from '../../templates/web/landing.html' with { type: 'text' };
|
||||
import tWfAppRelease from '../../templates/profiles/app/workflows/release.yml' with { type: 'text' };
|
||||
import tWfAppBeta from '../../templates/profiles/app/workflows/beta.yml' with { type: 'text' };
|
||||
import tWfRelease from '../../templates/workflows/release.yml' with { type: 'text' };
|
||||
import tWfBeta from '../../templates/workflows/beta.yml' with { type: 'text' };
|
||||
|
||||
/** Template text by its path under templates/ (forward slashes). */
|
||||
export const TEMPLATE_TEXT: Record<string, string> = {
|
||||
'app-privacy.md': t0 as unknown as string,
|
||||
'appstore.md': t1 as unknown as string,
|
||||
'credentials.example.json': t2 as unknown as string,
|
||||
'data-safety.csv': t3 as unknown as string,
|
||||
'declarations.md': t4 as unknown as string,
|
||||
'listing.md': t5 as unknown as string,
|
||||
'privacy-policy.html': t6 as unknown as string,
|
||||
'profiles/app/app-privacy.md': t7 as unknown as string,
|
||||
'profiles/app/appstore.md': t8 as unknown as string,
|
||||
'profiles/app/declarations.md': t9 as unknown as string,
|
||||
'profiles/app/listing.md': t10 as unknown as string,
|
||||
'profiles/app/privacy-policy.html': t11 as unknown as string,
|
||||
'profiles/app/shots-copy.json': t12 as unknown as string,
|
||||
'profiles/app/storebound.config.ts': t13 as unknown as string,
|
||||
'profiles/subscription-app/app-privacy.md': t14 as unknown as string,
|
||||
'profiles/subscription-app/appstore.md': t15 as unknown as string,
|
||||
'profiles/subscription-app/declarations.md': t16 as unknown as string,
|
||||
'profiles/subscription-app/listing.md': t17 as unknown as string,
|
||||
'profiles/subscription-app/storebound.config.ts': t18 as unknown as string,
|
||||
'shots-copy.json': t19 as unknown as string,
|
||||
'storebound.config.ts': t20 as unknown as string,
|
||||
'testers.csv': t21 as unknown as string,
|
||||
'web/appstore.svg': tWebAppstore as unknown as string,
|
||||
'web/googleplay.svg': tWebGoogleplay as unknown as string,
|
||||
'web/landing.html': tWebLanding as unknown as string,
|
||||
'profiles/app/workflows/release.yml': tWfAppRelease as unknown as string,
|
||||
'profiles/app/workflows/beta.yml': tWfAppBeta as unknown as string,
|
||||
'workflows/release.yml': tWfRelease as unknown as string,
|
||||
'workflows/beta.yml': tWfBeta as unknown as string,
|
||||
};
|
||||
// Every file under templates/, embedded as text so `storebound init` also works from the npm bundle and the compiled
|
||||
// desktop sidecar (neither has a templates folder next to its code). The table is generated into lib/embedded.ts by
|
||||
// tools/gen-embedded.ts (bun run ci checks it is fresh).
|
||||
export { TEMPLATE_TEXT } from './embedded';
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// Nothing is installed system-wide. Sources and licenses: docs/desktop.md.
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { delimiter, join } from 'node:path';
|
||||
import * as rt from './rt';
|
||||
|
||||
export type ToolId = 'ffmpeg' | 'ffprobe' | 'chrome' | 'xcode' | 'simctl' | 'adb' | 'git';
|
||||
|
||||
|
|
@ -47,7 +48,7 @@ export function toolPath(env: Record<string, string | undefined> = process.env,
|
|||
/** First line of `<cmd> <args>` (version banners), or null. */
|
||||
function firstLine(cmd: string[], env: Record<string, string>): string | null {
|
||||
try {
|
||||
const r = Bun.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 });
|
||||
const r = rt.spawnSync(cmd, { stdout: 'pipe', stderr: 'pipe', env, timeout: 5_000 });
|
||||
if (r.exitCode !== 0) return null;
|
||||
return (r.stdout.toString() || r.stderr.toString()).trim().split('\n')[0]?.trim() || null;
|
||||
} catch { return null; }
|
||||
|
|
@ -72,7 +73,7 @@ export function detectTools(env: Record<string, string | undefined> = process.en
|
|||
const PATH = toolPath(env);
|
||||
const spawnEnv: Record<string, string> = { ...Object.fromEntries(Object.entries(env).filter((e): e is [string, string] => e[1] !== undefined)), PATH };
|
||||
const appDirs = (env.STOREBOUND_TOOL_PATH ?? '').split(delimiter).filter(Boolean);
|
||||
const which = (n: string): string | null => Bun.which(n, { PATH });
|
||||
const which = (n: string): string | null => rt.which(n, { PATH });
|
||||
const src = (p: string | null): ToolStatus['source'] => (!p ? null : appDirs.some(d => p.startsWith(d)) ? 'app' : 'system');
|
||||
const bin = (id: ToolId, name: string, usedFor: string, install: ToolStatus['install'], versionArgs: string[]): ToolStatus => {
|
||||
const path = which(id);
|
||||
|
|
@ -206,7 +207,7 @@ export function androidSdkMissing(l: AndroidSdkLookup): string {
|
|||
/** Where playwright resolves from (the project, then the storebound folder), or null. */
|
||||
export function findPlaywright(bases: string[]): string | null {
|
||||
for (const base of bases) {
|
||||
try { return Bun.resolveSync('playwright', base); } catch { /* next */ }
|
||||
try { return rt.resolveFrom('playwright', base); } catch { /* next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { log, warn } from './log';
|
||||
import * as rt from './rt';
|
||||
|
||||
/** A write whose outcome is unknown: it may or may not have been applied (timeout, connection reset, 5xx answer to a
|
||||
* POST). The API clients throw this instead of retrying such a request. */
|
||||
|
|
@ -58,7 +59,7 @@ export function retrySafe(method: string, path: string): boolean {
|
|||
}
|
||||
|
||||
/** Waits between re-checks (STOREBOUND_UPSERT_WAIT_MS; the test preload sets 0). */
|
||||
export const upsertTiming = { waitMs: Number(process.env.STOREBOUND_UPSERT_WAIT_MS ?? 3000), tries: 4, sleep: (ms: number): Promise<void> => (ms > 0 ? Bun.sleep(ms) : Promise.resolve()) };
|
||||
export const upsertTiming = { waitMs: Number(process.env.STOREBOUND_UPSERT_WAIT_MS ?? 3000), tries: 4, sleep: (ms: number): Promise<void> => (ms > 0 ? rt.sleep(ms) : Promise.resolve()) };
|
||||
|
||||
export interface CreateOnce<T> {
|
||||
/** what is being created, for log lines and errors: `in-app purchase com.example.gems` */
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { dirname, join, relative, sep } from 'node:path';
|
|||
import type { ShipConfig } from './config';
|
||||
import { log, warn } from './log';
|
||||
import { pump } from './runner';
|
||||
import * as rt from './rt';
|
||||
|
||||
export type Store = 'apple' | 'play';
|
||||
|
||||
|
|
@ -138,7 +139,7 @@ export function lastSourceCommit(root: string, capDir = ''): number | null {
|
|||
if (capDir) for (const d of ['ios', 'android']) ex.add(`:(exclude)${capDir}/${d}/`);
|
||||
ex.add(':(exclude,glob)**/node_modules/**');
|
||||
try {
|
||||
const r = Bun.spawnSync(['git', 'log', '-1', '--format=%ct', '--', '.', ...ex], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
|
||||
const r = rt.spawnSync(['git', 'log', '-1', '--format=%ct', '--', '.', ...ex], { cwd: root, stdout: 'pipe', stderr: 'pipe' });
|
||||
if (r.exitCode !== 0) return null;
|
||||
const s = Number(r.stdout.toString().trim());
|
||||
return s > 0 ? s * 1000 : null;
|
||||
|
|
@ -179,7 +180,7 @@ export function prebuildsDone(env: Record<string, string | undefined> = process.
|
|||
/** Runs a prebuild command in root with sh -c, streaming its output; throws on a nonzero exit. */
|
||||
export async function runPrebuild(cmd: string, root: string, tag: string): Promise<void> {
|
||||
log(tag, `prebuild: $ ${cmd}`);
|
||||
const p = Bun.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
||||
const p = rt.spawn(['sh', '-c', cmd], { cwd: root, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: process.env });
|
||||
const tail: string[] = [];
|
||||
const each = (line: string, kind: 'stdout' | 'stderr'): void => {
|
||||
tail.push(line); if (tail.length > 20) tail.shift();
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { CONFIG_NAMES } from './config';
|
|||
import { PROFILE_RE } from './credentials';
|
||||
import type { StoreApp } from './desktop-protocol';
|
||||
import { type IdProject, NO_OVERRIDES, type Overrides, type Pair } from './identity';
|
||||
import * as rt from './rt';
|
||||
|
||||
export const WORKSPACE_NAMES = ['storebound.workspace.json', 'storebound.workspace.ts', 'storebound.workspace.js'];
|
||||
/** Colours a studio can carry: the desktop's set (workspace.rs STUDIO_COLORS). */
|
||||
|
|
@ -94,7 +95,7 @@ export async function loadWorkspace(from: string, explicit?: string): Promise<Lo
|
|||
let raw: unknown;
|
||||
if (file.endsWith('.json')) {
|
||||
try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new Error(`${file}: ${(e as Error).message}`); }
|
||||
} else raw = ((await import(file)) as { default?: unknown }).default;
|
||||
} else raw = ((await rt.importConfigModule(file)) as { default?: unknown }).default;
|
||||
const problems = checkWorkspace(raw);
|
||||
if (problems.length) throw new Error(`${file}:\n ${problems.join('\n ')}`);
|
||||
return { file, root: resolve(file, '..'), ws: raw as Workspace };
|
||||
|
|
@ -162,7 +163,7 @@ export const studiosOf = (ws: Workspace): Studio[] =>
|
|||
(ws.studios ?? []).map((s, i) => ({ id: studioId(s), name: s.name, color: s.color ?? STUDIO_COLORS[i % STUDIO_COLORS.length]! }));
|
||||
|
||||
const isGlob = (p: string): boolean => /[*?[{]/.test(p);
|
||||
const matchAny = (globs: string[], rel: string): boolean => globs.some(g => new Bun.Glob(g.replace(/\/+$/, '')).match(rel));
|
||||
const matchAny = (globs: string[], rel: string): boolean => globs.some(g => new rt.Glob(g.replace(/\/+$/, '')).match(rel));
|
||||
const hasConfig = (dir: string): boolean => CONFIG_NAMES.some(n => existsSync(join(dir, n)));
|
||||
|
||||
/**
|
||||
|
|
@ -182,7 +183,7 @@ export function resolveApps(l: LoadedWorkspace): { apps: AppFolder[]; warnings:
|
|||
const base = isAbsolute(p) ? '/' : root;
|
||||
const pattern = isAbsolute(p) ? p.slice(1) : p;
|
||||
let hits = 0;
|
||||
for (const m of new Bun.Glob(pattern.replace(/\/+$/, '')).scanSync({ cwd: base, onlyFiles: false, dot: false })) {
|
||||
for (const m of new rt.Glob(pattern.replace(/\/+$/, '')).scanSync({ cwd: base, onlyFiles: false, dot: false })) {
|
||||
if (/(^|\/)(node_modules|\.git)(\/|$)/.test(m)) continue;
|
||||
const dir = resolve(base, m);
|
||||
try { if (!statSync(dir).isDirectory()) continue; } catch { continue; }
|
||||
|
|
@ -249,7 +250,7 @@ export function selectApps<T extends { rel: string; name: string }>(apps: T[], n
|
|||
const picked = new Set<T>();
|
||||
for (const n of names) {
|
||||
const lc = n.toLowerCase();
|
||||
const hits = apps.filter(a => a.rel.toLowerCase() === lc || basename(a.rel).toLowerCase() === lc || a.name.toLowerCase() === lc || (isGlob(n) && new Bun.Glob(n).match(a.rel)));
|
||||
const hits = apps.filter(a => a.rel.toLowerCase() === lc || basename(a.rel).toLowerCase() === lc || a.name.toLowerCase() === lc || (isGlob(n) && new rt.Glob(n).match(a.rel)));
|
||||
if (!hits.length) unknown.push(n);
|
||||
hits.forEach(h => picked.add(h));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue