From c3fed8efa2445309cb3497bd03da565cc93f5c63 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 07:54:56 +0800 Subject: [PATCH 001/397] storebound 0.8.3: version in package.json and every copy, CHANGELOG 0.8.3 section (docs: no private account names in the published package) --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- CHANGELOG.md | 4 ++++ apps/desktop/package.json | 2 +- apps/desktop/src-tauri/Cargo.lock | 2 +- apps/desktop/src-tauri/Cargo.toml | 2 +- apps/desktop/src-tauri/tauri.conf.json | 2 +- gemini-extension.json | 2 +- package.json | 2 +- 10 files changed, 13 insertions(+), 9 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7a31c56..05a8463 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ "name": "storebound", "source": ".", "description": "Take a mobile app or game from nothing to Submit for review on both stores: skills for every store step and questionnaire plus the storebound CLI and MCP server.", - "version": "0.8.2", + "version": "0.8.3", "category": "development", "tags": [ "mobile", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index d6f8582..c022a4a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.2", + "version": "0.8.3", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index c7b16e8..6ea030b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.2", + "version": "0.8.3", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "author": { "name": "CRTkafa" diff --git a/CHANGELOG.md b/CHANGELOG.md index 510f490..819e691 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.8.3 · 2026-10-14 + +Docs: no private account names in the published package. + ## 0.8.2 · 2026-10-14 The desktop app edits everything the consoles do (listings, app info, catalog and prices, TestFlight, store art), with diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 7b499e5..3792f39 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@storebound/desktop", - "version": "0.8.2", + "version": "0.8.3", "private": true, "license": "MIT", "type": "module", diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index 5e1346a..6488483 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -4010,7 +4010,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "storebound-desktop" -version = "0.8.2" +version = "0.8.3" dependencies = [ "base64 0.22.1", "getrandom 0.3.4", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index e55c9a2..7d35e6e 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "storebound-desktop" -version = "0.8.2" +version = "0.8.3" description = "storebound desktop: every game's releases on both stores in one window" license = "MIT" edition = "2021" diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index a3b5d66..3d4f250 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "storebound", "mainBinaryName": "storebound-desktop", - "version": "0.8.2", + "version": "0.8.3", "identifier": "dev.storebound.desktop", "build": { "frontendDist": "../dist", diff --git a/gemini-extension.json b/gemini-extension.json index aac7070..9999e83 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.2", + "version": "0.8.3", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every questionnaire (Data safety, App access and demo accounts, account deletion, IARC, target audience, ads, App Privacy, age rating, export compliance), privacy, icons, screenshots and previews, validate, TestFlight, release management, and after launch: crash vitals, review replies, price sheets, store analytics, Product Page Optimization. Agent Skills plus a Bun CLI and MCP server (storebound); you press Submit. CRTkafa's own toolchain, open-sourced.", "contextFileName": "GEMINI.md", "mcpServers": { diff --git a/package.json b/package.json index aa70844..f6250eb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "storebound", - "version": "0.8.2", + "version": "0.8.3", "description": "Take a mobile app or game from nothing to Submit for review on Google Play and the App Store: app records, listings in every language, in-app products and subscriptions with regional prices, every store questionnaire (incl. account deletion and App Review demo accounts), privacy, icons, screenshots and previews, TestFlight. After launch: crash vitals, review replies, price sheets, analytics and Product Page Optimization. One config, a CLI (Node 20+ or Bun), an MCP server and Agent Skills for your AI coding agent.", "license": "MIT", "author": "CRTkafa", From 3a27c78f4938965c4924540d17f828817bb3141d Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 08:07:40 +0800 Subject: [PATCH 002/397] storebound 0.8.3: prompts read stdin with rt.readLine (node:readline) instead of Bun-only `for await (const line of console)`, which crashed npx storebound desktop on Node/Windows; node-compat test bans Bun-only stdin iteration; Node prompt smoke, also on the Windows release runner --- .forgejo/workflows/release.yml | 9 +++++++++ CHANGELOG.md | 8 +++++++- cli/api.ts | 4 ++-- cli/desktop.ts | 4 ++-- cli/lib/ctx.ts | 5 +++-- cli/lib/rt.ts | 29 +++++++++++++++++++++++++++ tests/node-compat.test.ts | 25 +++++++++++++++++++++++ tools/node-smoke.ts | 36 ++++++++++++++++++++++++++++++++-- 8 files changed, 111 insertions(+), 9 deletions(-) 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'); From ae681e3ecd335f22d872d2d9899bdea1aad725ea Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 08:21:35 +0800 Subject: [PATCH 003/397] release: privacy scan of signed macOS artifacts leaves out Apple code signatures only (copies with codesign --remove-signature on every Mach-O, the DMG's koly signature blob zeroed); any other hit still fails; tests and docs --- CHANGELOG.md | 3 + docs/desktop-release.md | 7 ++ tests/mac-signature-scan.test.ts | 79 +++++++++++++++++++++ tools/desktop-release.ts | 24 ++++++- tools/mac-signature-scan.ts | 113 +++++++++++++++++++++++++++++++ tools/privacy-scan.ts | 5 +- 6 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 tests/mac-signature-scan.test.ts create mode 100644 tools/mac-signature-scan.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ecf2279..efa261f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ prompt of `storebound desktop` crashed on Windows). 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. +- **Release privacy scan of signed macOS builds leaves out Apple code signatures only.** The Developer ID certificate + in a signature names the developer; the .app, DMG and updater bundle are scanned as copies without signatures, and + any hit elsewhere still fails the release. ## 0.8.2 · 2026-10-14 diff --git a/docs/desktop-release.md b/docs/desktop-release.md index c0f42de..713a20b 100644 --- a/docs/desktop-release.md +++ b/docs/desktop-release.md @@ -110,6 +110,13 @@ it, never copy it to a runner. The Forgejo workflow builds unsigned installers, or 7-Zip) and NSIS (7-Zip). - **Debug files:** .dSYM, .pdb and source maps fail the scan. - **Exit codes:** 1 when it finds something, 2 when it cannot open an archive. A term is never printed in full. +- **Signed macOS artifacts are scanned without their code signatures** (`tools/mac-signature-scan.ts`, used by + `desktop-release.ts` for the .app, the DMG and the updater bundle). A Developer ID signature embeds the signing + certificate, and its subject is the developer's legal name, which may be in the terms file. That name may appear + inside Apple code signatures and nowhere else, so the scan reads copies: `codesign --remove-signature` on every + Mach-O file of the copied app (also the app copied out of the mounted DMG and the one unpacked from the updater + bundle), and the DMG's own signature blob (the range the UDIF `koly` trailer names) zeroed in memory. Every other + byte is scanned as it ships; a hit outside a signature still fails the release (tests/mac-signature-scan.test.ts). **Path-free builds.** `tools/desktop-release.ts` builds with `--remap-path-prefix` and `-ffile-prefix-map`, which turn home into `~`, the checkout into `/src`, the cargo and rustup homes into `/cargo` and `/rustup`, and diff --git a/tests/mac-signature-scan.test.ts b/tests/mac-signature-scan.test.ts new file mode 100644 index 0000000..efb7b7a --- /dev/null +++ b/tests/mac-signature-scan.test.ts @@ -0,0 +1,79 @@ +// The privacy scan of signed macOS artifacts leaves out Apple code signatures only (tools/mac-signature-scan.ts): +// a term inside a signature passes, the same term anywhere else (a resource, the binary's own bytes) fails. +import { afterAll, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { blankDmgSignature, dmgSignatureRange, isMachO, scanSignedMac } from '../tools/mac-signature-scan'; +import { type Report, scanPath, type Term } from '../tools/privacy-scan'; + +const TERM = 'Qzvfakecorp'; +const terms: Term[] = [{ text: TERM, short: false }]; +const mac = process.platform === 'darwin'; +const dir = mkdtempSync(join(tmpdir(), 'sb-macsig-test-')); +afterAll(() => rmSync(dir, { recursive: true, force: true })); +const empty = (): Report => ({ files: 0, bytes: 0, hits: [], errors: [] }); +const ok = (cmd: string[]) => { const r = spawnSync(cmd[0]!, cmd.slice(1), { encoding: 'utf8' }); if (r.status !== 0) throw new Error(`${cmd.join(' ')}: ${r.stderr}`); }; + +/** A fake app whose executable is signed with the term in its signature (the identifier lives in the CodeDirectory). */ +function fakeApp(name: string, resource?: string): string { + const app = join(dir, name, 'Fake.app'); + mkdirSync(join(app, 'Contents', 'MacOS'), { recursive: true }); + mkdirSync(join(app, 'Contents', 'Resources'), { recursive: true }); + copyFileSync('/usr/bin/true', join(app, 'Contents', 'MacOS', 'fake')); + ok(['codesign', '-f', '-s', '-', '-i', `com.${TERM}.fake`, join(app, 'Contents', 'MacOS', 'fake')]); + writeFileSync(join(app, 'Contents', 'Resources', 'about.txt'), resource ?? 'nothing private here\n'); + return app; +} + +describe.skipIf(!mac)('signed macOS artifacts: signatures left out of the privacy scan', () => { + test('the term only in a code signature: the plain scan finds it, the signed-mac scan passes', () => { + const app = fakeApp('sig-only'); + const bin = join(app, 'Contents', 'MacOS', 'fake'); + expect(isMachO(readFileSync(bin).subarray(0, 4))).toBe(true); + const plain = empty(); + scanPath(app, terms, 'Fake.app', plain); + expect(plain.hits.length).toBeGreaterThan(0); + const r = scanSignedMac(app, terms, empty()); + expect(r.errors).toEqual([]); + expect(r.hits).toEqual([]); + // the original stays signed (only a copy is stripped) + ok(['codesign', '--verify', bin]); + }); + + test('the same term in a resource file still fails', () => { + const app = fakeApp('in-resource', `made by ${TERM}\n`); + const r = scanSignedMac(app, terms, empty()); + expect(r.hits.map(h => h.file)).toEqual(['Fake.app/Contents/Resources/about.txt']); + }); + + test('updater .app.tar.gz: the unpacked app is stripped, a resource hit fails', () => { + const good = fakeApp('tar-good'), bad = fakeApp('tar-bad', `${TERM}\n`); + const tg = join(dir, 'good.app.tar.gz'), tb = join(dir, 'bad.app.tar.gz'); + ok(['tar', '-czf', tg, '-C', join(good, '..'), 'Fake.app']); + ok(['tar', '-czf', tb, '-C', join(bad, '..'), 'Fake.app']); + expect(scanSignedMac(tg, terms, empty()).hits).toEqual([]); + expect(scanSignedMac(tb, terms, empty()).hits.map(h => h.file)).toEqual(['bad.app.tar.gz!/Fake.app/Contents/Resources/about.txt']); + }); + + test('DMG: its own signature blob is zeroed, the term in the volume fails', () => { + const src = join(dir, 'dmg-src'); + mkdirSync(src, { recursive: true }); + writeFileSync(join(src, 'readme.txt'), 'hello\n'); + const dmg = join(dir, 'x.dmg'); + ok(['hdiutil', 'create', '-quiet', '-fs', 'HFS+', '-srcfolder', src, '-format', 'UDZO', '-ov', dmg]); + ok(['codesign', '-f', '-s', '-', '-i', `com.${TERM}.dmg`, dmg]); + const data = readFileSync(dmg); + const range = dmgSignatureRange(data); + expect(range).not.toBeNull(); + expect(data.subarray(range!.offset, range!.offset + range!.length).includes(Buffer.from(TERM))).toBe(true); + expect(Buffer.from(blankDmgSignature(data)).includes(Buffer.from(TERM))).toBe(false); + expect(scanSignedMac(dmg, terms, empty()).hits).toEqual([]); + + writeFileSync(join(src, 'readme.txt'), `by ${TERM}\n`); + const dmg2 = join(dir, 'y.dmg'); + ok(['hdiutil', 'create', '-quiet', '-fs', 'HFS+', '-srcfolder', src, '-format', 'UDZO', '-ov', dmg2]); + expect(scanSignedMac(dmg2, terms, empty()).hits.map(h => h.file)).toEqual(['y.dmg!/readme.txt']); + }, 60_000); +}); diff --git a/tools/desktop-release.ts b/tools/desktop-release.ts index 68519ac..a30ee8d 100644 --- a/tools/desktop-release.ts +++ b/tools/desktop-release.ts @@ -41,7 +41,8 @@ import { verify as verifySig } from '../cli/lib/minisign'; import { buildOne, OUT_DIR as SIDECAR_DIR } from './build-sidecars'; import { versions } from './desktop-version'; import { upsert as forgejoUpsert } from './forgejo-release'; -import { scan } from './privacy-scan'; +import { isMacArtifact, scanSignedMac } from './mac-signature-scan'; +import { allTerms, DEFAULT_TERMS, type Report, scan, TERMS_ENV } from './privacy-scan'; export const ROOT = resolve(import.meta.dir, '..'); const APP_DIR = join(ROOT, 'apps/desktop'); @@ -187,8 +188,27 @@ function signFile(file: string): string { return sig; } +/** + * The privacy scan of release artifacts. Signed macOS artifacts (.app, .dmg, .app.tar.gz) are scanned without their + * Apple code signatures (tools/mac-signature-scan.ts): the Developer ID certificate in them names the developer, and + * that name may appear there only. Every other byte is scanned; a hit outside a signature fails the release. + */ function privacyGate(paths: string[]): { files: number; bytes: number; hits: number } { - const r = scan(paths); + const mac = process.platform === 'darwin' ? paths.filter(isMacArtifact) : []; + const r = scan(paths.filter(p => !mac.includes(p))); + if (mac.length) { + const termsFile = process.env[TERMS_ENV] ?? DEFAULT_TERMS; + const terms = allTerms(termsFile); + const m: Report = { files: 0, bytes: 0, hits: [], errors: [] }; + for (const p of mac) { + if (!existsSync(p)) { m.errors.push(`${p}: does not exist`); continue; } + try { scanSignedMac(p, terms, m); } catch (e) { m.errors.push((e as Error).message); } + } + r.files += m.files; r.bytes += m.bytes; r.hits.push(...m.hits); r.errors.push(...m.errors); + r.terms = terms.length; + if (!r.termsFile && existsSync(termsFile)) r.termsFile = termsFile; + say(`privacy: ${mac.length} signed macOS artifact(s) scanned without their code signatures (the Developer ID certificate may name the developer there only)`); + } for (const h of r.hits) console.error(`privacy: ${h.file} @${h.offset}${h.encoding ? ` (${h.encoding})` : ''}: ${h.what}`); for (const e of r.errors) console.error(`privacy: could not open ${e}`); if (r.hits.length || r.errors.length) fail(`privacy scan failed (${r.hits.length} hits, ${r.errors.length} errors): nothing may ship`); diff --git a/tools/mac-signature-scan.ts b/tools/mac-signature-scan.ts new file mode 100644 index 0000000..e7a2d77 --- /dev/null +++ b/tools/mac-signature-scan.ts @@ -0,0 +1,113 @@ +// Privacy scan of signed macOS artifacts with Apple's code signatures left out. +// +// A Developer ID signature embeds the signing certificate, and its subject is the developer's legal (company) name. +// That name may appear there and nowhere else. So the signed .app, DMG and updater bundle are scanned as copies with +// the code signatures removed; every other byte is scanned as it ships, and a hit outside a signature still fails. +// - Mach-O files (main binary, sidecar, helpers, dylibs): `codesign --remove-signature` on a copy; +// - the DMG's own bytes: the code-signature SuperBlob the UDIF trailer ("koly") points to is zeroed in memory +// (codesign cannot remove a DMG signature); its volume is copied out of a read-only mount and stripped like an app; +// - the updater .app.tar.gz: its compressed bytes as they are, then the unpacked app, stripped. +import { spawnSync } from 'node:child_process'; +import { closeSync, lstatSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join } from 'node:path'; +import { type Report, scanBuffer, scanPath, type Term } from './privacy-scan'; + +const MACHO = new Set(['feedface', 'feedfacf', 'cefaedfe', 'cffaedfe', 'cafebabe', 'bebafeca']); +const SUPERBLOB = 0xfade0cc0; + +export function isMachO(head: Uint8Array): boolean { + return head.length >= 4 && MACHO.has(Buffer.from(head.subarray(0, 4)).toString('hex')); +} + +function head4(file: string): Uint8Array { + const fd = openSync(file, 'r'); + try { const b = Buffer.alloc(4); const n = readSync(fd, b, 0, 4, 0); return b.subarray(0, n); } finally { closeSync(fd); } +} + +function sh(cmd: string[], what: string): void { + const r = spawnSync(cmd[0]!, cmd.slice(1), { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' }); + if (r.status !== 0) throw new Error(`${what}: ${basename(cmd[0]!)} failed: ${(r.stderr || '').trim().split('\n').at(-1)}`); +} + +/** Removes the code signature of every Mach-O file under dir (a copy); returns how many were stripped. */ +export function stripMachOSignatures(dir: string): number { + let n = 0; + const walk = (p: string) => { + const st = lstatSync(p); + if (st.isSymbolicLink()) return; + if (st.isDirectory()) { for (const e of readdirSync(p)) walk(join(p, e)); return; } + if (!st.isFile() || st.size < 4 || !isMachO(head4(p))) return; + const r = spawnSync('codesign', ['--remove-signature', p], { stdio: ['ignore', 'ignore', 'pipe'], encoding: 'utf8' }); + // an unsigned Mach-O (nothing to remove) is fine; anything else is an error + if (r.status !== 0 && !/not signed|no signature/i.test(r.stderr || '')) throw new Error(`${p}: codesign --remove-signature failed: ${(r.stderr || '').trim()}`); + if (r.status === 0) n++; + }; + walk(dir); + return n; +} + +/** The code-signature range of a UDIF disk image (koly trailer, offset 0x128), or null. */ +export function dmgSignatureRange(data: Uint8Array): { offset: number; length: number } | null { + if (data.length < 512) return null; + const k = Buffer.from(data.buffer, data.byteOffset + data.length - 512, 512); + if (k.toString('latin1', 0, 4) !== 'koly') return null; + const offset = Number(k.readBigUInt64BE(0x128)), length = Number(k.readBigUInt64BE(0x130)); + if (!length || offset + length > data.length - 512 || offset < 0) return null; + if (Buffer.from(data.buffer, data.byteOffset + offset, 4).readUInt32BE(0) !== SUPERBLOB) return null; + return { offset, length }; +} + +/** A copy of a DMG's bytes with its code-signature SuperBlob zeroed (unchanged when it has none). */ +export function blankDmgSignature(data: Uint8Array): Uint8Array { + const r = dmgSignatureRange(data); + if (!r) return data; + const out = Buffer.from(data); + out.fill(0, r.offset, r.offset + r.length); + return out; +} + +const tmp = (p: string) => mkdtempSync(join(tmpdir(), p)); + +/** Scans one signed macOS artifact (.app folder, .dmg, .app.tar.gz) without its code signatures; other paths as usual. */ +export function scanSignedMac(path: string, terms: Term[], report: Report): Report { + if (process.platform !== 'darwin') throw new Error('the signed macOS scan needs macOS (codesign, hdiutil, ditto)'); + const name = basename(path); + const work = tmp('sb-privacy-mac-'); + try { + if (lstatSync(path).isDirectory()) { + const copy = join(work, name); + sh(['ditto', path, copy], name); + stripMachOSignatures(copy); + return scanPath(copy, terms, name, report); + } + if (/\.dmg$/i.test(name)) { + const data = readFileSync(path); + report.files++; + report.bytes += data.length; + report.hits.push(...scanBuffer(name, blankDmgSignature(data), terms)); + const mnt = join(work, 'mnt'), vol = join(work, 'vol'); + sh(['hdiutil', 'attach', '-readonly', '-nobrowse', '-noverify', '-noautoopen', '-mountpoint', mnt, path], name); + try { sh(['ditto', mnt, vol], name); } finally { spawnSync('hdiutil', ['detach', mnt, '-force'], { stdio: 'ignore' }); } + stripMachOSignatures(vol); + return scanPath(vol, terms, `${name}!`, report); + } + if (/\.(tar\.gz|tgz)$/i.test(name)) { + const data = readFileSync(path); + report.files++; + report.bytes += data.length; + report.hits.push(...scanBuffer(name, data, terms)); + const x = join(work, 'x'); + sh(['mkdir', '-p', x], name); + sh(['tar', '-xzf', path, '-C', x], name); + stripMachOSignatures(x); + return scanPath(x, terms, `${name}!`, report); + } + return scanPath(path, terms, name, report); + } finally { + rmSync(work, { recursive: true, force: true }); + } +} + +/** True for the artifacts scanSignedMac handles specially. */ +export const isMacArtifact = (path: string): boolean => /\.app\/?$|\.dmg$|\.app\.tar\.gz$/i.test(path); diff --git a/tools/privacy-scan.ts b/tools/privacy-scan.ts index 7cffca5..8ca90a5 100644 --- a/tools/privacy-scan.ts +++ b/tools/privacy-scan.ts @@ -247,8 +247,11 @@ export function scanPath(path: string, terms: Term[], shown = basename(path), re return report; } +/** Every term a scan looks for: the terms file, this machine's names (auto) and scrub's private-name rule. */ +export const allTerms = (termsFile: string | null, auto = true): Term[] => [...collectTerms(termsFile, auto), ...scrubTerms()]; + export function scan(paths: string[], termsFile: string | null = process.env[TERMS_ENV] ?? DEFAULT_TERMS, auto = true): Report & { terms: number; termsFile: string | null } { - const terms = [...collectTerms(termsFile, auto), ...scrubTerms()]; + const terms = allTerms(termsFile, auto); const report: Report = { files: 0, bytes: 0, hits: [], errors: [] }; for (const p of paths) { if (!existsSync(p)) { report.errors.push(`${p}: does not exist`); continue; } From f58e1920019f992a6355b4a8030d94ec252301a5 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 08:29:26 +0800 Subject: [PATCH 004/397] release acceptance matrix: tools/acceptance.ts runs every user-facing command from the packed npm tarball (npx --package= on Node 20 / 22, bun x --bun after bun add ) in fresh temp folders with a fresh HOME, real piped stdin (n, EOF) and per-cell fake stores over HTTP (STOREBOUND_STORE_MOCK, loopback only); a cell passes only with the expected exit code and no crash text; report -> markdown matrix. Prompts go through stdinIsTTY (STOREBOUND_ASSUME_TTY drives them with piped stdin), STOREBOUND_DESKTOP_APP=none for the install prompt on a machine with the app. release.yml: required acceptance job matrix (Linux + Windows x node20/node22/bun) and report, the matrix attached to the release; ci.yml: the quick subset on Linux and Windows. Scan fixtures for React Native and Expo --- .forgejo/workflows/ci.yml | 42 ++ .forgejo/workflows/release.yml | 115 +++- cli/api.ts | 3 +- cli/apple/appinfo.ts | 3 +- cli/apple/subscriptions.ts | 5 +- cli/desktop.ts | 3 +- cli/index.ts | 2 + cli/lib/ctx.ts | 3 +- cli/lib/desktop-dist.ts | 2 + cli/lib/price-gate.ts | 3 +- cli/lib/store-mock-env.ts | 32 + cli/lib/tty.ts | 5 + cli/listing-store.ts | 5 +- cli/play/listing.ts | 3 +- cli/play/subscriptions.ts | 3 +- cli/prices.ts | 3 +- tests/acceptance.test.ts | 100 ++++ tests/fixtures/scan/expo/README.md | 2 + tests/fixtures/scan/expo/app.json | 9 + tests/fixtures/scan/expo/package.json | 12 + tests/fixtures/scan/react-native/README.md | 2 + .../android/app/src/main/AndroidManifest.xml | 5 + .../scan/react-native/ios/Notes/Info.plist | 12 + tests/fixtures/scan/react-native/package.json | 10 + tests/helpers/fake-stores.ts | 1 + tools/acceptance.ts | 556 ++++++++++++++++++ 26 files changed, 926 insertions(+), 15 deletions(-) create mode 100644 cli/lib/store-mock-env.ts create mode 100644 cli/lib/tty.ts create mode 100644 tests/acceptance.test.ts create mode 100644 tests/fixtures/scan/expo/README.md create mode 100644 tests/fixtures/scan/expo/app.json create mode 100644 tests/fixtures/scan/expo/package.json create mode 100644 tests/fixtures/scan/react-native/README.md create mode 100644 tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml create mode 100644 tests/fixtures/scan/react-native/ios/Notes/Info.plist create mode 100644 tests/fixtures/scan/react-native/package.json create mode 100644 tools/acceptance.ts diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 63d25c3..d816b74 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -3,6 +3,7 @@ # tools/ci.ts runs the steps as a task graph and the tests as parallel shards (CPU cores - 1, max 8) on the one # self-hosted runner, so a job matrix would only queue; CI=full makes it run the Rust steps on every push. # Publishes nothing: site/ is still published by Jenkins; releases come from release.yml (a v* tag). +# acceptance-quick: the quick subset of the acceptance matrix (tools/acceptance.ts) on the Linux and Windows runners. # release.yml's ci job runs the same steps (the setup is shared: .forgejo/actions/linux-setup). name: ci @@ -33,3 +34,44 @@ jobs: cache-key: ci rust-components: clippy - uses: ./.forgejo/actions/ci-run + + # The quick subset of the release acceptance matrix (tools/acceptance.ts --quick: --version, init, check, scan, the + # desktop [Y/n] prompt answered n, mcp, a console plan, store --dry writes and confirm prompts answered n) from the + # packed npm tarball with real piped stdin, on Linux and Windows with Node 20. The full matrix gates the release. + acceptance-quick: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-22.04, platform: Linux } + - { os: windows, platform: Windows } + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Bun (Linux) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" + - name: Acceptance quick (Linux, Node 20) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: bun tools/acceptance.ts run --runtime node --platform Linux --quick --jobs 4 --out acceptance/Linux-node20-quick.json + - name: Acceptance quick (Windows, Node 20) + if: ${{ matrix.platform == 'Windows' }} + shell: pwsh + run: | + bun tools/acceptance.ts run --runtime node --platform Windows --quick --jobs 3 --out acceptance/Windows-node20-quick.json + if ($LASTEXITCODE -ne 0) { throw "acceptance quick: red cells" } + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: acceptance-quick-${{ matrix.platform }} + path: acceptance/ + retention-days: 14 diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml index 18d2e05..d5549d2 100644 --- a/.forgejo/workflows/release.yml +++ b/.forgejo/workflows/release.yml @@ -189,6 +189,110 @@ jobs: retention-days: 14 + # The release acceptance matrix (tools/acceptance.ts, docs/acceptance.md): every user-facing command from the packed + # npm tarball, in fresh temp folders with piped stdin and the fake stores, on Linux and Windows x Node 20 / Node 22 + # (npx --package=) / Bun (bun x). One red cell and there is no release. macOS runs on the owner's Mac + # (bun tools/acceptance.ts run ...) before the DMG upload; its result files live in docs/acceptance/. + acceptance: + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - { os: ubuntu-22.04, platform: Linux, runtime: node, node: '20', name: node20 } + - { os: ubuntu-22.04, platform: Linux, runtime: node, node: '22', name: node22 } + - { os: ubuntu-22.04, platform: Linux, runtime: bun, node: '22', name: bun } + - { os: windows, platform: Windows, runtime: node, node: '20', name: node20 } + - { os: windows, platform: Windows, runtime: node, node: '22', name: node22 } + - { os: windows, platform: Windows, runtime: bun, node: '22', name: bun } + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + - name: Bun (Linux) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + echo "$HOME/.bun/bin" >> "${FORGEJO_PATH:-$GITHUB_PATH}" + - name: Acceptance matrix (Linux) + if: ${{ matrix.platform == 'Linux' }} + shell: bash + run: | + node --version && bun --version + bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Linux --jobs 4 --out acceptance/Linux-${{ matrix.name }}.json + - name: Acceptance matrix (Windows) + if: ${{ matrix.platform == 'Windows' }} + shell: pwsh + run: | + node --version; bun --version + bun tools/acceptance.ts run --runtime ${{ matrix.runtime }} --platform Windows --jobs 3 --out acceptance/Windows-${{ matrix.name }}.json + if ($LASTEXITCODE -ne 0) { throw "acceptance matrix: red cells" } + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: acceptance-${{ matrix.platform }}-${{ matrix.name }} + path: acceptance/ + retention-days: 30 + + acceptance-report: + needs: [prepare, acceptance] + if: ${{ always() && needs.prepare.result == 'success' }} + runs-on: ubuntu-22.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ needs.prepare.outputs.ref }} + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Linux-node20 + path: acceptance-runs/Linux-node20 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Linux-node22 + path: acceptance-runs/Linux-node22 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Linux-bun + path: acceptance-runs/Linux-bun + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Windows-node20 + path: acceptance-runs/Windows-node20 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Windows-node22 + path: acceptance-runs/Windows-node22 + - uses: actions/download-artifact@v3 + continue-on-error: true + with: + name: acceptance-Windows-bun + path: acceptance-runs/Windows-bun + - name: Matrix (markdown), red cells fail the job + run: | + curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" >/dev/null + export PATH="$HOME/.bun/bin:$PATH" + V="${{ needs.prepare.outputs.version }}" + mkdir -p out + bun tools/acceptance.ts report acceptance-runs docs/acceptance --md "out/storebound_${V}_acceptance.md" --expect 7 --strict + - uses: actions/upload-artifact@v3 + if: ${{ always() }} + with: + name: storebound-acceptance + path: out/ + retention-days: 30 + windows: needs: prepare # a tag push always builds Windows; a manual run unless "windows" is unticked @@ -243,9 +347,9 @@ jobs: retention-days: 14 release: - needs: [prepare, ci, linux, windows] - # a real release needs every platform; a dry run may skip Windows - if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }} + needs: [prepare, ci, acceptance, acceptance-report, linux, windows] + # a real release needs every platform and a green acceptance matrix; a dry run may skip Windows + if: ${{ always() && needs.prepare.result == 'success' && needs.ci.result == 'success' && needs.acceptance.result == 'success' && needs.acceptance-report.result == 'success' && needs.linux.result == 'success' && (needs.windows.result == 'success' || (needs.windows.result == 'skipped' && needs.prepare.outputs.dry == 'true')) }} runs-on: ubuntu-22.04 timeout-minutes: 30 env: @@ -277,6 +381,11 @@ jobs: with: name: storebound-desktop-windows path: dist/windows + # the acceptance matrix goes with the release (storebound__acceptance.md) + - uses: actions/download-artifact@v3 + with: + name: storebound-acceptance + path: dist/acceptance - name: Collect, privacy scan (private terms), checksums env: FORBIDDEN_TERMS: ${{ secrets.FORBIDDEN_TERMS }} diff --git a/cli/api.ts b/cli/api.ts index e6cca83..543ea34 100644 --- a/cli/api.ts +++ b/cli/api.ts @@ -19,6 +19,7 @@ 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'; +import { stdinIsTTY } from './lib/tty'; const STORES: Store[] = ['apple', 'play']; @@ -140,7 +141,7 @@ export async function run(args: Args): Promise { const kind = gateOf(st, op.id, op.method); const decision = decide({ kind, method: op.method, dry: args.has('dry'), yes: args.has('yes'), getOnly, - tty: !!process.stdin.isTTY && !!process.stdout.isTTY, + tty: stdinIsTTY() && !!process.stdout.isTTY, agent: process.env.STOREBOUND_SERVE === '1' || process.env.STOREBOUND_MCP === '1', }); if (decision.action === 'refuse') throw new Error(`${op.id}: ${decision.why}`); diff --git a/cli/apple/appinfo.ts b/cli/apple/appinfo.ts index c58d2f0..510c752 100644 --- a/cli/apple/appinfo.ts +++ b/cli/apple/appinfo.ts @@ -15,6 +15,7 @@ import { ascClient } from './asc'; export { AGE_RATING_NONE } from '../lib/store-catalogs'; import { AGE_RATING_NONE } from '../lib/store-catalogs'; +import { stdinIsTTY } from '../lib/tty'; const CATEGORY_RELS = ['primaryCategory', 'primarySubcategoryOne', 'primarySubcategoryTwo', 'secondaryCategory'] as const; const str = (v: unknown): string => (v === null || v === undefined ? '' : String(v)); @@ -75,7 +76,7 @@ export async function run(ctx: Ctx): Promise { else for (const line of appInfoPlanLines(p)) console.log(line); if (dry) { done('apple', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } if (!p.changes) { done('apple', 'appinfo already up to date (App Privacy is UI-only: store-declarations skill)'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to App Store Connect?`))) { warn('apple', 'nothing written'); return; } const changed = (key: string) => p.items.some(i => i.key === key && i.action !== 'same'); if (info.categories && changed('categories')) { diff --git a/cli/apple/subscriptions.ts b/cli/apple/subscriptions.ts index 81c8d29..0a8f024 100644 --- a/cli/apple/subscriptions.ts +++ b/cli/apple/subscriptions.ts @@ -30,6 +30,7 @@ import { import { pages } from '../prices'; import { createOnce, findOrCreate, getById } from '../lib/upsert'; import { type Asc, ascClient, md5, type J } from './asc'; +import { stdinIsTTY } from '../lib/tty'; const TAG = 'apple'; @@ -84,7 +85,7 @@ export async function run(ctx: Ctx): Promise { const yes = ctx.args.has('yes'); // price changes of existing subscriptions: known per subscription, decided at the first one (lib/price-gate.ts) let liveOk: boolean | undefined; - if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') { + if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, 'Create / update these subscriptions on App Store Connect? Nothing is public before App Review, but price changes of existing subscriptions apply to new subscribers right away.'))) return; liveOk = true; } else if (!yes) log(TAG, 'not a terminal: creating what is new; price changes of existing subscriptions wait for the owner'); @@ -220,7 +221,7 @@ export async function run(ctx: Ctx): Promise { * the code can redeem it). Existing codes (same customCode) are left alone. */ async function customCodes(c: Asc, ctx: Ctx, subId: string, s: Subscription, oc: OfferCodeDef, ocId: string | undefined): Promise { if (!ctx.args.has('codes')) { log(TAG, ` ${oc.customCodes!.length} custom code(s) of "${oc.name}" not created (pass --codes from a terminal)`); return; } - if (!process.stdin.isTTY) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; } + if (!stdinIsTTY()) { warn(TAG, ` custom codes of "${oc.name}" need a terminal confirmation`); return; } const id = ocId ?? (await c.all(`/v1/subscriptions/${subId}/offerCodes?limit=200`)).find((x: J) => x.attributes.name === oc.name)?.id; if (!id) throw new Error(`${s.id}: offer code campaign "${oc.name}" not found`); const have = new Set((await c.all(`/v1/subscriptionOfferCodes/${id}/customCodes?limit=200`)).map((x: J) => x.attributes.customCode)); diff --git a/cli/desktop.ts b/cli/desktop.ts index 11be003..9cda1b6 100644 --- a/cli/desktop.ts +++ b/cli/desktop.ts @@ -26,6 +26,7 @@ import { verify } from './lib/minisign'; import { parsePlist } from './lib/plist'; import { addFolder } from './lib/registry'; import * as rt from './lib/rt'; +import { stdinIsTTY } from './lib/tty'; type Fetch = (url: string) => Promise; @@ -209,7 +210,7 @@ export async function run(args: Args): Promise { try { const r = rt.spawnSync(chk, { timeout: 10_000 }); running = isRunning(platform, r.exitCode, r.stdout.toString()); } catch { /* no pgrep / tasklist: go on */ } if (running) throw new Error(`storebound desktop ${decision.from} is running: quit it first, then run this again (or update it from the app: Settings > Updates)`); } - const how = consent(args.has('yes'), !!process.stdin.isTTY); + const how = consent(args.has('yes'), stdinIsTTY()); if (how !== 'yes') { if (how === 'declined') { const again = ['storebound', 'desktop', ...process.argv.slice(3).filter(a => a !== '--yes'), '--yes'].join(' '); diff --git a/cli/index.ts b/cli/index.ts index a128ef7..892b478 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -45,6 +45,8 @@ const groupUsage = (g: string, keys: string[]): string => async function main(): Promise { // tests (tests/cli-help.test.ts) run the CLI with STOREBOUND_NO_NETWORK=1: any request fails loudly if (process.env.STOREBOUND_NO_NETWORK === '1') globalThis.fetch = (async (u: unknown) => { throw new Error(`network disabled (STOREBOUND_NO_NETWORK): ${String(u)}`); }) as unknown as typeof fetch; + // the release acceptance matrix: store hosts answered by a local double (loopback only, cli/lib/store-mock-env.ts) + if (process.env.STOREBOUND_STORE_MOCK) (await import('./lib/store-mock-env')).installStoreMock(); // tools (adb, emulator, ffmpeg…) are also looked up where they usually live, for apps started without a shell PATH process.env.PATH = toolPath(process.env); const argv = process.argv.slice(2); diff --git a/cli/lib/ctx.ts b/cli/lib/ctx.ts index 979a217..daabbd0 100644 --- a/cli/lib/ctx.ts +++ b/cli/lib/ctx.ts @@ -2,6 +2,7 @@ import type { Args } from './args'; import type { Language, Loaded } from './config'; import { playCodes } from './config'; import * as rt from './rt'; +import { stdinIsTTY } from './tty'; export interface Ctx { loaded: Loaded; args: Args } @@ -38,7 +39,7 @@ export async function confirm(ctx: Ctx, question: string): Promise { return declined('confirm-required: terminal only'); } if (ctx.args.has('yes')) return true; - if (!process.stdin.isTTY) { + if (!stdinIsTTY()) { console.log(`${question}\n not a terminal: re-run with --yes to confirm`); return declined('not a terminal and no --yes (re-run with --yes to confirm)'); } diff --git a/cli/lib/desktop-dist.ts b/cli/lib/desktop-dist.ts index 29570e1..20f252e 100644 --- a/cli/lib/desktop-dist.ts +++ b/cli/lib/desktop-dist.ts @@ -98,6 +98,8 @@ export interface Installed { path: string; how: 'bundle' | 'exe' | 'appimage' | export function findInstalled(platform: NodeJS.Platform, env: Record = process.env, exists: (p: string) => boolean = existsSync, home = homedir(), list: (dir: string) => string[] = d => { try { return readdirSync(d); } catch { return []; } }): Installed | null { + // the release acceptance matrix drives the install prompt on a machine that has the app: act as if it had none + if (env.STOREBOUND_DESKTOP_APP === 'none') return null; if (platform === 'darwin') { for (const dir of ['/Applications', join(home, 'Applications')]) { const app = join(dir, 'storebound.app'); diff --git a/cli/lib/price-gate.ts b/cli/lib/price-gate.ts index d82e2e4..f070948 100644 --- a/cli/lib/price-gate.ts +++ b/cli/lib/price-gate.ts @@ -2,6 +2,7 @@ // play subscriptions, apple subscriptions and prices import (apple iap never changes the prices of an existing product). // Imported only by those commands, so --allow-price-changes is a flag of theirs alone (tools/gen-flags.ts). import { confirm, type Ctx, declined } from './ctx'; +import { stdinIsTTY } from './tty'; /** The flag that lets a write command change the price of an existing product. `flow run` never adds it and refuses it in steps. */ export const ALLOW_PRICE_CHANGES = 'allow-price-changes'; @@ -18,7 +19,7 @@ export interface HeldPrice { id: string; changes: { region: string; from: string export async function allowPriceChanges(ctx: Ctx, count: number, question: string): Promise { if (!count || ctx.args.has('allow-price-changes')) return true; const yes = ctx.args.has('yes'); - if (process.env.STOREBOUND_SERVE === '1' || (process.stdin.isTTY && !yes)) return confirm(withoutYes(ctx), question); + if (process.env.STOREBOUND_SERVE === '1' || (stdinIsTTY() && !yes)) return confirm(withoutYes(ctx), question); return false; } diff --git a/cli/lib/store-mock-env.ts b/cli/lib/store-mock-env.ts new file mode 100644 index 0000000..ff1c4cb --- /dev/null +++ b/cli/lib/store-mock-env.ts @@ -0,0 +1,32 @@ +// STOREBOUND_STORE_MOCK=http://127.0.0.1:: every request to a store host (App Store Connect, Google APIs, the +// public store pages, the example.com placeholders) goes to that local double instead, as +// /__host//. The release acceptance matrix (tools/acceptance.ts) runs the packed npm tarball this +// way against the fake stores of tests/helpers/fake-stores.ts, on every platform and runtime. Only a loopback address +// (with an optional path: the matrix gives every cell its own store) is accepted, so the variable can never send store keys or tokens to another machine. Unset: nothing changes. + +const STORE_HOST = /^(?:[a-z0-9-]+\.)*(?:apple\.com|googleapis\.com|google\.com|example\.com)$/i; +const LOOPBACK = /^http:\/\/(?:127\.0\.0\.1|localhost|\[::1\]):\d{1,5}(?:\/[\w.-]+)*$/; + +/** The URL a request goes to under the mock (unchanged for hosts that are not store hosts). */ +export function mockedUrl(url: string, base: string): string { + let u: URL; + try { u = new URL(url); } catch { return url; } + if (u.protocol !== 'https:' || !STORE_HOST.test(u.hostname)) return url; + return `${base}/__host/${u.hostname}${u.pathname}${u.search}`; +} + +/** Installs the redirect when STOREBOUND_STORE_MOCK is set; throws on a non-loopback value. */ +export function installStoreMock(env: Record = process.env): boolean { + const base = env.STOREBOUND_STORE_MOCK?.replace(/\/+$/, ''); + if (!base) return false; + if (!LOOPBACK.test(base)) throw new Error(`STOREBOUND_STORE_MOCK must be a loopback address like http://127.0.0.1:8080 (got ${JSON.stringify(base)})`); + const real = globalThis.fetch; + globalThis.fetch = ((input: string | URL | Request, init?: RequestInit) => { + if (input instanceof Request) { + const to = mockedUrl(input.url, base); + return real(to === input.url ? input : new Request(to, input), init); + } + return real(mockedUrl(String(input), base), init); + }) as typeof fetch; + return true; +} diff --git a/cli/lib/tty.ts b/cli/lib/tty.ts new file mode 100644 index 0000000..ba27ed1 --- /dev/null +++ b/cli/lib/tty.ts @@ -0,0 +1,5 @@ +// Whether a prompt can ask on stdin. STOREBOUND_ASSUME_TTY=1 treats piped stdin as the terminal, so the release +// acceptance matrix (tools/acceptance.ts) drives the real [y/N] and [Y/n] prompts with piped answers ("n\n", EOF) on +// every platform and runtime: the same reading code a person's terminal goes through. +export const stdinIsTTY = (env: Record = process.env): boolean => + env.STOREBOUND_ASSUME_TTY === '1' || !!process.stdin.isTTY; diff --git a/cli/listing-store.ts b/cli/listing-store.ts index fd2f754..784e6c5 100644 --- a/cli/listing-store.ts +++ b/cli/listing-store.ts @@ -20,6 +20,7 @@ import { confirm, need } from './lib/ctx'; import { done, log, printJson, warn } from './lib/log'; import { ascClient, EDITABLE, type J } from './apple/asc'; import { playClient } from './play/client'; +import { stdinIsTTY } from './lib/tty'; const TAG = 'listing'; @@ -143,7 +144,7 @@ async function set(ctx: Ctx): Promise { if (a.has('json')) printJson({ ...p, dry, written: false }); if (errors) throw new Error(`nothing sent: ${errors} error(s)`); if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on Google Play'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on Google Play (one edit, committed)?`))) { warn(TAG, 'nothing sent'); return; } const { root } = ctx.loaded; const play = await playClient(root, need(config.play?.packageName, 'play.packageName')); const body: Record = { language: lang }; @@ -173,7 +174,7 @@ async function set(ctx: Ctx): Promise { if (p.refused) throw new Error(p.refused); if (errors) throw new Error(`nothing sent: ${errors} error(s)`); if (dry || !p.changes.length) { done(TAG, dry ? 'dry run: nothing sent' : 'already the same on the App Store'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Change ${p.changes.length} field(s) of ${lang} on App Store Connect?`))) { warn(TAG, 'nothing sent'); return; } const info = Object.fromEntries(p.changes.filter(c => (APPLE_INFO_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); const ver = Object.fromEntries(p.changes.filter(c => (APPLE_VERSION_FIELDS as readonly string[]).includes(c.field)).map(c => [c.field, c.to])); if (Object.keys(info).length) { diff --git a/cli/play/listing.ts b/cli/play/listing.ts index 3e7645b..521e283 100644 --- a/cli/play/listing.ts +++ b/cli/play/listing.ts @@ -19,6 +19,7 @@ import type { Ctx } from '../lib/ctx'; import { confirm, need } from '../lib/ctx'; import { done, log, printJson, step, warn } from '../lib/log'; import { playClient } from './client'; +import { stdinIsTTY } from '../lib/tty'; export function readPlayCopy(ctx: Ctx) { const { config, resolve } = ctx.loaded; @@ -139,7 +140,7 @@ export async function run(ctx: Ctx): Promise { else for (const line of playPlanLines(p)) console.log(line); if (dry) { done('play', `dry run: ${p.changes ? `${p.changes} change(s)` : 'nothing to change'}; read-only, nothing written`); return; } if (!p.changes) { done('play', 'listing already up to date'); return; } - if (process.stdin.isTTY && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; } + if (stdinIsTTY() && !(await confirm(ctx, `Apply these ${p.changes} change(s) to Google Play (one edit, committed)?`))) { warn('play', 'nothing written'); return; } // The edit compares again with what it holds (a Console save or another run since the plan): only differing details // and languages are PUT; nothing left to change = the edit is deleted, not committed. Commit only after every PUT worked. let sent = 0; diff --git a/cli/play/subscriptions.ts b/cli/play/subscriptions.ts index e25dbd8..0ef8a24 100644 --- a/cli/play/subscriptions.ts +++ b/cli/play/subscriptions.ts @@ -26,6 +26,7 @@ import { import { subscriptionCsv } from '../apple/subscriptions'; import { createOnce } from '../lib/upsert'; import { playClient, type PlayClient } from './client'; +import { stdinIsTTY } from '../lib/tty'; /** GET of one Play resource, null on 404. */ export async function getOrNull(play: PlayClient, url: string): Promise { @@ -88,7 +89,7 @@ export async function run(ctx: Ctx): Promise { const yes = ctx.args.has('yes'); const priced = todo.filter(w => w.diff.basePlan === 'changed' && w.diff.priceChanges.length); let liveOk: boolean; - if (process.stdin.isTTY && !yes && process.env.STOREBOUND_SERVE !== '1') { + if (stdinIsTTY() && !yes && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, `Write ${todo.length} subscription(s) to Google Play? Activated base plans and offers can be bought as soon as the app is published; price changes apply to new subscribers.`))) return; liveOk = true; } else { diff --git a/cli/prices.ts b/cli/prices.ts index 09f6b31..90e465d 100644 --- a/cli/prices.ts +++ b/cli/prices.ts @@ -30,6 +30,7 @@ import { } from './lib/prices'; import { playClient, type PlayClient } from './play/client'; import { allSubscriptions, appleSubPriceRows, basePlanId, type PlaySubscription, playSubPriceRows } from './lib/subscriptions'; +import { stdinIsTTY } from './lib/tty'; const TAG = 'prices'; @@ -154,7 +155,7 @@ export async function run(ctx: Ctx): Promise { if (live.length) log(TAG, `owner: price change of ${live.length} existing product / store pair(s) (--allow-price-changes): ${live.map(k => k.replace('|', ' ')).join(', ')}`); if (dry) { done(TAG, 'dry run: nothing written'); return; } let liveOk: boolean; - if (process.stdin.isTTY && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') { + if (stdinIsTTY() && !a.has('yes') && process.env.STOREBOUND_SERVE !== '1') { if (!(await confirm(ctx, `Write the new prices for ${changed.size} product / store pair(s)? Users see them as soon as the stores apply them; an App Store price increase cannot be undone once it starts.`))) return; liveOk = true; } else { diff --git a/tests/acceptance.test.ts b/tests/acceptance.test.ts new file mode 100644 index 0000000..3964f65 --- /dev/null +++ b/tests/acceptance.test.ts @@ -0,0 +1,100 @@ +// The release acceptance matrix (tools/acceptance.ts) and the seams it drives the packed CLI through: the store mock +// (loopback only), piped stdin read as a terminal, the desktop app treated as not installed; no prompt checks +// process.stdin.isTTY on its own (the matrix could not reach it). The bugs it found: tests/matrix-bugs.test.ts. +import { afterEach, describe, expect, test } from 'bun:test'; +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { installStoreMock, mockedUrl } from '../cli/lib/store-mock-env'; +import { stdinIsTTY } from '../cli/lib/tty'; +import { findInstalled } from '../cli/lib/desktop-dist'; +import { CRASH, cells, renderReport, type Result } from '../tools/acceptance'; +import { restore } from './helpers/store-mock'; + +const ROOT = join(import.meta.dir, '..'); +afterEach(restore); + +describe('store mock (STOREBOUND_STORE_MOCK)', () => { + test('store hosts go to the mock, other hosts stay', () => { + const b = 'http://127.0.0.1:4100/c/cell-1'; + expect(mockedUrl('https://api.appstoreconnect.apple.com/v1/apps?limit=1', b)).toBe(`${b}/__host/api.appstoreconnect.apple.com/v1/apps?limit=1`); + expect(mockedUrl('https://androidpublisher.googleapis.com/androidpublisher/v3/applications/x/edits', b)).toBe(`${b}/__host/androidpublisher.googleapis.com/androidpublisher/v3/applications/x/edits`); + expect(mockedUrl('https://oauth2.googleapis.com/token', b)).toBe(`${b}/__host/oauth2.googleapis.com/token`); + expect(mockedUrl('https://example.com/privacy', b)).toBe(`${b}/__host/example.com/privacy`); + expect(mockedUrl('https://storebound.dev/desktop/latest.json', b)).toBe('https://storebound.dev/desktop/latest.json'); + expect(mockedUrl('https://evilapple.com/x', b)).toBe('https://evilapple.com/x'); + expect(mockedUrl('http://api.appstoreconnect.apple.com/x', b)).toBe('http://api.appstoreconnect.apple.com/x'); + }); + + test('only a loopback address is accepted (store keys never leave the machine)', () => { + const real = globalThis.fetch; + try { + for (const bad of ['http://192.0.2.' + '5:80', 'https://127.0.0.1:80', 'http://example.com:80', 'http://127.0.0.1.example.com:80', 'http://127.0.0.1:80' + '@evil.example']) + expect(() => installStoreMock({ STOREBOUND_STORE_MOCK: bad })).toThrow(/loopback/); + expect(installStoreMock({})).toBe(false); + expect(installStoreMock({ STOREBOUND_STORE_MOCK: 'http://127.0.0.1:4100/c/x' })).toBe(true); + } finally { globalThis.fetch = real; } + }); +}); + +describe('prompt and desktop seams', () => { + test('STOREBOUND_ASSUME_TTY=1 lets piped stdin answer the prompts', () => { + expect(stdinIsTTY({ STOREBOUND_ASSUME_TTY: '1' })).toBe(true); + expect(stdinIsTTY({})).toBe(!!process.stdin.isTTY); + }); + + test('STOREBOUND_DESKTOP_APP=none: the app counts as not installed', () => { + const exists = () => true; + expect(findInstalled('darwin', {}, exists, '/tmp/h')).not.toBeNull(); + for (const p of ['darwin', 'win32', 'linux'] as const) expect(findInstalled(p, { STOREBOUND_DESKTOP_APP: 'none', LOCALAPPDATA: 'C:\\x' }, exists, '/tmp/h', () => ['storebound.AppImage'])).toBeNull(); + }); + + test('no prompt checks process.stdin.isTTY directly (cli/lib/tty.ts stdinIsTTY, so the matrix can drive it)', () => { + const files = (readdirSync(join(ROOT, 'cli'), { recursive: true }) as string[]).filter(f => f.endsWith('.ts') && !f.endsWith('lib/tty.ts')); + const hits = files.filter(f => statSync(join(ROOT, 'cli', f)).isFile() && /process\.stdin\.isTTY/.test(readFileSync(join(ROOT, 'cli', f), 'utf8'))); + expect(hits).toEqual([]); + }); +}); + +describe('matrix', () => { + const forms = ['apple-app-privacy', 'play-ads']; + const list = cells(forms); + + test('crash text is recognised in every runtime\'s wording', () => { + for (const s of ['TypeError: x is not a function', "undefined is not an object (evaluating 'info.id')", "Cannot read properties of undefined (reading 'id')", 'error: console is not async iterable', 'Assertion failed: (x)', 'thread panicked at']) + expect(s).toMatch(CRASH); + for (const s of ['error: 5 problems', 'declined: the owner answered no', 'dry run: nothing changed']) expect(s).not.toMatch(CRASH); + }); + + test('every console form, every scan fixture, the headline prompts and a quick subset are covered', () => { + const ids = list.map(c => c.id); + for (const f of forms) expect(ids).toContain(`console plan ${f}`); + for (const f of ['capacitor', 'flutter', 'react-native', 'expo', 'unity', 'ios-native', 'android']) expect(ids).toContain(`scan ${f}`); + expect(ids).toContain('desktop [Y/n] answered n'); + expect(ids).toContain('mcp initialize + tools/list + tools/call check'); + expect(new Set(ids).size).toBe(ids.length); + const quick = list.filter(c => c.quick).map(c => c.id); + expect(quick).toContain('desktop [Y/n] answered n'); + expect(quick.length).toBeGreaterThan(10); + expect(quick.length).toBeLessThan(list.length / 2); + }); + + test('store cells are dry runs or answered prompts: none may write', () => { + for (const c of list.filter(x => x.group === 'store --dry')) { + expect(c.noWrites, c.id).toBe(true); + const reads = /^(apple testflight status|release (plan|status)|catalog diff|validate|play status|apple status|apps (list|check))/; + if (!reads.test(c.id)) expect(c.args, c.id).toContain('--dry'); + } + for (const c of list.filter(x => x.group === 'confirm n' && x.noWrites && x.stdin !== 'wrong\n')) expect([c.exit].flat(), c.id).toContain(3); + }); + + test('report: one column per platform and runtime, ✓ / ✗ with the first error', () => { + const r = (ok: boolean, rt: string, error?: string): Result => ({ id: 'help', group: 'basics', cmd: 'storebound help', platform: 'Linux', runtime: rt, ok, exit: ok ? 0 : 1, want: '0', ms: 1, ...(error ? { error } : {}) }); + const md = renderReport([ + { platform: 'Linux', runtime: 'node 20', label: 'node v20', version: '9.9.9', quick: false, at: '2026-10-14T00:00:00Z', results: [r(true, 'node 20')] }, + { platform: 'Linux', runtime: 'bun', label: 'bun', version: '9.9.9', quick: false, at: '2026-10-14T00:00:00Z', results: [r(false, 'bun', 'crash text: a | b')] }, + ]); + expect(md).toContain('| command | Linux · node 20 | Linux · bun |'); + expect(md).toContain('| ✓ | ✗ crash text: a \\| b |'); + expect(md).toContain('**1 / 2 cells passed.**'); + }); +}); diff --git a/tests/fixtures/scan/expo/README.md b/tests/fixtures/scan/expo/README.md new file mode 100644 index 0000000..e310c14 --- /dev/null +++ b/tests/fixtures/scan/expo/README.md @@ -0,0 +1,2 @@ +Scan fixture: a managed Expo app (app.json, no native folders), used by the release acceptance matrix +(tools/acceptance.ts) and tests/scan.test.ts. Placeholder ids only. diff --git a/tests/fixtures/scan/expo/app.json b/tests/fixtures/scan/expo/app.json new file mode 100644 index 0000000..578b203 --- /dev/null +++ b/tests/fixtures/scan/expo/app.json @@ -0,0 +1,9 @@ +{ + "expo": { + "name": "Example Expo", + "slug": "example-expo", + "version": "1.0.0", + "ios": { "bundleIdentifier": "com.example.expo" }, + "android": { "package": "com.example.expo" } + } +} diff --git a/tests/fixtures/scan/expo/package.json b/tests/fixtures/scan/expo/package.json new file mode 100644 index 0000000..b8f11f8 --- /dev/null +++ b/tests/fixtures/scan/expo/package.json @@ -0,0 +1,12 @@ +{ + "name": "example-expo", + "version": "1.0.0", + "private": true, + "main": "expo-router/entry", + "dependencies": { + "expo": "54.0.0", + "expo-location": "19.0.0", + "react": "19.1.0", + "react-native": "0.81.0" + } +} diff --git a/tests/fixtures/scan/react-native/README.md b/tests/fixtures/scan/react-native/README.md new file mode 100644 index 0000000..9e6d8bd --- /dev/null +++ b/tests/fixtures/scan/react-native/README.md @@ -0,0 +1,2 @@ +Scan fixture: a bare React Native app (ios/ and android/ folders, Info.plist and manifest), used by the release +acceptance matrix (tools/acceptance.ts) and tests/scan.test.ts. Placeholder ids only. diff --git a/tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml b/tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..7143bb5 --- /dev/null +++ b/tests/fixtures/scan/react-native/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/tests/fixtures/scan/react-native/ios/Notes/Info.plist b/tests/fixtures/scan/react-native/ios/Notes/Info.plist new file mode 100644 index 0000000..dfea0af --- /dev/null +++ b/tests/fixtures/scan/react-native/ios/Notes/Info.plist @@ -0,0 +1,12 @@ + + + + + CFBundleIdentifier + com.example.rn + CFBundleShortVersionString + 1.0.0 + NSCameraUsageDescription + Scan documents into your notes. + + diff --git a/tests/fixtures/scan/react-native/package.json b/tests/fixtures/scan/react-native/package.json new file mode 100644 index 0000000..d70004d --- /dev/null +++ b/tests/fixtures/scan/react-native/package.json @@ -0,0 +1,10 @@ +{ + "name": "example-rn", + "version": "1.0.0", + "private": true, + "dependencies": { + "react": "19.1.0", + "react-native": "0.81.0", + "react-native-vision-camera": "4.7.0" + } +} diff --git a/tests/helpers/fake-stores.ts b/tests/helpers/fake-stores.ts index 29484db..9cb3e6d 100644 --- a/tests/helpers/fake-stores.ts +++ b/tests/helpers/fake-stores.ts @@ -70,6 +70,7 @@ const LISTS: Record = { 'apps/promotedPurchases': { type: 'promotedPurchases' }, 'subscriptions/winBackOffers': { type: 'winBackOffers' }, 'betaGroups/betaTesters': { type: 'betaTesters' }, + 'apps/analyticsReportRequests': { type: 'analyticsReportRequests' }, }; /** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */ const UNIQUE: Record string> = { diff --git a/tools/acceptance.ts b/tools/acceptance.ts new file mode 100644 index 0000000..0abb5ee --- /dev/null +++ b/tools/acceptance.ts @@ -0,0 +1,556 @@ +#!/usr/bin/env bun +// Release acceptance matrix: every user-facing command, run the way a user runs it, on this platform and one runtime. +// bun tools/acceptance.ts run --runtime node|bun [--node ] [--tgz ] [--quick] +// [--platform