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:
CRTkafa 2026-10-13 04:03:09 +08:00
parent 614c32bcba
commit 36a8bf9625
73 changed files with 1195 additions and 251 deletions

View file

@ -21,6 +21,8 @@ import { captionsFor, type CopyFile, copyText, palette, renderDir, scriptFont }
import { FONT_TYPES, fontsRoute, resolveShotFonts } from './local-fonts';
import { ffprobe } from './report';
import { checkVideo, PREVIEW_RULES, VIDEO_TARGETS } from './spec';
import { SHOTS_PAGES } from '../lib/embedded';
import * as rt from '../lib/rt';
export const CLIP_RE = /\.(mp4|mov|m4v|webm)$/i;
@ -145,13 +147,13 @@ export async function run(ctx: Ctx): Promise<void> {
if (!dry) mkdirSync(tmp, { recursive: true });
// caption PNGs come from a local page (fonts from `shots fonts`), like the screenshots
let server: ReturnType<typeof Bun.serve> | null = null, browser: { close(): Promise<void>; newContext(o: unknown): Promise<{ newPage(): Promise<unknown> }> } | null = null;
let server: rt.Server | null = null, browser: { close(): Promise<void>; newContext(o: unknown): Promise<{ newPage(): Promise<unknown> }> } | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let page: any = null;
if (captions) {
const fontsDir = join(root, '.storebound', 'fonts');
if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .storebound/fonts/fonts.css: run `storebound shots fonts` first (captions fall back to system fonts)');
server = Bun.serve({ port: a.num('port', 8793), hostname: '127.0.0.1', async fetch(req) {
server = await rt.serve({ port: a.num('port', 8793), hostname: '127.0.0.1', async fetch(req) {
const url = new URL(req.url);
if (url.pathname === '/caption.json') {
const lang = url.searchParams.get('lang') ?? '';
@ -162,11 +164,11 @@ export async function run(ctx: Ctx): Promise<void> {
return Response.json({ text: texts[Number(url.searchParams.get('i'))] ?? '', ink: style === 'app' ? '#111827' : pal.ink, rtl: rtl.get(lang),
fonts: { title: resolveShotFonts(shots, style, ctx.loaded.resolve, root).title.family, script: script ? `Store ${script.slug}` : null, titleStack: readManifest(fontsDir)?.langs[lang]?.title } });
}
if (url.pathname === '/caption.html') return new Response(Bun.file(join(import.meta.dir, 'caption.html')), { headers: { 'content-type': 'text/html; charset=utf-8' } });
if (url.pathname === '/caption.html') return new Response(SHOTS_PAGES['caption.html']!, { headers: { 'content-type': 'text/html; charset=utf-8' } });
if (url.pathname.startsWith('/fonts/')) {
const file = fontsRoute(decodeURIComponent(url.pathname.slice(7)), fontsDir, root, readManifest(fontsDir));
const f = file ? Bun.file(file) : null;
if (f && await f.exists()) return new Response(f, { headers: { 'content-type': FONT_TYPES[file!.split('.').pop()!.toLowerCase()] ?? 'font/woff2' } });
const f = file ? rt.fileResponse(file, { 'content-type': FONT_TYPES[file.split('.').pop()!.toLowerCase()] ?? 'font/woff2' }) : null;
if (f) return f;
}
return new Response('no', { status: 404 });
} });