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
153
tests/node-compat.test.ts
Normal file
153
tests/node-compat.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// The CLI runs on Node 20+ (npm bundle) and Bun: cli/ reaches the runtime only through cli/lib/rt.ts, and rt behaves
|
||||
// like the Bun APIs it replaced (spawn defaults and exit codes, which, Glob, type stripping of a config).
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, relative } from 'node:path';
|
||||
import * as rt from '../cli/lib/rt';
|
||||
|
||||
const ROOT = join(import.meta.dir, '..');
|
||||
|
||||
function cliSources(dir = join(ROOT, 'cli')): string[] {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap(e => {
|
||||
const p = join(dir, e.name);
|
||||
if (e.isDirectory()) return cliSources(p);
|
||||
return e.name.endsWith('.ts') && !e.name.endsWith('.test.ts') ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('cli/ is runtime neutral', () => {
|
||||
test('no Bun global, bun: module, import.meta.dir or text import outside cli/lib/rt.ts', () => {
|
||||
const bad: string[] = [];
|
||||
for (const f of cliSources()) {
|
||||
const rel = relative(ROOT, f);
|
||||
if (rel === join('cli', 'lib', 'rt.ts')) continue;
|
||||
readFileSync(f, 'utf8').split('\n').forEach((line, i) => {
|
||||
if (/\bBun\.[A-Za-z]|['"]bun:[a-z]|import\.meta\.(dir|dirname|path|file|filename|main)\b|with \{ type: 'text' \}/.test(line)) bad.push(`${rel}:${i + 1}: ${line.trim().slice(0, 120)}`);
|
||||
});
|
||||
}
|
||||
expect(bad).toEqual([]);
|
||||
});
|
||||
|
||||
test('package.json: bin is the Node bundle, engines name Node 20, npm pack builds it', () => {
|
||||
const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')) as { bin: Record<string, string>; engines: Record<string, string>; files: string[]; scripts: Record<string, string> };
|
||||
expect(pkg.bin.storebound).toBe('dist/storebound.mjs');
|
||||
expect(pkg.engines.node).toBe('>=20');
|
||||
expect(pkg.files).toContain('dist/storebound.mjs');
|
||||
expect(pkg.scripts.prepack).toContain('tools/build-node.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rt: processes', () => {
|
||||
test('spawn: Bun defaults, exit code, 128 + signal, streams', async () => {
|
||||
const p = rt.spawn(['sh', '-c', 'echo out; echo err >&2; exit 3'], { stderr: 'pipe' });
|
||||
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]);
|
||||
expect([out, err, code, p.exitCode]).toEqual(['out\n', 'err\n', 3, 3]);
|
||||
const s = rt.spawn(['sleep', '10']);
|
||||
s.kill();
|
||||
expect(await s.exited).toBe(143);
|
||||
expect(s.signalCode).toBe('SIGTERM');
|
||||
const i = rt.spawn(['sleep', '10']);
|
||||
i.kill('SIGINT');
|
||||
expect(await i.exited).toBe(130);
|
||||
});
|
||||
|
||||
test('a missing executable throws right away, like Bun', () => {
|
||||
expect(() => rt.spawn(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH: "storebound-no-such-tool"');
|
||||
expect(() => rt.spawnSync(['storebound-no-such-tool'])).toThrow('Executable not found in $PATH');
|
||||
});
|
||||
|
||||
test('spawnSync: stdin bytes, env, exit code, timeout without a throw', () => {
|
||||
const r = rt.spawnSync(['sh', '-c', 'tr a-z A-Z; echo "$X" >&2; exit 2'], { stdin: new TextEncoder().encode('hi'), env: { ...process.env, X: 'x1' }, stdout: 'pipe', stderr: 'pipe' });
|
||||
expect([r.stdout.toString(), r.stderr.toString(), r.exitCode, r.success]).toEqual(['HI', 'x1\n', 2, false]);
|
||||
const t = rt.spawnSync(['sleep', '5'], { timeout: 100 });
|
||||
expect([t.exitCode, t.signalCode, t.success]).toEqual([null, 'SIGTERM', false]);
|
||||
});
|
||||
|
||||
test('which: PATH lookup with an explicit PATH, executables only', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-which-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'tool-x'), '#!/bin/sh\n');
|
||||
chmodSync(join(dir, 'tool-x'), 0o755);
|
||||
writeFileSync(join(dir, 'plain-y'), '');
|
||||
expect(rt.which('tool-x', { PATH: dir })).toBe(join(dir, 'tool-x'));
|
||||
expect(rt.which('plain-y', { PATH: dir })).toBeNull();
|
||||
expect(rt.which('tool-x', { PATH: '' })).toBeNull();
|
||||
expect(rt.which('sh')).toBe(Bun.which('sh'));
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('hasher and gunzip match Bun', () => {
|
||||
expect(rt.hasher('sha256').update('a').update('b').digest('hex')).toBe(new Bun.CryptoHasher('sha256').update('ab').digest('hex'));
|
||||
expect(new TextDecoder().decode(rt.gunzipSync(Bun.gzipSync(new TextEncoder().encode('zip'))))).toBe('zip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rt: Glob like Bun.Glob', () => {
|
||||
const cases: [string, string][] = [
|
||||
['apps/*', 'apps/a'], ['apps/*', 'apps/a/b'], ['apps/**', 'apps/a/b'], ['**/game', 'game'], ['**/game', 'x/y/game'],
|
||||
['studio-*/app?', 'studio-1/app2'], ['studio-*/app?', 'studio-1/app22'], ['{a,b}/c', 'b/c'], ['{a,b}/c', 'd/c'],
|
||||
['[ab]x', 'bx'], ['[!ab]x', 'cx'], ['[!ab]x', 'ax'], ['*.ts', 'a.ts'], ['*.ts', 'dir/a.ts'], ['a/**/z', 'a/z'], ['a/**/z', 'a/b/c/z'],
|
||||
];
|
||||
for (const [g, p] of cases) test(`${g} vs ${p}`, () => expect(new rt.Glob(g).match(p)).toBe(new Bun.Glob(g).match(p)));
|
||||
|
||||
test('scanSync finds the same folders, no dot entries', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-glob-'));
|
||||
try {
|
||||
for (const d of ['apps/one', 'apps/two/deep', 'apps/.hidden', 'other/three']) mkdirSync(join(dir, d), { recursive: true });
|
||||
writeFileSync(join(dir, 'apps/file.txt'), '');
|
||||
for (const g of ['apps/*', 'apps/**', '*/*', 'apps/t*']) {
|
||||
const mine = [...new rt.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort();
|
||||
const bun = [...new Bun.Glob(g).scanSync({ cwd: dir, onlyFiles: false, dot: false })].sort();
|
||||
expect(mine, g).toEqual(bun);
|
||||
}
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('rt: configs on Node without type stripping', () => {
|
||||
test('stripTypes turns every shipped config into JavaScript with the same value', async () => {
|
||||
const files = ['templates/storebound.config.ts', ...readdirSync(join(ROOT, 'templates/profiles')).map(p => `templates/profiles/${p}/storebound.config.ts`), 'examples/demo-game/storebound.config.ts', 'examples/demo-app/storebound.config.ts']
|
||||
.filter(f => { try { readFileSync(join(ROOT, f)); return true; } catch { return false; } });
|
||||
expect(files.length).toBeGreaterThan(3);
|
||||
for (const f of files) {
|
||||
const js = rt.stripTypes(readFileSync(join(ROOT, f), 'utf8'));
|
||||
const viaJs = (await import(`data:text/javascript;base64,${Buffer.from(js).toString('base64')}`)).default;
|
||||
const viaTs = (await import(join(ROOT, f))).default;
|
||||
expect(JSON.stringify(viaJs), f).toBe(JSON.stringify(viaTs));
|
||||
}
|
||||
});
|
||||
|
||||
test('stripTypes keeps strings and comments that look like types', () => {
|
||||
const js = rt.stripTypes("import type { X } from 'x';\n// keep: a as B\nconst c: X = { s: 'a satisfies B', n: 1 as number } satisfies X;\nexport default c as X;\n");
|
||||
expect(js).toContain("// keep: a as B");
|
||||
expect(js).toContain("'a satisfies B'");
|
||||
expect(js).not.toContain('import type');
|
||||
expect(js).toContain('const c = {');
|
||||
expect(js).toContain('n: 1 }');
|
||||
expect(js).toContain('export default c;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rt: serve', () => {
|
||||
test('fetch handler, file responses, 404', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-serve-'));
|
||||
writeFileSync(join(dir, 'a.txt'), 'file body');
|
||||
const s = await rt.serve({ port: 0, hostname: '127.0.0.1', fetch(req) {
|
||||
const u = new URL(req.url);
|
||||
if (u.pathname === '/json') return Response.json({ q: u.searchParams.get('q') });
|
||||
return rt.fileResponse(join(dir, u.pathname.slice(1)), { 'content-type': 'text/plain' }) ?? new Response('no', { status: 404 });
|
||||
} });
|
||||
try {
|
||||
const base = `http://127.0.0.1:${s.port}`;
|
||||
expect(await (await fetch(`${base}/json?q=1`)).json()).toEqual({ q: '1' });
|
||||
const f = await fetch(`${base}/a.txt`);
|
||||
expect([f.status, f.headers.get('content-type'), await f.text()]).toEqual([200, 'text/plain', 'file body']);
|
||||
expect((await fetch(`${base}/missing`)).status).toBe(404);
|
||||
} finally { s.stop(true); rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test('packageRoot is the storebound folder', () => {
|
||||
expect(rt.packageRoot()).toBe(ROOT);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue