diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 27bf7ee..18d2e05 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -208,6 +208,15 @@ jobs: bun install --frozen-lockfile bun tools/desktop-version.ts bun tools/build-sidecars.ts --target bun-windows-x64 + - name: Node bundle on Windows (npx storebound) - stdin prompts read with node, clean exit + shell: pwsh + run: | + bun tools/build-node.ts + node --version + bun tools/node-smoke.ts --only prompt + if ($LASTEXITCODE -ne 0) { throw "node prompt smoke failed" } + "n" | node dist/storebound.mjs --version + if ($LASTEXITCODE -ne 0) { throw "node dist/storebound.mjs --version failed" } - name: Build NSIS installer (per user, WebView2 bootstrapper, path-free) shell: pwsh working-directory: apps/desktop diff --git a/CHANGELOG.md b/CHANGELOG.md index 819e691..ecf2279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,13 @@ ## 0.8.3 · 2026-10-14 -Docs: no private account names in the published package. +Docs: no private account names in the published package. `npx storebound` prompts work on Node again (the [Y/n] +prompt of `storebound desktop` crashed on Windows). + +- **`npx storebound` prompts work on Node.** `storebound desktop` (and every [y/N] or type-the-id prompt) crashed on + Node at the question with "console is not async iterable" (Windows also hit a libuv assertion at exit): prompts now + read stdin through `rt.readLine` (node:readline, closed after one line, EOF is "no"). A test keeps Bun-only stdin + iteration out of cli/, and the Windows release job runs the Node prompt smoke on the runner. ## 0.8.2 · 2026-10-14 diff --git a/cli/api.ts b/cli/api.ts index 6d67e36..e6cca83 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -18,6 +18,7 @@ import { APPLE_SPEC } from './api/apple.gen'; import { PLAY_APIS } from './api/play.gen'; import { WRAPPED } from './api/wrapped.gen'; import type { ParamValue } from './api/types'; +import * as rt from './lib/rt'; const STORES: Store[] = ['apple', 'play']; @@ -95,8 +96,7 @@ export function describeRequest(req: BuiltRequest): string { } async function readLine(): Promise { - for await (const line of console) return line.trim(); - return ''; + return ((await rt.readLine()) ?? '').trim(); } export async function run(args: Args): Promise { diff --git a/cli/desktop.ts b/cli/desktop.ts index 85c6842..11be003 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -128,8 +128,8 @@ function canWrite(dir: string): boolean { async function askYes(text: string): Promise { process.stdout.write(text); - for await (const line of console) return !/^n(o)?$/i.test(line.trim()); - return false; + const line = await rt.readLine(); + return line !== null && !/^n(o)?$/i.test(line.trim()); } const tilde = (p: string) => (p.startsWith(homedir()) ? '~' + p.slice(homedir().length) : p); diff --git a/cli/lib/ctx.ts b/cli/lib/ctx.ts index 05e0d1b..979a217 100644 --- a/cli/lib/ctx.ts +++ b/cli/lib/ctx.ts @@ -1,6 +1,7 @@ import type { Args } from './args'; import type { Language, Loaded } from './config'; import { playCodes } from './config'; +import * as rt from './rt'; export interface Ctx { loaded: Loaded; args: Args } @@ -42,8 +43,8 @@ export async function confirm(ctx: Ctx, question: string): Promise { return declined('not a terminal and no --yes (re-run with --yes to confirm)'); } process.stdout.write(`${question} [y/N] `); - for await (const line of console) return /^y(es)?$/i.test(line.trim()) || declined('the owner answered no'); - return declined('no answer (stdin closed)'); + const line = await rt.readLine(); + return line !== null && /^y(es)?$/i.test(line.trim()) || declined(line === null ? 'no answer (stdin closed)' : 'the owner answered no'); } /** Exit code of a command whose confirmation was declined or could not be asked. */ diff --git a/cli/lib/rt.ts b/cli/lib/rt.ts index 47ed585..dc7fd5a 100644 --- a/cli/lib/rt.ts +++ b/cli/lib/rt.ts @@ -8,6 +8,7 @@ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync, type StdioOptions } fro import { createHash } from 'node:crypto'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; import * as nodeModule from 'node:module'; import { constants } from 'node:os'; import { delimiter, dirname, isAbsolute, join, resolve } from 'node:path'; @@ -323,6 +324,34 @@ export const stdinStream = (): ReadableStream => webStream(process.s /** All of stdin as text (Bun.stdin.text()). */ export const stdinText = (): Promise => new Response(stdinStream()).text(); +/** Streams a readLine saw end: a new interface on them would never emit close. */ +const endedInputs = new WeakSet(); + +/** + * One line from stdin, without the line break; null at EOF (so a [Y/n] prompt never reads a closed stdin as Enter). For prompts on Node and Bun (`for await (const line of + * console)` is Bun-only: on Node it throws "console is not async iterable"). The interface is closed after the line + * and stdin is paused and unref'd, so no handle stays open (an open stdin handle tripped a libuv assertion on Windows + * at exit). + */ +export function readLine(input: NodeJS.ReadableStream & { ref?: () => void; unref?: () => void; pause?: () => void } = process.stdin): Promise { + if ((input as { readableEnded?: boolean }).readableEnded || endedInputs.has(input)) return Promise.resolve(null); + return new Promise(res => { + input.ref?.(); // an earlier readLine unref'd it + const rl = createInterface({ input, terminal: false, crlfDelay: Infinity }); + let done = false; + const finish = (line: string | null) => { + if (done) return; + done = true; + rl.close(); + input.pause?.(); + input.unref?.(); + res(line === null ? null : line.replace(/\r$/, '')); + }; + rl.once('line', finish); + rl.once('close', () => { if (!done) endedInputs.add(input); finish(null); }); + }); +} + // ---- a small HTTP server (Bun.serve) ---- export interface Server { readonly port: number; readonly hostname: string; stop(closeActive?: boolean): void } diff --git a/tests/node-compat.test.ts b/tests/node-compat.test.ts index 3474594..9018a27 100644 --- a/tests/node-compat.test.ts +++ b/tests/node-compat.test.ts @@ -29,6 +29,19 @@ describe('cli/ is runtime neutral', () => { expect(bad).toEqual([]); }); + test('no Bun-only runtime behaviour: console as a stdin iterator, prompt(), alert(), HTMLRewriter (use rt.readLine)', () => { + // `for await (const line of console)` works only on Bun; on Node (npx storebound) it throws + // "console is not async iterable" at the first prompt (owner's report on Windows, 0.8.2). + const bad: string[] = []; + for (const f of cliSources()) { + const rel = relative(ROOT, f); + readFileSync(f, 'utf8').split('\n').forEach((line, i) => { + if (/\bof\s+console\b|console\s*\[\s*Symbol\.asyncIterator|(^|[;{(=\s])(globalThis\.)?(prompt|alert)\s*\(\s*['"`]|\bnew\s+HTMLRewriter\b/.test(line.replace(/\/\/.*$/, ''))) 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; engines: Record; files: string[]; scripts: Record }; expect(pkg.bin.storebound).toBe('dist/storebound.mjs'); @@ -151,3 +164,15 @@ describe('rt: serve', () => { expect(rt.packageRoot()).toBe(ROOT); }); }); + +describe('rt: readLine', () => { + test('one line, CRLF trimmed, null at EOF, stream paused afterwards', async () => { + const { Readable } = await import('node:stream'); + const a = Readable.from(['n\r\nmore\n']); + expect(await rt.readLine(a)).toBe('n'); + expect(a.isPaused()).toBe(true); + expect(await rt.readLine(Readable.from(['\n']))).toBe(''); + expect(await rt.readLine(Readable.from([]))).toBe(null); + expect(await rt.readLine(Readable.from(['yes']))).toBe('yes'); + }); +}); diff --git a/tools/node-smoke.ts b/tools/node-smoke.ts index 9aeb6b4..265946a 100644 --- a/tools/node-smoke.ts +++ b/tools/node-smoke.ts @@ -1,11 +1,12 @@ #!/usr/bin/env bun // Smoke test of the npm bundle (dist/storebound.mjs) with the real `node` binary: the CLI must run on Node 20+. -// bun tools/node-smoke.ts [--bundle dist/storebound.mjs] [--node ] +// bun tools/node-smoke.ts [--bundle dist/storebound.mjs] [--node ] [--only prompt] +// --only prompt: just the stdin prompt steps (the Windows release job runs these on the runner). // Offline only: --version, help, check on examples/demo-game (same exit code and problems as under Bun), scan on the // teaser fixture, release plan --help, init + check in an empty folder (the TypeScript config the template writes), // and an MCP initialize handshake over stdio with tools/list and one tools/call (a child run through the bundle). // Every step has a timeout; on a failure the step's output is printed. -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { ROOT } from './build-sidecars'; @@ -45,6 +46,37 @@ const norm = (s: string) => s.replace(/^\d\d:\d\d:\d\d /gm, '').replace(/\/\S*\/ const nodeVersion = run([node, '--version']).out.trim(); console.log(`node-smoke: ${bundle.replace(ROOT + '/', '')} with node ${nodeVersion} (package engines: node ${pkg.engines?.node ?? '?'})`); +// ---- stdin prompts: `for await (const line of console)` is Bun-only and crashed `npx storebound desktop` at its +// [Y/n] prompt on Node ("console is not async iterable", then a libuv assertion on Windows). The bundle must not +// contain it, and rt.readLine (what every prompt uses) must read one piped line, see EOF, and exit cleanly. +step('prompt: the bundle has no Bun-only stdin iteration', () => { + const src = readFileSync(bundle, 'utf8'); + if (/\bof\s+console\b/.test(src)) throw new Error('dist bundle iterates `console` (Bun-only): use rt.readLine'); + if (!/readline/.test(src)) throw new Error('dist bundle has no node:readline (rt.readLine missing?)'); +}); +step('prompt: rt.readLine under node reads "n", EOF and CRLF, exits 0 without a crash', () => { + const tmp = mkdtempSync(join(tmpdir(), 'storebound-prompt-smoke-')); + try { + const entry = join(tmp, 'entry.ts'); + writeFileSync(entry, `import { readLine } from ${JSON.stringify(join(ROOT, 'cli/lib/rt.ts'))};\nprocess.stdout.write('Install? [Y/n] ');\nconst a = await readLine();\nconst b = await readLine();\nconsole.log(JSON.stringify([a, b]));\n`); + const built = Bun.spawnSync(['bun', 'build', entry, '--target', 'node', '--format', 'esm', '--outfile', join(tmp, 'p.mjs')], { stdout: 'pipe', stderr: 'pipe' }); + if (built.exitCode !== 0) throw new Error(`bun build: ${built.stderr.toString()}`); + const notes: string[] = []; + for (const [input, want] of [['n\n', '["n",null]'], ['', '[null,null]'], ['y\r\n', '["y",null]']] as const) { + const r = run([node, join(tmp, 'p.mjs')], tmp, input, 20_000); + const got = r.out.replace('Install? [Y/n] ', '').trim(); + if (r.code !== 0 || got !== want || /assert|not async iterable/i.test(r.err)) throw new Error(`stdin ${JSON.stringify(input)}: exit ${r.code}, printed ${JSON.stringify(got)} (want ${want})\n${r.err.slice(-1500)}`); + notes.push(`${JSON.stringify(input)} -> ${got}`); + } + return notes.join(', '); + } finally { rmSync(tmp, { recursive: true, force: true }); } +}); +if (arg('--only') === 'prompt') { + if (failed) { console.error(`node-smoke: ${failed} step(s) failed`); process.exit(1); } + console.log('node-smoke: prompt steps passed'); + process.exit(0); +} + step('--version', () => { const r = sb(['--version']); expectOk(r, '--version');