storebound/cli/play/data-safety.ts
CRT 36a8bf9625 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
2026-10-13 04:03:28 +08:00

71 lines
4 KiB
TypeScript

// Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer).
// storebound play data-safety [--csv out.csv] [--dry] [--force]
// Answers come from `dataSafety` (and accounts.deletionUrl) in storebound.config.ts. --dry only writes / prints the CSV.
// Both --dry and a real run stop on a missing required row (dataSafetyIssues) before anything is sent.
// Play cannot read the form back, so a run compares with what this project uploaded last: the sha256 of that CSV
// (per package) is kept in .storebound/data-safety.sha256 (gitignored). The same CSV is not sent again; --force sends it
// anyway (e.g. after the form was changed in Play Console by hand).
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { dataSafetyCsv, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety';
import { DATA_SAFETY_UPLOADED } from '../lib/declarations';
import { done, log } from '../lib/log';
import { playClient } from './client';
import * as rt from '../lib/rt';
export const DATA_SAFETY_HASH = '.storebound/data-safety.sha256';
/** sha256 of the CSV for one package. Pure. */
export const dataSafetyHash = (pkg: string, csv: string): string => rt.hasher('sha256').update(`${pkg}\n${csv}`).digest('hex');
/** The hash of the last upload of this package, null when none is recorded. */
export function lastUpload(root: string, pkg: string): { sha256: string; at: string } | null {
const f = join(root, DATA_SAFETY_HASH);
if (!existsSync(f)) return null;
try { return (JSON.parse(readFileSync(f, 'utf8')) as Record<string, { sha256: string; at: string }>)[pkg] ?? null; } catch { return null; }
}
function rememberUpload(root: string, pkg: string, sha256: string): void {
const f = join(root, DATA_SAFETY_HASH);
let all: Record<string, { sha256: string; at: string }> = {};
try { if (existsSync(f)) all = JSON.parse(readFileSync(f, 'utf8')); } catch { all = {}; }
all[pkg] = { sha256, at: new Date().toISOString() };
mkdirSync(join(root, '.storebound'), { recursive: true });
writeFileSync(f, JSON.stringify(all, null, 2) + '\n');
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root, resolve } = ctx.loaded;
const ds = need(config.dataSafety, 'dataSafety');
const csv = dataSafetyCsv(ds, config.accounts);
const problem = dataSafetyProblem(dataSafetyRows(ds, config.accounts));
const out = ctx.args.get('csv');
if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); }
const types = Object.keys(config.dataSafety!.data);
log('play', `data safety: ${types.length} data types declared (${types.join(', ') || 'none'})`);
const pkg = need(config.play?.packageName, 'play.packageName');
const hash = dataSafetyHash(pkg, csv);
const last = lastUpload(root, pkg);
const same = last?.sha256 === hash;
if (ctx.args.has('dry')) {
if (!out) process.stdout.write(csv);
if (problem) throw new Error(problem);
log('play', same ? `the same CSV was uploaded ${last!.at.slice(0, 16).replace('T', ' ')} UTC: a real run sends nothing (--force sends it again)` : 'a real run uploads it (it differs from the last upload, or none is recorded)');
return;
}
if (problem) throw new Error(problem);
if (same && !ctx.args.has('force')) {
done('play', `data safety unchanged since the upload of ${last!.at.slice(0, 16).replace('T', ' ')} UTC: nothing sent (--force sends it again, e.g. after a change in Play Console)`);
return;
}
const play = await playClient(root, pkg);
await play.call('POST', `${play.API}/dataSafety`, { safetyLabels: csv });
// keep what went up: the hash skips the next identical upload, the CSV lets the desktop app show what changed since
try {
rememberUpload(root, pkg, hash);
writeFileSync(join(root, DATA_SAFETY_UPLOADED), csv);
} catch { /* the upload itself worked */ }
done('play', `data safety updated (${csv.split('\n').length - 2} rows)`);
}