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
|
|
@ -23,17 +23,18 @@ import { existsSync, readlinkSync } from 'node:fs';
|
|||
import { hostname } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { compiled } from '../lib/runner';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function chromium(root: string): Promise<any> {
|
||||
for (const base of [root, join(import.meta.dir, '../..')]) {
|
||||
for (const base of [root, rt.packageRoot()]) {
|
||||
try {
|
||||
const p = Bun.resolveSync('playwright', base);
|
||||
const p = rt.resolveFrom('playwright', base);
|
||||
// only an installed copy (node_modules): outside a project Bun auto-installs the newest playwright into its global
|
||||
// cache (~/.bun/install/cache), whose browser build was never downloaded ("headless shell: not installed" on
|
||||
// Linux CI, where no Google Chrome hides it); storebound's own copy is next
|
||||
if (!isInstalledPackage(p)) continue;
|
||||
return (await import(p)).chromium;
|
||||
return (await rt.importPath(p)).chromium;
|
||||
} catch { /* next */ }
|
||||
}
|
||||
// only reached in the compiled sidecar: the bundled playwright-core (not a runtime dependency of the npm package)
|
||||
|
|
@ -68,7 +69,7 @@ export interface Candidate {
|
|||
}
|
||||
|
||||
/**
|
||||
* Switches for a Chromium storebound starts itself (Bun.spawn, not Playwright's launch, which already passes
|
||||
* Switches for a Chromium storebound starts itself (rt.spawn, not Playwright's launch, which already passes
|
||||
* --no-sandbox unless chromiumSandbox is set): Chromium refuses to run as root on Linux without --no-sandbox
|
||||
* ("Running as root without --no-sandbox is not supported", crbug.com/638180), and inside a Docker / Podman container
|
||||
* its user-namespace sandbox is usually blocked by seccomp, so CI containers (the Forgejo runner runs jobs as root)
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a
|
|||
import { type CopyFile, copyText, type FrameDims, frameDims } from './lib';
|
||||
import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web';
|
||||
import { imageInfo } from './spec';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
type Device = 'phone' | 'tablet';
|
||||
|
||||
|
|
@ -56,7 +57,7 @@ export function adbPath(): string {
|
|||
|
||||
/** Throws the install hint when a tool is neither a path that exists nor on PATH. */
|
||||
export function requireTool(cmd: string, name = cmd): void {
|
||||
if (existsSync(cmd) || Bun.which(cmd)) return;
|
||||
if (existsSync(cmd) || rt.which(cmd)) return;
|
||||
throw new Error(TOOL_HINTS[name] ?? `${name} not found on PATH`);
|
||||
}
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (source === 'ios-sim') {
|
||||
const ios = need(cap.ios, 'shots.capture.ios');
|
||||
if (!dry) { requireTool('xcrun'); requireTool('ffmpeg'); }
|
||||
const list = dry && !Bun.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
|
||||
const list = dry && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
|
||||
for (const device of devices) {
|
||||
const want = simTarget(ios, device);
|
||||
const todo = shotsFor(cap, device, only);
|
||||
|
|
@ -171,16 +172,16 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (bootedByUs && warmup > 0) {
|
||||
log('shots', `warming up ${ios.bundleId} (first launch after boot, ${warmup} ms; shots.capture.ios.warmupMs)`);
|
||||
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang: langs[0]!, locale: localeOf(config, langs[0]!, 'ios-sim'), shot: '', baseArgs: ios.args }));
|
||||
if (!dry) await Bun.sleep(warmup);
|
||||
if (!dry) await rt.sleep(warmup);
|
||||
}
|
||||
for (const lang of langs) for (const [name, s] of todo) {
|
||||
try {
|
||||
const locale = localeOf(config, lang, 'ios-sim');
|
||||
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang, locale, shot: name, baseArgs: ios.args, args: s.args }));
|
||||
if (s.url) { if (!dry) await Bun.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(s.url, { lang, locale, shot: name }))); }
|
||||
if (s.url) { if (!dry) await rt.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(s.url, { lang, locale, shot: name }))); }
|
||||
const out = target(lang, device, name);
|
||||
if (dry) { progress(`${lang}/${device}/${name}.png (${sim.name}, would wait ${s.delayMs ?? 2500} ms)`); continue; }
|
||||
await Bun.sleep(s.delayMs ?? 2500);
|
||||
await rt.sleep(s.delayMs ?? 2500);
|
||||
const tmp = join(tmpDir, `sim-${name}.png`);
|
||||
sh('xcrun', simScreenshotArgs(sim.udid, tmp));
|
||||
storeFrame(tmp, out, cap.cropTop, device, dims[device]);
|
||||
|
|
@ -221,7 +222,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
exec(adb, adbStartArgs(and.package, { lang, locale, shot: name, url: s.url, activity: and.activity, extras: s.extras }, serial));
|
||||
const out = target(lang, device, name);
|
||||
if (dry) { progress(`${lang}/${device}/${name}.png (would wait ${s.delayMs ?? 2500} ms, screencap)`); continue; }
|
||||
await Bun.sleep(s.delayMs ?? 2500);
|
||||
await rt.sleep(s.delayMs ?? 2500);
|
||||
const tmp = join(tmpDir, `adb-${name}.png`);
|
||||
writeFileSync(tmp, sh(adb, adbScreencapArgs(serial)).out);
|
||||
storeFrame(tmp, out, cap.cropTop, device, dims[device]);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { ADB_REMOTE_CLIP, adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, ad
|
|||
simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, trimArgs, validateRecord, concatArgs, concatList, type ScreencastFrame } from './devices';
|
||||
import { frameDims } from './lib';
|
||||
import { CLIP_RE, clipFiles } from './video';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
/** Clips to film, in config order (with their index for the file name), limited by --only. */
|
||||
export function clipsToRecord(r: VideoRecord, only: string[] = []): [number, string, ClipSpec][] {
|
||||
|
|
@ -137,7 +138,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (source === 'ios-sim') {
|
||||
const ios = need(cap?.ios, 'shots.capture.ios');
|
||||
if (!dry) requireTool('xcrun');
|
||||
const list = dry && !Bun.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
|
||||
const list = dry && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString();
|
||||
const want = simTarget(ios, device);
|
||||
if (!want) throw new Error(`no shots.capture.ios.${device === 'tablet' ? 'devices.tablet' : 'device'}`);
|
||||
const sim = pickSimulator(list, want) ?? (dry ? { udid: want, name: want, state: 'Shutdown' } : null);
|
||||
|
|
@ -151,19 +152,19 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (bootedByUs && warmup > 0) {
|
||||
log('shots', `warming up ${ios.bundleId} (${warmup} ms)`);
|
||||
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang: langs[0]!, locale: localeOf(config, langs[0]!, 'ios-sim'), shot: '', baseArgs: ios.args }));
|
||||
if (!dry) await Bun.sleep(warmup);
|
||||
if (!dry) await rt.sleep(warmup);
|
||||
}
|
||||
for (const lang of langs) for (const [idx, name, cl] of todo) {
|
||||
try {
|
||||
const locale = localeOf(config, lang, 'ios-sim');
|
||||
exec('xcrun', simLaunchArgs(sim.udid, ios.bundleId, { lang, locale, shot: name, baseArgs: ios.args, args: cl.args }));
|
||||
if (cl.url) { if (!dry) await Bun.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(cl.url, { lang, locale, shot: name }))); }
|
||||
if (cl.url) { if (!dry) await rt.sleep(1500); exec('xcrun', simOpenUrlArgs(sim.udid, fill(cl.url, { lang, locale, shot: name }))); }
|
||||
const out = outFor(lang, idx, name);
|
||||
const raw = join(tmpDir, `sim-${name}.mp4`);
|
||||
if (dry) { log('shots', `would run: xcrun ${simRecordArgs(sim.udid, raw).join(' ')} (${cl.seconds} s, then SIGINT)`); progress(`${lang}/${clipName(idx, name)}`); continue; }
|
||||
await Bun.sleep(cl.delayMs ?? 2500);
|
||||
const p = Bun.spawn(['xcrun', ...simRecordArgs(sim.udid, raw)], { stdout: 'ignore', stderr: 'pipe' });
|
||||
await Bun.sleep(cl.seconds * 1000 + 700);
|
||||
await rt.sleep(cl.delayMs ?? 2500);
|
||||
const p = rt.spawn(['xcrun', ...simRecordArgs(sim.udid, raw)], { stdout: 'ignore', stderr: 'pipe' });
|
||||
await rt.sleep(cl.seconds * 1000 + 700);
|
||||
p.kill('SIGINT');
|
||||
await p.exited;
|
||||
if (!existsSync(raw)) throw new Error(`simctl recordVideo wrote nothing: ${(await new Response(p.stderr).text()).trim().slice(0, 300)}`);
|
||||
|
|
@ -193,7 +194,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
try {
|
||||
exec(adb, adbForceStopArgs(and.package, serial));
|
||||
exec(adb, adbStartArgs(and.package, { lang, locale, shot: name, url: cl.url, activity: and.activity, extras: cl.extras }, serial));
|
||||
if (!dry) await Bun.sleep(cl.delayMs ?? 2500);
|
||||
if (!dry) await rt.sleep(cl.delayMs ?? 2500);
|
||||
exec(adb, adbRecordArgs(ADB_REMOTE_CLIP, cl.seconds, serial), { timeout: (cl.seconds + 30) * 1000 });
|
||||
const raw = join(tmpDir, `adb-${name}.mp4`);
|
||||
exec(adb, adbPullArgs(ADB_REMOTE_CLIP, raw, serial), { timeout: 120_000 });
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
// paths) are served from where they are at /fonts/local/... (local-fonts.ts); a file changed since `shots fonts` fails.
|
||||
// Style: shotsStyle() = shots.style, else app for the app / subscription-app profiles (template-app.html,
|
||||
// feature-app.html: calm gradient, device frame, headline + subline), else game (template.html, feature.html).
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { SHOTS_PAGES } from '../lib/embedded';
|
||||
import { fontCodepoints } from './cmap';
|
||||
import { readManifest } from './fonts';
|
||||
import { type FontManifest, fontUsage, measureFonts, roleTexts, uncoveredChars } from './glyphs';
|
||||
|
|
@ -27,6 +28,7 @@ import { shotsStyle } from '../lib/config';
|
|||
import { type CopyFile, SIZES, copyText, defaultSizes, frameDims, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib';
|
||||
import { faceFilePath, fontsRoute, resolveShotFonts, staleLocalFonts } from './local-fonts';
|
||||
import { type RenderReport, mergeReport, readReport, resolvedTablets } from './report';
|
||||
import * as rt from '../lib/rt';
|
||||
|
||||
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
||||
webp: 'image/webp', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', css: 'text/css', svg: 'image/svg+xml' };
|
||||
|
|
@ -113,9 +115,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const lang of langs) for (const f of manifest.langs[lang]?.fallbacks ?? [])
|
||||
(manifest.faces[f.face]?.chosen ? log : warn)('shots', `${lang} ${f.role}: ${[...f.chars].join(' ')} from ${f.family}${manifest.faces[f.face]?.chosen ? '' : ' (automatic fallback: shots check will fail)'}`);
|
||||
}
|
||||
const tpl = import.meta.dir;
|
||||
|
||||
const server = Bun.serve({
|
||||
const server = await rt.serve({
|
||||
port: a.num('port', 8792), hostname: '127.0.0.1',
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
|
|
@ -138,11 +138,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
|
||||
else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? '';
|
||||
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
|
||||
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) file = join(tpl, p.slice(1));
|
||||
else if (/^\/(template|feature)(-app)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } });
|
||||
if (!file || file.includes('..')) return new Response('no', { status: 404 });
|
||||
const f = Bun.file(file);
|
||||
if (!(await f.exists())) return new Response('missing ' + p, { status: 404 });
|
||||
return new Response(f, { headers: { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' } });
|
||||
return rt.fileResponse(file, { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' }) ?? new Response('missing ' + p, { status: 404 });
|
||||
},
|
||||
});
|
||||
const base = `http://127.0.0.1:${server.port}`;
|
||||
|
|
@ -182,7 +180,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
}
|
||||
if (overflow.length) warn('shots', `${relative(root, out)}: ${overflow.join('; ')}`);
|
||||
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, width: w, height: h } });
|
||||
const ff = Bun.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
|
||||
const ff = rt.spawn(['ffmpeg', '-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out], { stderr: 'pipe' });
|
||||
const code = await ff.exited;
|
||||
rmSync(tmp, { force: true });
|
||||
if (code !== 0) throw new Error('ffmpeg: ' + await new Response(ff.stderr).text());
|
||||
|
|
@ -232,7 +230,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const lay = sheetLayout(rows);
|
||||
const imgs = lay.tiles.map(t => `<img src="/out/${relative(outDir, t.file)}" style="position:absolute;left:${t.x}px;top:${t.y}px;width:${t.w}px;height:${t.h}px;border-radius:14px">`).join('');
|
||||
const sheet = join(dir, '.sheet.html');
|
||||
await Bun.write(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${lay.w}px;height:${lay.h}px;position:relative">${imgs}
|
||||
writeFileSync(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${lay.w}px;height:${lay.h}px;position:relative">${imgs}
|
||||
<script>Promise.all([...document.images].map(i=>i.decode())).then(()=>document.body.dataset.ready='1')</script></body>`);
|
||||
await withPage(p => shoot(p, `${base}/out/${relative(outDir, sheet)}`, lay.w, lay.h, join(dir, 'sheet.png')));
|
||||
rmSync(sheet, { force: true });
|
||||
|
|
@ -241,7 +239,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
for (const p of pages) await p?.page.context().close().catch(() => {});
|
||||
await closeBrowser(browser);
|
||||
server.stop(true);
|
||||
if (Object.keys(report).length) await Bun.write(join(outDir, 'report.json'), JSON.stringify(mergeReport(readReport(outDir), report), null, 1) + '\n');
|
||||
if (Object.keys(report).length) writeFileSync(join(outDir, 'report.json'), JSON.stringify(mergeReport(readReport(outDir), report), null, 1) + '\n');
|
||||
}
|
||||
const flagged = Object.values(report).filter(r => r.overflow.length).length;
|
||||
if (flagged) warn('shots', `${flagged} images with text overflow flags: storebound shots check / shots gallery`);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
} });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue