diff --git a/test/package.test.js b/test/package.test.js index b9dbeb56..41702a2a 100644 --- a/test/package.test.js +++ b/test/package.test.js @@ -1,155 +1,268 @@ -// Player-package file selection. Dry-run only: no zip, no npm ci. +// test/package.test.js — tools/package.mjs, the player release zips (docs/DEPLOY.md §7). No network, no npm install, no +// zip of the repository: the selection rules on sample paths and on the real `git ls-files`, then the packager itself +// (--dry-run, the scans and guards, one --no-install build) on a small temporary git checkout with fake art. +// Run: node --test test/package.test.js + import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { - extraPaths, - isExcluded, - packageOutIsUnsafe, + FOLDER, REFUSE, RUNTIME_RESEARCH, entryProblems, isRefused, packageOutIsUnsafe, plan, resolveSpecifier, scanFiles, selectTracked, - textHasHomePath, - trackedFiles, } from '../tools/package.mjs'; const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TOOL = path.join(ROOT, 'tools', 'package.mjs'); +// hermetic scans: not this machine's account name, but a planted one +const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' }; -const SAMPLE = [ - 'server/index.js', - 'server/sim/rng.js', - 'shared/constants.js', - 'public/index.html', - 'public/js/main.js', - 'public/vendor/preact.mjs', - 'public/dev/game-mock.html', - 'public/assets/board.png', - 'public/fonts/fonts.css', - 'data/chess.json', - 'data/local-assets.json', - 'tools/setup.mjs', - 'tools/vendor.mjs', - 'tools/fetch-assets.mjs', - 'tools/assets/plan.mjs', - 'tools/local-extract/extract.py', - 'tools/golden.mjs', - 'tools/check-imports.mjs', - 'scripts/start.sh', - 'docs/PLAYING.md', - 'docs/DEPLOY.md', - 'docs/WINDOWS.md', - 'docs/ASSETS.md', - 'docs/img/combat.jpg', - 'docs/DESIGN.md', - 'docs/SIM.md', - 'docs/research/03-operators.json', - 'docs/research/10-networking-hosting.md', - 'test/version.test.js', - 'package.json', - 'package-lock.json', - 'LICENSE', - 'NOTICE.md', - 'THIRD-PARTY-NOTICES.md', - 'README.md', - 'CHANGELOG.md', - 'Dockerfile', - '.dockerignore', - 'AGENTS.md', - '.env', - '.env.local', - 'server/.env.production', - 'pv/clip.mp4', - '3,9,11回合情况/note.txt', - 'handoff/HANDOFF.md', - 'node_modules/ws/index.js', - '.cache/x', - 'eslint.config.js', - 'jsconfig.json', - 'types/core.js', -]; - -test('selectTracked keeps runtime files and drops the rest', () => { - const got = selectTracked(SAMPLE); - assert.deepEqual(got, selectTracked(SAMPLE, { lite: true })); - for (const rel of [ - 'server/index.js', 'tools/setup.mjs', 'tools/vendor.mjs', 'docs/PLAYING.md', - 'docs/DEPLOY.md', 'public/index.html', 'data/chess.json', 'package.json', - 'Dockerfile', 'scripts/start.sh', 'tools/assets/plan.mjs', 'tools/local-extract/extract.py', - ]) assert.ok(got.includes(rel), rel); - for (const rel of [ - 'tools/golden.mjs', 'tools/check-imports.mjs', 'test/version.test.js', 'public/dev/game-mock.html', - 'public/assets/board.png', 'public/fonts/fonts.css', 'docs/research/10-networking-hosting.md', - 'docs/research/03-operators.json', 'docs/DESIGN.md', 'docs/SIM.md', 'AGENTS.md', '.env', - '.env.local', 'server/.env.production', 'pv/clip.mp4', '3,9,11回合情况/note.txt', - 'handoff/HANDOFF.md', 'node_modules/ws/index.js', 'data/local-assets.json', 'eslint.config.js', - 'types/core.js', - ]) assert.ok(!got.includes(rel), rel); - assert.equal(isExcluded('docs/research/10-networking-hosting.md'), true); -}); - -test('lite extras skip art, fonts and the local manifest', () => { - assert.deepEqual(extraPaths({ lite: true, hasLocalManifest: true }), ['public/vendor']); - assert.deepEqual(extraPaths({ lite: false, hasLocalManifest: true }), [ - 'public/vendor', 'public/assets', 'public/fonts', 'data/local-assets.json', - ]); - assert.deepEqual(extraPaths({ lite: false, hasLocalManifest: false }), [ - 'public/vendor', 'public/assets', 'public/fonts', +test('selection: runtime files in; tests, maintainer tools, other docs, dev pages, private and config files out', () => { + const { keep, drop } = selectTracked([ + 'server/index.js', 'server/sim/rng.js', 'shared/constants.js', 'public/index.html', 'public/js/main.js', 'public/i18n/en.json', + 'data/chess.json', 'data/i18n/en.json', 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs', + 'tools/crop-board-atlas.mjs', 'tools/assets/plan.mjs', 'tools/assets/local-enemy-spines.json', 'tools/local-extract/extract.py', + 'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/start.sh', 'scripts/start-windows.bat', 'scripts/launch.mjs', + 'scripts/install-service-windows.ps1', 'docs/PLAYING.md', 'docs/DEPLOY.md', ...RUNTIME_RESEARCH, 'package.json', + 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md', + // out + 'test/version.test.js', 'tools/golden.mjs', 'tools/build-data.mjs', 'tools/check-imports.mjs', 'tools/package.mjs', + 'tools/i18n/fallback-remake.json', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'docs/WINDOWS.md', 'docs/ASSETS.md', + 'docs/img/combat.jpg', 'docs/research/00-INDEX.md', 'docs/research/01-core-data.json', 'docs/research/03-operators.md', + 'docs/research/10-networking-hosting.md', 'public/dev/game-mock.html', 'public/assets/x.png', 'public/fonts/fonts.css', + 'public/vendor/pixi.min.js', 'data/local-assets.json', 'handoff/HANDOFF.md', '.github/workflows/ci.yml', 'types/core.js', + 'eslint.config.js', 'jsconfig.json', 'Dockerfile', '.dockerignore', '.gitignore', '.gitattributes', 'AGENTS.md', '.env', + 'server/.env.production', 'pv/clip.mp4', '3,9,11回合情况/note.txt', 'review/a.md', '.cache/x', 'server/__pycache__/a.pyc', + 'public/.DS_Store', 'tools/local-extract/.venv-extract/x.py', 'node_modules/ws/index.js', ]); + for (const f of ['server/index.js', 'public/i18n/en.json', 'data/i18n/en.json', 'tools/assets/local-enemy-spines.json', + 'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'scripts/install-service-windows.ps1', 'docs/research/07-assets.json', 'CHANGELOG.md']) { + assert.ok(keep.includes(f), `keeps ${f}`); + } + assert.equal(keep.length, 34, keep.join(' ')); + assert.equal(drop.length, 40, drop.join(' ')); + assert.ok(!keep.some((f) => drop.includes(f))); }); -test('home-directory text and unsafe output directories', () => { - assert.equal(textHasHomePath('see /Users/example/game'), 'mac-users'); - assert.equal(textHasHomePath('C:\\Users\\example\\game'), 'win-users'); - assert.equal(textHasHomePath('prefix /home/example/game'), 'posix-home'); - assert.equal(textHasHomePath('https://github.com/example/game'), null); - assert.equal(packageOutIsUnsafe(ROOT, ROOT), true); - assert.equal(packageOutIsUnsafe(path.dirname(ROOT), ROOT), true); - assert.equal(packageOutIsUnsafe(path.join(ROOT, 'dist-pack'), ROOT), false); +test('refusal list: 0.1.x\'s entries and what 0.2.0 leaves out, at the path or under it; .env and .venv anywhere', () => { + for (const r of ['pv', '3,9,11回合情况', 'review', 'docs/research/10-networking-hosting.md', '.cache', '.claude', '.git', 'logs', + 'test/e2e/out', 'scripts/service.env.cmd', '.env', 'handoff', 'test', '.github']) assert.ok(REFUSE.includes(r), r); + for (const r of REFUSE) { + assert.ok(isRefused(r), r); + assert.ok(isRefused(`${r}/x`), `${r}/x`); + } + for (const p of ['data/.env', 'server/.env.local', '.venv-extract/lib/a.py', 'tools/.venv/x']) assert.ok(isRefused(p), p); + for (const p of ['server/index.js', 'docs/research/03-operators.json', 'testing.md', 'public/js/review.js']) assert.ok(!isRefused(p), p); }); -test('real git ls-files: runtime in, dev and private out', () => { - const got = new Set(selectTracked(trackedFiles(ROOT))); - assert.ok(got.has('server/index.js')); - assert.ok(got.has('tools/setup.mjs')); - assert.ok(got.has('docs/PLAYING.md')); - assert.ok(got.has('package-lock.json')); - assert.equal(got.has('tools/golden.mjs'), false); - assert.equal(got.has('tools/package.mjs'), false); - assert.equal(got.has('test/golden.test.js'), false); - assert.equal(got.has('docs/DESIGN.md'), false); - assert.equal(got.has('eslint.config.js'), false); - for (const rel of got) { - assert.equal(rel.startsWith('docs/research/'), false, rel); - assert.equal(rel.startsWith('test/'), false, rel); - assert.equal(rel.startsWith('public/dev/'), false, rel); - assert.equal(rel.startsWith('handoff/'), false, rel); +test('the real tracked tree: runtime in, the rest out; every shipped import, npm entry point, research table and spawned tool ships', () => { + const p = plan(ROOT, { lite: true, allowDirty: true, scan: false, measure: false }); + assert.deepEqual(p.problems, []); + const got = new Set(p.files); + for (const f of ['server/index.js', 'tools/setup.mjs', 'tools/fetch-assets.mjs', 'scripts/start.sh', 'docs/PLAYING.md', 'docs/DEPLOY.md', + 'package-lock.json', 'NOTICE.md', ...RUNTIME_RESEARCH]) assert.ok(got.has(f), f); + for (const f of ['tools/golden.mjs', 'tools/package.mjs', 'scripts/make-windows-bundle.mjs', 'docs/DESIGN.md', 'eslint.config.js', 'Dockerfile']) { + assert.ok(!got.has(f), f); + } + for (const f of got) assert.ok(!/^(?:test|handoff|\.github|types|public\/dev|docs\/img)\//.test(f), f); + const code = [...got].filter((f) => /\.(?:m?js|py)$/.test(f)); + for (const f of code) { + const src = fs.readFileSync(path.join(ROOT, f), 'utf8'); + // research tables named in shipped code (nodeData.js, fetch-assets.mjs, plan.mjs …) + for (const m of src.matchAll(/\b(\d\d-[a-z-]+\.json)\b/g)) assert.ok(got.has(`docs/research/${m[1]}`), `${f} names ${m[1]}`); + // files a shipped script starts or reads by path.join(ROOT, 'dir', …, 'file.ext') — setup's tools, launch's setup + for (const m of src.matchAll(/path\.join\(ROOT,\s*((?:'[^']+',\s*)*'[^']+\.[a-z]+')\)/g)) { + const rel = [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]).join('/'); + if (/^(?:tools|scripts|server|shared|docs)\//.test(rel)) assert.ok(got.has(rel), `${f} starts ${rel}`); + } } }); -test('dry-run lists the lite set and prints both size estimates', () => { - const lite = spawnSync(process.execPath, ['tools/package.mjs', '--dry-run', '--lite'], { - cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, - }); - assert.equal(lite.status, 0, lite.stderr); - assert.match(lite.stdout, /^mode: lite/m); - assert.match(lite.stdout, /^dry-run: yes/m); - assert.match(lite.stdout, /^file server\/index\.js$/m); - assert.match(lite.stdout, /^file tools\/setup\.mjs$/m); - assert.match(lite.stdout, /^file docs\/PLAYING\.md$/m); - assert.match(lite.stdout, /^extra public\/vendor$/m); - assert.doesNotMatch(lite.stdout, /^file tools\/golden\.mjs$/m); - assert.doesNotMatch(lite.stdout, /^file test\//m); - assert.doesNotMatch(lite.stdout, /^extra public\/assets$/m); - assert.match(lite.stdout, /^uncompressed lite: \d+/m); - assert.match(lite.stdout, /^uncompressed full estimate: \d+/m); - assert.match(lite.stdout, /puppeteer-core/); - const full = spawnSync(process.execPath, ['tools/package.mjs', '--dry-run'], { - cwd: ROOT, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024, - }); - assert.equal(full.status, 0, full.stderr); - assert.match(full.stdout, /^mode: full/m); - assert.match(full.stdout, /^extra public\/assets$/m); - assert.match(full.stdout, /^extra public\/fonts$/m); - const assets = Number((full.stdout.match(/^assets bytes: (\d+)/m) || [])[1]); - assert.ok(assets > 300 * 1048576, `assets bytes ${assets}`); +test('import specifiers resolve like the server mounts; npm entry points must ship', () => { + assert.equal(resolveSpecifier('public/js/ui/a.js', '../b.js'), 'public/js/b.js'); + assert.equal(resolveSpecifier('public/js/a.js', '/sim/battle.js?v=2'), 'server/sim/battle.js'); + assert.equal(resolveSpecifier('public/js/a.js', '/shared/constants.js'), 'shared/constants.js'); + assert.equal(resolveSpecifier('public/js/a.js', '/js/store.js'), 'public/js/store.js'); + assert.equal(resolveSpecifier('server/a.js', 'node:fs'), null); + assert.equal(resolveSpecifier('server/a.js', 'ws'), null); + const pkg = { main: 'server/index.js', scripts: { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/golden.mjs', + launch: 'node scripts/launch.mjs', postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs' } }; + const shipped = ['server/index.js', 'tools/setup.mjs', 'scripts/launch.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs']; + assert.deepEqual(entryProblems(pkg, shipped), ['npm run doctor: tools/golden.mjs is not shipped']); + assert.deepEqual(entryProblems({ ...pkg, scripts: { ...pkg.scripts, doctor: undefined } }, shipped), ['npm script "doctor" is missing']); +}); + +test('output directory: never the repository, inside it or a parent of it', () => { + assert.equal(packageOutIsUnsafe(ROOT, ROOT), true); + assert.equal(packageOutIsUnsafe(path.dirname(ROOT), ROOT), true); + assert.equal(packageOutIsUnsafe(path.join(ROOT, 'dist'), ROOT), true); + assert.equal(packageOutIsUnsafe(path.join(os.tmpdir(), 'sp-release'), ROOT), false); +}); + +// --------------------------------------------------------------------------------------------------------------------- +// A temporary checkout: tracked runtime and non-runtime files, git-ignored fake art (one listed file with a URL-encoded +// name, an orphan like 焰狐龙梓兰's old files, the local-client extraction, fonts, OS clutter), private files. + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]); + +function fakeCheckout() { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-test-')); + const put = (rel, body = `// ${rel}\n`) => { + fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true }); + fs.writeFileSync(path.join(dir, rel), body); + }; + const scripts = { start: 'node server/index.js', setup: 'node tools/setup.mjs', doctor: 'node tools/doctor.mjs', launch: 'node scripts/launch.mjs', + postinstall: 'node tools/vendor.mjs', vendor: 'node tools/vendor.mjs', assets: 'node tools/vendor.mjs && node tools/fetch-assets.mjs', + test: 'node --test', golden: 'node tools/golden.mjs', package: 'node tools/package.mjs' }; + put('package.json', JSON.stringify({ name: 'sp-test', version: '9.9.9', main: 'server/index.js', scripts, dependencies: {} })); + put('package-lock.json', JSON.stringify({ name: 'sp-test', version: '9.9.9', lockfileVersion: 3, packages: { '': { name: 'sp-test' } } })); + put('server/index.js', "import './sim/rng.js';\n"); + put('server/sim/rng.js'); + put('shared/constants.js'); + put('public/index.html', '\n'); + put('public/js/main.js', "import { u } from './util.js';\nimport { h } from '../vendor/preact.module.js';\n"); + put('public/js/util.js', 'export const u = 1;\n'); + put('public/dev/mock.js'); + put('data/chess.json', '{}\n'); + put('data/assets.json', JSON.stringify({ chars: { a: { avatar: '/assets/char/a.png' } }, ui: { b: '/assets/ui/b%20c.png' }, fonts: { css: '/fonts/fonts.css' } })); + put('tools/setup.mjs', "import { n } from './assets/network.mjs';\n"); + for (const f of ['tools/assets/network.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs', 'tools/crop-board-atlas.mjs', + 'tools/local-extract/extract.py', 'tools/golden.mjs', 'tools/build-data.mjs', 'tools/package.mjs', 'scripts/launch.mjs', + 'scripts/make-windows-bundle.mjs', 'test/a.test.js', 'handoff/HANDOFF.md', '.github/workflows/ci.yml', 'types/core.js', 'eslint.config.js', + 'Dockerfile', 'AGENTS.md', 'review/notes.md', 'docs/DESIGN.md', 'docs/research/00-INDEX.md', 'docs/research/10-networking-hosting.md']) put(f); + put('scripts/start.sh', '#!/usr/bin/env bash\nexec node scripts/launch.mjs\n'); + fs.chmodSync(path.join(dir, 'scripts/start.sh'), 0o755); + for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'docs/PLAYING.md', 'docs/DEPLOY.md']) put(f, `# ${f}\n`); + for (const f of [...RUNTIME_RESEARCH, 'docs/research/01-core-data.json']) put(f, '{}\n'); + put('docs/img/x.jpg', PNG); + put('.gitignore', 'public/assets/\npublic/fonts/\npublic/vendor/\ndata/local-assets.json\n.cache/\npv/\n'); + // git-ignored: the art, the local extraction, fonts, caches, the promo folder + put('public/assets/char/a.png', PNG); + put('public/assets/ui/b c.png', PNG); + put('public/assets/char/char_1048_orchd2.png', PNG); + put('public/assets/local/g/x.webp', PNG); + put('public/assets/local/g/x.png', PNG); + put('public/assets/.DS_Store', 'junk'); + put('public/fonts/fonts.css', '@font-face{}\n'); + put('public/fonts/x.woff2', PNG); + put('data/local-assets.json', JSON.stringify({ version: 1, groups: { g: { x: { path: '/assets/local/g/x.webp' } } } })); + put('.cache/assets-report.json', '{}'); + put('pv/clip.txt', 'promo'); + const git = (...args) => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' }); + assert.equal(git('init', '-q').status, 0); + assert.equal(git('add', '-A').status, 0); + return { dir, put, rm: (rel) => fs.rmSync(path.join(dir, rel), { force: true }) }; +} + +const runTool = (args) => spawnSync(process.execPath, [TOOL, ...args], { encoding: 'utf8', env: { ...process.env, ...ENV }, maxBuffer: 16 * 1024 * 1024 }); +const listed = (stdout) => stdout.split('\n').filter((l) => l.startsWith('file ')).map((l) => l.slice(5)); + +const SHIPPED_TRACKED = ['CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'README.md', 'THIRD-PARTY-NOTICES.md', 'data/assets.json', 'data/chess.json', + 'docs/DEPLOY.md', 'docs/PLAYING.md', ...RUNTIME_RESEARCH, 'package-lock.json', 'package.json', 'public/index.html', 'public/js/main.js', + 'public/js/util.js', 'scripts/launch.mjs', 'scripts/start.sh', 'server/index.js', 'server/sim/rng.js', 'shared/constants.js', + 'tools/assets/network.mjs', 'tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/local-extract/extract.py', + 'tools/setup.mjs', 'tools/vendor.mjs'].sort(); +const ART = ['data/local-assets.json', 'public/assets/char/a.png', 'public/assets/local/g/x.png', 'public/assets/local/g/x.webp', + 'public/assets/ui/b c.png', 'public/fonts/fonts.css', 'public/fonts/x.woff2']; + +test('dry-run on a temporary checkout: the full and the lite file lists, the summary, nothing written', () => { + const { dir } = fakeCheckout(); + try { + const before = fs.readdirSync(dir).sort(); + const full = runTool(['--root', dir, '--dry-run', '--list', '--allow-dirty']); + assert.equal(full.status, 0, full.stdout + full.stderr); + assert.deepEqual(listed(full.stdout).sort(), [...SHIPPED_TRACKED, ...ART].sort()); + assert.match(full.stdout, /^package: full · version 9\.9\.9$/m); + assert.match(full.stdout, /^left out art: 1 files under public\/assets that nothing lists/m, 'the orphan (an excluded operator\'s old file)'); + assert.match(full.stdout, /^left out: .*test\/ 1 .*$/m); + assert.match(full.stdout, /^checks: ok$/m); + const lite = runTool(['--root', dir, '--dry-run', '--list', '--lite', '--allow-dirty']); + assert.equal(lite.status, 0, lite.stdout + lite.stderr); + assert.deepEqual(listed(lite.stdout), SHIPPED_TRACKED, 'lite: the same runtime files, no art, fonts or local manifest'); + assert.match(lite.stdout, /^package: lite \(no game art\)/m); + assert.deepEqual(fs.readdirSync(dir).sort(), before, 'a dry-run writes nothing'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('scans and guards: home paths and the account name in shipped files (art too), missing art, a stray import, uncommitted files', () => { + const { dir, put, rm } = fakeCheckout(); + const check = (opts = {}) => plan(dir, { allowDirty: true, env: ENV, ...opts }).problems; + try { + assert.deepEqual(check(), []); + assert.deepEqual(check({ lite: true }), []); + put('public/js/util.js', 'export const u = "/Users/someone/game";\n'); + assert.deepEqual(check(), ['personal info: public/js/util.js (home-directory path)']); + put('public/js/util.js', 'export const u = 1;\n'); + put('data/chess.json', '{"p": "C:\\\\Users\\\\Someone\\\\x"}\n'); + assert.deepEqual(check({ lite: true }), ['personal info: data/chess.json (home-directory path)'], 'Windows path, JSON-escaped'); + put('data/chess.json', '{}\n'); + put('public/assets/char/a.png', Buffer.concat([PNG, Buffer.from('..PlantedName..')])); + assert.deepEqual(check(), ['personal info: public/assets/char/a.png (account name)'], 'binary art, any case'); + assert.deepEqual(check({ lite: true }), [], 'the lite zip has no art'); + put('public/assets/char/a.png', PNG); + put('test/a.test.js', '// /Users/someone plantedname\n'); + assert.deepEqual(check(), [], 'files that never ship are not scanned'); + rm('public/assets/ui/b c.png'); + assert.match(check().join('\n'), /^missing art: public\/assets\/ui\/b c\.png/); + assert.deepEqual(check({ lite: true }), []); + put('public/assets/ui/b c.png', PNG); + put('public/js/main.js', "import { u } from './util.js';\nimport '../dev/mock.js';\n"); + assert.deepEqual(check({ lite: true }), ['public/js/main.js:2 imports ../dev/mock.js (not shipped)']); + assert.ok(plan(dir, { lite: true, env: ENV, scan: false }).problems.some((x) => x.startsWith('uncommitted change: server/index.js')), + 'staged, never committed: refused without --allow-dirty'); + const cli = runTool(['--root', dir, '--dry-run', '--lite', '--allow-dirty']); + assert.equal(cli.status, 1, 'a problem fails the dry-run'); + assert.match(cli.stdout, /^PROBLEMS \(1\):\n {2}public\/js\/main\.js:2 imports \.\.\/dev\/mock\.js/m); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +// tools/package.mjs zips with `zip`, else with a bsdtar `tar --format zip` (Windows 10+, macOS; GNU tar cannot) +const hasZipTool = !spawnSync('zip', ['-v'], { encoding: 'utf8' }).error + || /bsdtar/.test(spawnSync('tar', ['--version'], { encoding: 'utf8' }).stdout || ''); +const hasUnzip = !spawnSync('unzip', ['-h'], { encoding: 'utf8' }).error; + +test('a --no-install build of the temporary checkout zips exactly the plan in one Stronghold-Protocol/ folder', { skip: !hasZipTool && 'no zip / tar' }, () => { + const { dir } = fakeCheckout(); + const out = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-out-')); + try { + const r = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']); + assert.equal(r.status, 0, r.stdout + r.stderr); + const zip = path.join(out, `${FOLDER}-v9.9.9.zip`); + assert.ok(fs.statSync(zip).size > 0); + assert.deepEqual(fs.readdirSync(out), [path.basename(zip)], 'the stage is removed after zipping'); + if (hasUnzip) { + const entries = spawnSync('unzip', ['-Z1', zip], { encoding: 'utf8' }).stdout.split('\n').filter((l) => l && !l.endsWith('/')); + assert.deepEqual(entries.sort(), [...SHIPPED_TRACKED, ...ART].map((f) => `${FOLDER}/${f}`).sort()); + } + const again = runTool(['--root', dir, '--out', out, '--no-install', '--allow-dirty']); + assert.equal(again.status, 1, 'an existing zip is not replaced without --force'); + assert.match(again.stderr, /already exists \(--force replaces it\)/); + const inRepo = runTool(['--root', dir, '--out', path.join(dir, 'dist'), '--no-install', '--allow-dirty', '--lite']); + assert.equal(inRepo.status, 1); + assert.match(inRepo.stderr, /refusing to write into the repository/); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(out, { recursive: true, force: true }); + } +}); + +test('the personal scan reads bytes, never reports the matched text, and skips files it cannot read', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-package-scan-')); + try { + fs.writeFileSync(path.join(dir, 'a.bin'), Buffer.from([0, 1, 2, ...Buffer.from('/home/someone'), 3])); + fs.writeFileSync(path.join(dir, 'b.txt'), 'see https://github.com/users/x and /usr/lib'); + fs.writeFileSync(path.join(dir, 'c.txt'), 'Built by PLANTEDNAME'); + assert.deepEqual(scanFiles(dir, ['a.bin', 'b.txt', 'c.txt', 'missing.txt'], { names: ['plantedname'] }), + ['a.bin (home-directory path)', 'c.txt (account name)']); + assert.deepEqual(scanFiles(dir, ['a.bin', 'c.txt'], { home: false, names: [] }), [], 'node_modules: the names only'); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } }); diff --git a/tools/package.mjs b/tools/package.mjs index 3359c8d6..bf85a76f 100644 --- a/tools/package.mjs +++ b/tools/package.mjs @@ -1,343 +1,505 @@ -// Player package: git-tracked runtime files, production dependencies, and (unless --lite) local art. +#!/usr/bin/env node +// tools/package.mjs — the two player release zips (docs/DEPLOY.md §7; README「方式一」says which one to pick). // -// scripts/make-windows-bundle.mjs is the Windows portable-Node pack. This tool is the release zip: -// a runtime allowlist, not the whole tree. The Docker image still copies docs/research as a sim -// fallback; this zip does not. Players on --lite run `npm run setup` to download art and fonts. +// npm run package → Stronghold-Protocol-v.zip full: what runs the game + the game art +// npm run package:lite → Stronghold-Protocol-v-lite.zip the same without the art (`npm run setup` +// downloads it on the first start) +// node tools/package.mjs [--lite] [--dry-run [--list]] [--out ] [--force] [--keep-stage] [--no-install] +// [--allow-dirty] [--root ] // -// node tools/package.mjs [--dry-run] [--lite] [--out ] [--no-install] +// Both zips hold one folder, Stronghold-Protocol/, with only what a player runs — an allowlist over `git ls-files`, so +// untracked work, caches, logs and per-machine config never get in: server/, shared/, data/, public/ (not public/dev/), +// the start scripts, the tools a player runs (setup, vendor = the postinstall, fetch-assets + tools/assets, doctor, and +// what setup starts: tools/local-extract, crop-board-atlas), the research tables the Node server (server/sim/ +// nodeData.js fallback) and fetch-assets read, package.json / package-lock.json, LICENSE / NOTICE.md / +// THIRD-PARTY-NOTICES.md, README.md, CHANGELOG.md, docs/PLAYING.md and docs/DEPLOY.md. `npm ci --omit=dev` in the +// stage adds the production node_modules and (postinstall) public/vendor. The full zip adds the git-ignored art: the +// files data/assets.json lists, public/fonts, and the local-client extraction (public/assets/local/, +// data/local-assets.json) when present — nothing else on disk, so art the data no longer lists (焰狐龙梓兰, left out of +// 自选 in 0.2.0, or files of an old mapping) never ships even when this machine still has it. +// Left out: test/, the maintainer tools (build-data, golden, botbench, i18n, check-imports, this file …), +// scripts/make-windows-bundle.mjs, the other docs (DESIGN, SIM, the research notes, docs/img …), public/dev/, handoff/, +// .github/, types/, lint / editor / Docker files (Docker builds from a git clone). // -// --dry-run prints the file list and a size summary and writes nothing. -// A real full zip is large because public/assets is included. Do not point --out at the repo. +// Checks, all of them in --dry-run too (which writes nothing): the refusal list below; every relative import of a +// shipped module and every `node ` of the player npm scripts resolves to a shipped file; a full zip has every file +// data/assets.json and data/local-assets.json list; no shipped file carries a home-directory path (/Users/…, +// C:\Users\…, /home/…) or this machine's account name (text and binary, art included; node_modules only for the name); +// no shipped tracked file has an uncommitted change (--allow-dirty skips that). A build also checks that the stage holds +// exactly the plan before zipping. +// The account name comes from the OS at run time (never written in the repository): SP_PACKAGE_SCAN_USER=0 skips it +// (a name that is a common word), SP_PACKAGE_SCAN_NAMES=a,b adds more names to refuse. +// +// --out defaults to /stronghold-protocol-release and must be outside the repository. --no-install skips npm ci +// (no node_modules, no public/vendor: a test build). Zipping needs `zip` (or a bsdtar `tar`, e.g. Windows 10+). import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { findSpecifiers } from './check-imports.mjs'; -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +/** The one folder inside both zips (DEPLOY §1.1: "解压后把里面的 Stronghold-Protocol 文件夹放到…"). */ +export const FOLDER = 'Stronghold-Protocol'; -const ROOT_FILES = new Set([ - 'package.json', 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', - 'README.md', 'CHANGELOG.md', 'Dockerfile', '.dockerignore', -]); +/** Root files a player gets. */ +export const ROOT_FILES = ['package.json', 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md', 'README.md', 'CHANGELOG.md']; +/** The player docs. */ +export const PLAYER_DOCS = ['docs/PLAYING.md', 'docs/DEPLOY.md']; +/** Research tables read at run time: server/sim/nodeData.js (the Node sim's fallback) and tools/fetch-assets.mjs. */ +export const RUNTIME_RESEARCH = ['docs/research/03-operators.json', 'docs/research/05-enemies.json', 'docs/research/05-maps.json', 'docs/research/07-assets.json']; +/** The start scripts (scripts/make-windows-bundle.mjs is the maintainer's Windows pack, docs/WINDOWS.md). */ +export const PLAYER_SCRIPTS = ['scripts/install-service-windows.ps1', 'scripts/launch.mjs', 'scripts/open-browser.mjs', + 'scripts/run-server.cmd', 'scripts/start-windows.bat', 'scripts/start-windows.ps1', 'scripts/start.sh']; +/** The tools a player runs (npm run setup / doctor / assets, the postinstall) and the ones setup starts. */ +export const PLAYER_TOOLS = ['tools/crop-board-atlas.mjs', 'tools/doctor.mjs', 'tools/fetch-assets.mjs', 'tools/setup.mjs', 'tools/vendor.mjs']; +/** Whole tool directories: fetch-assets' modules, the local-client extraction setup runs. */ +export const PLAYER_TOOL_DIRS = ['tools/assets/', 'tools/local-extract/']; +/** Whole runtime directories (their tracked files). */ +export const RUNTIME_DIRS = ['server/', 'shared/', 'data/', 'public/']; +/** Never from the tracked list: the dev pages, and what only the art plan adds (or npm ci writes). */ +const NOT_TRACKED_SHIP = ['public/dev/', 'public/assets/', 'public/fonts/', 'public/vendor/']; +/** The npm scripts a player runs: each `node ` of them must ship. */ +export const PLAYER_NPM_SCRIPTS = ['start', 'setup', 'doctor', 'launch', 'postinstall', 'vendor', 'assets']; -const PLAYER_DOCS = new Set([ - 'docs/PLAYING.md', 'docs/DEPLOY.md', 'docs/WINDOWS.md', 'docs/ASSETS.md', -]); +/** + * Refused in a plan and in a stage: 0.1.x's list (owner-only notes, the promo project, caches, per-machine config) and + * what 0.2.0 leaves out on purpose. A path is refused when it is one of these or lies under one. + */ +export const REFUSE = ['pv', '3,9,11回合情况', 'review', 'docs/research/10-networking-hosting.md', '.cache', '.claude', '.git', + 'logs', 'test/e2e/out', 'scripts/service.env.cmd', '.env', 'handoff', + 'test', '.github', 'AGENTS.md', 'public/dev', 'node_modules/.cache']; -const RUNTIME_TOOLS = new Set([ - 'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', - 'tools/doctor.mjs', 'tools/crop-board-atlas.mjs', -]); +/** Home-directory paths: macOS / Linux (case as the OS writes them) and Windows (any case; / or \, JSON-escaped too). */ +const HOME_POSIX = /\/Users\/[A-Za-z]|\/home\/[A-Za-z]/; +const HOME_WIN = /[A-Za-z]:[\\/]{1,2}Users[\\/]{1,2}[A-Za-z]/i; +/** OS account names too generic to refuse (CI runners, containers). */ +const GENERIC_USERS = new Set(['root', 'user', 'admin', 'node', 'runner', 'ubuntu', 'ci', 'guest', 'app', 'build']); -const DROP_PREFIXES = [ - 'test/', 'public/dev/', 'public/assets/', 'public/fonts/', 'docs/research/', - 'handoff/', 'node_modules/', '.cache/', 'pv/', '3,9,11回合情况/', -]; +export const posixRel = (rel) => String(rel).split('\\').join('/'); -const TEXT_EXT = new Set([ - '.js', '.mjs', '.cjs', '.json', '.md', '.html', '.css', '.txt', '.yml', '.yaml', - '.ps1', '.sh', '.cmd', '.bat', '.py', '.svg', -]); - -const HOME_PATTERNS = [ - { name: 'mac-users', re: /\/Users\// }, - { name: 'win-users', re: /[A-Za-z]:\\Users\\/ }, - { name: 'posix-home', re: /\/home\// }, -]; - -export function posixRel(rel) { - return String(rel).split('\\').join('/'); -} - -/** True when the path must never ship, even if an allow rule would match. */ -export function isExcluded(rel) { +/** True for a path the refusal list names (or lies under), a `.env` file or a `.venv*` directory anywhere. */ +export function isRefused(rel) { const p = posixRel(rel); - const base = p.slice(p.lastIndexOf('/') + 1); + const parts = p.split('/'); + const base = parts[parts.length - 1]; if (base === '.env' || base.startsWith('.env.')) return true; - if (p === 'AGENTS.md' || p === 'docs/research/10-networking-hosting.md') return true; - return DROP_PREFIXES.some((pre) => p === pre.slice(0, -1) || p.startsWith(pre)); + if (parts.some((s) => s.startsWith('.venv'))) return true; + return REFUSE.some((r) => p === r || p.startsWith(`${r}/`)); } -/** Git-tracked paths that belong in the player package. `lite` does not change this set. */ -export function selectTracked(paths, _opts = {}) { - const out = []; - for (const raw of paths) { - const rel = posixRel(raw); - if (!rel || isExcluded(rel)) continue; - if (allowed(rel)) out.push(rel); - } - out.sort(); - return out; +/** OS / editor / Python clutter that never ships (the old rsync excludes). */ +export function isJunk(rel) { + const parts = posixRel(rel).split('/'); + const base = parts[parts.length - 1]; + if (parts.includes('__pycache__')) return true; + if (base === '.DS_Store' || base === 'Thumbs.db' || base === 'desktop.ini' || base.startsWith('._')) return true; + return /\.(?:py[cod]|log|tmp|swp)$/i.test(base); } -function allowed(rel) { - if (rel.startsWith('server/') || rel.startsWith('shared/')) return true; - if (rel.startsWith('public/')) return true; - if (rel.startsWith('data/')) return rel !== 'data/local-assets.json'; - if (rel.startsWith('scripts/')) return true; - if (RUNTIME_TOOLS.has(rel)) return true; - if (rel.startsWith('tools/assets/') || rel.startsWith('tools/local-extract/')) return true; - if (PLAYER_DOCS.has(rel) || rel.startsWith('docs/img/')) return true; - return ROOT_FILES.has(rel); -} - -/** Directories and files copied in addition to the git allowlist. */ -export function extraPaths({ lite, hasLocalManifest }) { - const paths = ['public/vendor']; - if (!lite) { - paths.push('public/assets', 'public/fonts'); - if (hasLocalManifest) paths.push('data/local-assets.json'); - } - return paths; -} - -export function textHasHomePath(text) { - for (const { name, re } of HOME_PATTERNS) { - if (re.test(text)) return name; - } - return null; -} - -/** True when `out` is the repo or a parent of it (a wipe there would take the repo with it). */ -export function packageOutIsUnsafe(out, root = ROOT) { - const r = path.resolve(root); - const o = path.resolve(out); - if (o === r) return true; - const rel = path.relative(o, r); - return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); -} - -export function trackedFiles(root = ROOT) { - const r = spawnSync('git', ['-C', root, 'ls-files', '-z'], { maxBuffer: 32 * 1024 * 1024 }); - if (r.error || r.status !== 0) { - throw new Error('git ls-files failed; package only tracked runtime files, so run it inside the repo'); - } - return r.stdout.toString('utf8').split('\0').filter(Boolean); -} - -function fileBytes(abs) { - try { - const st = fs.statSync(abs); - return st.isFile() ? st.size : 0; - } catch { - return 0; - } -} - -/** Bytes under abs, following symlinks. Skips nested node_modules and .cache. */ -export function treeBytes(abs) { - let total = 0; - const seen = new Set(); - function walk(dir) { - let real; - try { real = fs.realpathSync(dir); } catch { return; } - if (seen.has(real)) return; - seen.add(real); - let entries; - try { entries = fs.readdirSync(real, { withFileTypes: true }); } catch { return; } - for (const ent of entries) { - if (ent.name === 'node_modules' || ent.name === '.cache') continue; - const p = path.join(real, ent.name); - let st; - try { st = fs.statSync(p); } catch { continue; } - if (st.isDirectory()) walk(p); - else if (st.isFile()) total += st.size; - } - } - try { - const st = fs.statSync(abs); - if (st.isFile()) return st.size; - if (st.isDirectory()) walk(abs); - } catch { - return 0; - } - return total; -} - -/** Uncompressed size of production lockfile entries present under root/node_modules. Dev entries are skipped. */ -export function prodModulesBytes(root, lock) { - let total = 0; - const packages = lock?.packages || {}; - for (const [key, entry] of Object.entries(packages)) { - if (!key || entry.dev) continue; - total += treeBytes(path.join(root, key)); - } - return total; -} - -function exists(root, rel) { - try { return fs.statSync(path.join(root, rel)).isFile() || fs.statSync(path.join(root, rel)).isDirectory(); } - catch { return false; } +/** Whether a tracked file belongs in the player package. */ +export function isPlayerFile(rel) { + const p = posixRel(rel); + if (!p || isRefused(p) || isJunk(p) || p === 'data/local-assets.json') return false; + if (NOT_TRACKED_SHIP.some((d) => p.startsWith(d))) return false; + if (RUNTIME_DIRS.some((d) => p.startsWith(d)) || PLAYER_TOOL_DIRS.some((d) => p.startsWith(d))) return true; + return ROOT_FILES.includes(p) || PLAYER_DOCS.includes(p) || RUNTIME_RESEARCH.includes(p) || PLAYER_SCRIPTS.includes(p) || PLAYER_TOOLS.includes(p); } /** - * Selection plus uncompressed byte counts. Does not copy or install. - * @param {string} root - * @param {{ lite?: boolean, paths?: string[] }} [opts] + * Split tracked paths into what ships and what is left out (both sorted). + * @param {string[]} paths + * @returns {{ keep: string[], drop: string[] }} */ -export function measure(root, opts = {}) { - const lite = !!opts.lite; - const paths = opts.paths || trackedFiles(root); - const tracked = selectTracked(paths, { lite }); - let trackedBytes = 0; - for (const rel of tracked) trackedBytes += fileBytes(path.join(root, rel)); - const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); - const lock = JSON.parse(fs.readFileSync(path.join(root, 'package-lock.json'), 'utf8')); - const hasLocal = exists(root, 'data/local-assets.json'); - const vendorBytes = treeBytes(path.join(root, 'public', 'vendor')); - const fontsBytes = treeBytes(path.join(root, 'public', 'fonts')); - const assetsBytes = treeBytes(path.join(root, 'public', 'assets')); - const manifestBytes = hasLocal ? fileBytes(path.join(root, 'data', 'local-assets.json')) : 0; - const prodBytes = prodModulesBytes(root, lock); - const liteBytes = trackedBytes + vendorBytes + prodBytes; - const fullBytes = liteBytes + fontsBytes + assetsBytes + manifestBytes; - return { - version: pkg.version, - lite, - tracked, - extras: extraPaths({ lite, hasLocalManifest: hasLocal }), - trackedBytes, - vendorBytes, - fontsBytes, - assetsBytes, - manifestBytes, - prodBytes, - liteBytes, - fullBytes, - prodDeps: Object.keys(pkg.dependencies || {}).sort(), - devDeps: Object.keys(pkg.devDependencies || {}).sort(), - assetsPresent: exists(root, 'public/assets'), - fontsPresent: exists(root, 'public/fonts'), - vendorPresent: exists(root, 'public/vendor'), - }; +export function selectTracked(paths) { + const keep = []; + const drop = []; + for (const raw of paths) { + const p = posixRel(raw); + if (p) (isPlayerFile(p) ? keep : drop).push(p); + } + return { keep: keep.sort(), drop: drop.sort() }; } -export function formatSummary(m) { - const mb = (n) => `${(n / 1048576).toFixed(1)} MB`; - const lines = [ - `mode: ${m.lite ? 'lite' : 'full'}`, - `version: ${m.version}`, - `tracked files: ${m.tracked.length}`, - `tracked bytes: ${m.trackedBytes}`, - `vendor bytes: ${m.vendorBytes}${m.vendorPresent ? '' : ' (absent)'}`, - `fonts bytes: ${m.fontsBytes}${m.lite ? ' (omitted in lite)' : ''}`, - `assets bytes: ${m.assetsBytes}${m.lite ? ' (omitted in lite)' : ''}`, - `local manifest bytes: ${m.manifestBytes}${m.lite ? ' (omitted in lite)' : ''}`, - `production node_modules estimate: ${m.prodBytes}`, - `uncompressed lite: ${m.liteBytes} (${mb(m.liteBytes)})`, - `uncompressed full estimate: ${m.fullBytes} (${mb(m.fullBytes)})`, - `production dependencies: ${m.prodDeps.join(' ')}`, - `omitted devDependencies: ${m.devDeps.join(' ')}`, - ]; - return lines.join('\n') + '\n'; +export function trackedFiles(root) { + const r = spawnSync('git', ['-C', root, 'ls-files', '-z'], { maxBuffer: 64 * 1024 * 1024 }); + if (r.error || r.status !== 0) throw new Error('git ls-files failed: the package takes tracked files only, run it in a git checkout'); + return r.stdout.toString('utf8').split('\0').filter(Boolean); } -function copyRel(root, stage, rel) { - const src = path.join(root, rel); - const dest = path.join(stage, rel); - if (!fs.existsSync(src)) throw new Error(`missing ${rel}`); - fs.mkdirSync(path.dirname(dest), { recursive: true }); - fs.cpSync(src, dest, { dereference: true, recursive: true }); +/** Tracked files of `files` with uncommitted changes (staged or not). */ +export function dirtyFiles(root, files) { + const r = spawnSync('git', ['-C', root, 'status', '--porcelain=v1', '-z', '--untracked-files=no'], { maxBuffer: 64 * 1024 * 1024 }); + if (r.error || r.status !== 0) throw new Error('git status failed'); + const want = new Set(files); + const out = []; + const parts = r.stdout.toString('utf8').split('\0'); + for (let i = 0; i < parts.length; i++) { + const e = parts[i]; + if (e.length < 4) continue; + if (e[0] === 'R' || e[0] === 'C') i++; // the rename's source follows + const p = e.slice(3); + if (want.has(p)) out.push(p); + } + return out.sort(); } -function scanStage(stage) { - const hits = []; - function walk(dir, rel) { +const isFile = (abs) => { try { return fs.statSync(abs).isFile(); } catch { return false; } }; +const fileBytes = (abs) => { try { return fs.statSync(abs).size; } catch { return 0; } }; +const readJson = (abs) => { try { return JSON.parse(fs.readFileSync(abs, 'utf8')); } catch { return null; } }; + +/** Files under root/rel (following symlinks), as posix paths relative to root; junk left out unless `junk`. */ +export function listTree(root, rel, { junk = false } = {}) { + const out = []; + const walk = (abs, r) => { let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } - for (const ent of entries) { - const child = rel ? `${rel}/${ent.name}` : ent.name; - if (child === 'node_modules' || child.startsWith('node_modules/') || child.startsWith('public/assets') || child.startsWith('public/fonts')) continue; - const abs = path.join(dir, ent.name); - if (ent.isDirectory()) { walk(abs, child); continue; } - if (!ent.isFile()) continue; - const ext = path.extname(ent.name).toLowerCase(); - const named = ent.name === 'Dockerfile' || ent.name === '.dockerignore'; - if (!named && !TEXT_EXT.has(ext)) continue; - let text; - try { text = fs.readFileSync(abs, 'utf8'); } catch { continue; } - const hit = textHasHomePath(text); - if (hit) hits.push(`${child} (${hit})`); + try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; } + for (const e of entries) { + const child = r ? `${r}/${e.name}` : e.name; + if (!junk && isJunk(child)) continue; + const a = path.join(abs, e.name); + let st; + try { st = fs.statSync(a); } catch { continue; } + if (st.isDirectory()) walk(a, child); + else if (st.isFile()) out.push(child); + } + }; + walk(path.join(root, rel), posixRel(rel)); + return out.sort(); +} + +/** The '/assets/…' and '/fonts/…' URLs of a manifest, as public/… paths (the walk of tools/setup.mjs checkAssets). */ +export function manifestFiles(manifest) { + const out = new Set(); + const walk = (v) => { + if (typeof v === 'string') { + if (/^\/(?:assets|fonts)\//.test(v)) out.add(`public/${v.split('/').filter(Boolean).map(decodeURIComponent).join('/')}`); + } else if (v && typeof v === 'object') for (const x of Object.values(v)) walk(x); + }; + walk(manifest); + return out; +} + +/** + * The art of a full package: every file data/assets.json lists, public/fonts, public/assets/local/ and + * data/local-assets.json (when present). `missing`: listed files not on disk; `orphans`: files under public/assets + * that nothing lists (left out). + */ +export function artPlan(root, { lite = false } = {}) { + if (lite) return { files: [], missing: [], orphans: [], local: false }; + const listed = manifestFiles(readJson(path.join(root, 'data', 'assets.json')) ?? {}); + const local = isFile(path.join(root, 'data', 'local-assets.json')); + if (local) for (const f of manifestFiles(readJson(path.join(root, 'data', 'local-assets.json')) ?? {})) listed.add(f); + const files = new Set(); + const missing = []; + for (const f of listed) { + if (isFile(path.join(root, f))) files.add(f); + else missing.push(f); + } + for (const f of listTree(root, 'public/fonts')) files.add(f); + if (local) { + for (const f of listTree(root, 'public/assets/local')) files.add(f); + files.add('data/local-assets.json'); + } + // without case: on Windows / macOS a file whose name differs only in case is the listed one (fetch-assets orphanFiles) + const lower = new Set([...files].map((f) => f.toLowerCase())); + const orphans = listTree(root, 'public/assets').filter((f) => !lower.has(f.toLowerCase())); + return { files: [...files].sort(), missing: missing.sort(), orphans, local }; +} + +const URL_MOUNTS = [['/data/', 'data/'], ['/shared/', 'shared/'], ['/sim/', 'server/sim/']]; + +/** The repository path an import specifier of `fromRel` names, or null (an npm package or a Node builtin). */ +export function resolveSpecifier(fromRel, spec) { + const s = spec.replace(/[?#].*$/, ''); + if (s.startsWith('./') || s.startsWith('../')) return path.posix.normalize(path.posix.join(path.posix.dirname(fromRel), s)); + if (!s.startsWith('/')) return null; + for (const [prefix, dir] of URL_MOUNTS) if (s.startsWith(prefix)) return dir + s.slice(prefix.length); + return `public${s}`; // the server's static mounts (server/http/static.js) +} + +/** + * Imports of shipped modules that name a repository file the package lacks. public/vendor/ is npm ci's postinstall + * output; a specifier naming no file here (an import inside a template string, e.g. server/http/static.js's /data.js + * shim) is not an import of the package. + */ +export function importProblems(root, files) { + const shipped = new Set(files); + const out = []; + for (const rel of files) { + if (!/\.(?:m?js|cjs)$/.test(rel)) continue; + let src; + try { src = fs.readFileSync(path.join(root, rel), 'utf8'); } catch { continue; } + for (const { spec, line } of findSpecifiers(src)) { + const target = resolveSpecifier(rel, spec); + if (!target || shipped.has(target) || target.startsWith('public/vendor/') || !isFile(path.join(root, target))) continue; + out.push(`${rel}:${line} imports ${spec} (not shipped)`); } } - walk(stage, ''); + return out; +} + +/** The player npm scripts (and `main`) must start shipped files. */ +export function entryProblems(pkg, files) { + const shipped = new Set(files); + const out = []; + if (pkg?.main && !shipped.has(pkg.main)) out.push(`package.json main ${pkg.main} is not shipped`); + for (const name of PLAYER_NPM_SCRIPTS) { + const cmd = pkg?.scripts?.[name]; + if (!cmd) { out.push(`npm script "${name}" is missing`); continue; } + for (const m of cmd.matchAll(/\bnode\s+(?:--[\w-]+\s+)*([\w./-]+\.m?js)\b/g)) { + if (!shipped.has(m[1])) out.push(`npm run ${name}: ${m[1]} is not shipped`); + } + } + return out; +} + +/** + * Names refused in the personal scan: this machine's account name (unless SP_PACKAGE_SCAN_USER=0, a generic or short + * one) and SP_PACKAGE_SCAN_NAMES (comma-separated). + */ +export function scanNames(env = process.env) { + const names = []; + if (env.SP_PACKAGE_SCAN_USER !== '0') { + let user = ''; + try { user = os.userInfo().username; } catch { /* no passwd entry */ } + if (user.length >= 3 && !GENERIC_USERS.has(user.toLowerCase())) names.push(user); + } + for (const n of String(env.SP_PACKAGE_SCAN_NAMES || '').split(',')) if (n.trim().length >= 3) names.push(n.trim()); + return [...new Set(names)]; +} + +const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** + * Files whose bytes (read as latin1, so binaries are scanned too) carry a home-directory path (`home`) or one of + * `names` (case-insensitive). Returns "path (what)" lines, never the matched text. + */ +export function scanFiles(root, files, { home = true, names = [] } = {}) { + const nameRe = names.length ? new RegExp(names.map((n) => escapeRe(Buffer.from(n, 'utf8').toString('latin1'))).join('|'), 'i') : null; + const hits = []; + for (const rel of files) { + let text; + try { text = fs.readFileSync(path.join(root, rel)).toString('latin1'); } catch { continue; } + if (home && (HOME_POSIX.test(text) || HOME_WIN.test(text))) hits.push(`${rel} (home-directory path)`); + else if (nameRe && nameRe.test(text)) hits.push(`${rel} (account name)`); + } return hits; } -function build(root, m, { out, noInstall }) { - if (packageOutIsUnsafe(out, root)) throw new Error(`refusing to write the package into the repo or a parent of it: ${out}`); - const folder = `Stronghold-Protocol-${m.version}${m.lite ? '-lite' : ''}`; - const stage = path.join(out, folder); - if (fs.existsSync(stage)) throw new Error(`package directory already exists: ${stage}`); - if (!m.lite) { - if (!m.assetsPresent) throw new Error('public/assets is missing; a full package needs the local art (or pass --lite)'); - if (!m.fontsPresent) throw new Error('public/fonts is missing; a full package needs the local fonts (or pass --lite)'); +/** Production lockfile entries present under root/node_modules, as file lists relative to root. */ +function prodModuleFiles(root, lock) { + const out = []; + for (const [key, entry] of Object.entries(lock?.packages || {})) { + if (!key.startsWith('node_modules/') || entry.dev || entry.link) continue; + for (const f of listTree(root, key)) if (!f.slice(key.length + 1).includes('node_modules/')) out.push(f); } - fs.mkdirSync(stage, { recursive: true }); - for (const rel of m.tracked) copyRel(root, stage, rel); - for (const rel of m.extras) { - if (!fs.existsSync(path.join(root, rel))) { - if (rel === 'data/local-assets.json') continue; - if (rel === 'public/vendor' && !noInstall) continue; // npm ci postinstall (tools/vendor.mjs) fills it - throw new Error(`missing ${rel}`); - } - copyRel(root, stage, rel); + return out; +} + +const sumBytes = (root, files) => files.reduce((n, f) => n + fileBytes(path.join(root, f)), 0); + +/** Group left-out paths for the summary: `test/`, `tools/`, `docs/research/`, `(root)` … */ +function groupOf(rel) { + const s = rel.split('/'); + if (s.length === 1) return '(root)'; + if ((s[0] === 'docs' || s[0] === 'public') && s.length > 2) return `${s[0]}/${s[1]}/`; + return `${s[0]}/`; +} + +/** + * Everything a package would hold, with every check: no copying, no install. `scan: false` skips the personal-info + * scan and `measure: false` the node_modules / public/vendor sizes (tests that only want the selection). + * @param {string} root + * @param {{ lite?: boolean, paths?: string[], allowDirty?: boolean, env?: object, scan?: boolean, measure?: boolean }} [opts] + */ +export function plan(root, opts = {}) { + const lite = !!opts.lite; + const all = (opts.paths || trackedFiles(root)).map(posixRel); + const { keep, drop } = selectTracked(all); + const art = artPlan(root, { lite }); + const pkg = readJson(path.join(root, 'package.json')) || {}; + const lock = readJson(path.join(root, 'package-lock.json')) || {}; + const files = [...keep, ...art.files]; + const problems = []; + for (const f of files) if (isRefused(f) || isJunk(f)) problems.push(`refused: ${f}`); + const byCase = new Map(); + for (const f of files) { + const k = f.toLowerCase(); + if (byCase.has(k)) problems.push(`paths differ only in case (one file on Windows / macOS): ${byCase.get(k)} / ${f}`); + else byCase.set(k, f); } - if (!noInstall) { - const r = spawnSync('npm', ['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: stage, stdio: 'inherit' }); - if (r.error || r.status !== 0) throw new Error('npm ci --omit=dev failed in the package directory'); + for (const f of keep) if (!isFile(path.join(root, f))) problems.push(`missing tracked file: ${f}`); + for (const f of art.missing) problems.push(`missing art: ${f} (data/assets.json lists it; run npm run setup, or build --lite)`); + if (!lite && !art.files.some((f) => f.startsWith('public/assets/'))) problems.push('no art under public/assets: run npm run setup first, or build --lite'); + for (const p of importProblems(root, keep)) problems.push(p); + for (const p of entryProblems(pkg, keep)) problems.push(p); + if (!opts.allowDirty) for (const f of dirtyFiles(root, keep)) problems.push(`uncommitted change: ${f} (commit it, or --allow-dirty)`); + const names = scanNames(opts.env || process.env); + const modules = opts.measure !== false || (opts.scan !== false && names.length) ? prodModuleFiles(root, lock) : []; + let scanned = 0; + if (opts.scan !== false) { + for (const h of scanFiles(root, files, { home: true, names })) problems.push(`personal info: ${h}`); + for (const h of scanFiles(root, modules, { home: false, names })) problems.push(`personal info: ${h}`); + scanned = files.length + (names.length ? modules.length : 0); + } + const dropped = {}; + for (const f of drop) { + const g = groupOf(f); + dropped[g] ??= { files: 0, bytes: 0 }; + dropped[g].files++; + dropped[g].bytes += fileBytes(path.join(root, f)); + } + const trackedBytes = sumBytes(root, keep); + const artBytes = sumBytes(root, art.files); + const orphanBytes = sumBytes(root, art.orphans); + const modulesBytes = sumBytes(root, modules); + const vendorBytes = opts.measure !== false ? sumBytes(root, listTree(root, 'public/vendor')) : 0; + const dropBytes = Object.values(dropped).reduce((n, g) => n + g.bytes, 0); + return { + version: pkg.version || '0.0.0', lite, tracked: keep, art: art.files, files, dropped, orphans: art.orphans, + localArt: art.local, problems, names: names.length, scanned, + bytes: { + tracked: trackedBytes, art: artBytes, modules: modulesBytes, vendor: vendorBytes, + total: trackedBytes + artBytes + modulesBytes + vendorBytes, + // what 0.1.x's whole-tree layout adds on top: every other tracked file and (full) the art nothing lists + leftOut: dropBytes + (lite ? 0 : orphanBytes), orphans: orphanBytes, + }, + }; +} + +const MB = (n) => `${(n / 1048576).toFixed(1)} MB`; + +export function formatSummary(p) { + const lines = [ + `package: ${p.lite ? 'lite (no game art)' : 'full'} · version ${p.version}`, + `tracked: ${p.tracked.length} files, ${MB(p.bytes.tracked)}`, + ]; + if (!p.lite) { + lines.push(`art: ${p.art.length} files, ${MB(p.bytes.art)} (data/assets.json, public/fonts${p.localArt ? ', public/assets/local + data/local-assets.json' : '; no local-client art here'})`); + } + lines.push(`production node_modules: ${MB(p.bytes.modules)} · public/vendor: ${MB(p.bytes.vendor)} (npm ci --omit=dev and its postinstall)`); + lines.push(`uncompressed: ${MB(p.bytes.total)}; a 0.1.x whole-tree copy would add ${MB(p.bytes.leftOut)}`); + const groups = Object.entries(p.dropped).sort((a, b) => b[1].bytes - a[1].bytes); + if (groups.length) lines.push(`left out: ${groups.map(([g, v]) => `${g} ${v.files} (${MB(v.bytes)})`).join(', ')}`); + if (!p.lite && p.orphans.length) lines.push(`left out art: ${p.orphans.length} files under public/assets that nothing lists (${MB(p.bytes.orphans)})`); + lines.push(`personal-info scan: ${p.scanned} files, ${p.names ? `home paths + ${p.names} account name(s)` : 'home paths only (no account name to refuse)'}`); + lines.push(p.problems.length ? `PROBLEMS (${p.problems.length}):\n${p.problems.map((x) => ` ${x}`).join('\n')}` : 'checks: ok'); + return lines.join('\n') + '\n'; +} + +/** True when `out` is the repository, inside it, or a parent of it. */ +export function packageOutIsUnsafe(out, root = REPO) { + const r = path.resolve(root); + const o = path.resolve(out); + if (o === r) return true; + const inside = (a, b) => { const rel = path.relative(a, b); return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel); }; + return inside(r, o) || inside(o, r); +} + +function zipFolder(cwd, zipPath) { + // deflate everything: storing PNG / MP3 as they are made the full zip 8.5 MB larger and saved no real time + const z = spawnSync('zip', ['-q', '-r', '-X', zipPath, FOLDER], { cwd, stdio: 'inherit' }); + if (!z.error && z.status === 0) return; + const t = spawnSync('tar', ['-c', '--format', 'zip', '-f', zipPath, FOLDER], { cwd, stdio: 'inherit' }); // bsdtar (Windows 10+, macOS) + if (t.error || t.status !== 0 || !isFile(zipPath)) throw new Error('zipping failed: install `zip` (or a bsdtar `tar`)'); +} + +/** Copy the plan into //Stronghold-Protocol, npm ci, check the stage, zip it. */ +export function build(root, p, { out, install = true, force = false, keepStage = false, env = process.env, log = console.log } = {}) { + if (p.problems.length) throw new Error('refusing to build: the plan has problems (see the summary)'); + const dest = path.resolve(out); + if (packageOutIsUnsafe(dest, root)) throw new Error(`refusing to write into the repository or a parent of it: ${dest}`); + const name = `${FOLDER}-v${p.version}${p.lite ? '-lite' : ''}`; // 0.1.x's release asset names + const work = path.join(dest, name); + const stage = path.join(work, FOLDER); + const zipPath = path.join(dest, `${name}.zip`); + for (const t of [work, zipPath]) { + if (!fs.existsSync(t)) continue; + if (!force) throw new Error(`${t} already exists (--force replaces it)`); + fs.rmSync(t, { recursive: true, force: true }); + } + log(`copying ${p.files.length} files → ${stage}`); + for (const rel of p.files) { + const src = path.join(root, rel); + const to = path.join(stage, rel); + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.copyFileSync(src, to, fs.constants.COPYFILE_FICLONE); + fs.chmodSync(to, fs.statSync(src).mode & 0o777); + } + if (install) { + log('npm ci --omit=dev …'); + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + const r = spawnSync(npm, ['ci', '--omit=dev', '--no-audit', '--no-fund'], { cwd: stage, stdio: 'inherit', shell: process.platform === 'win32' }); + if (r.error || r.status !== 0) throw new Error('npm ci --omit=dev failed in the stage'); fs.rmSync(path.join(stage, 'node_modules', '.cache'), { recursive: true, force: true }); + if (!isFile(path.join(stage, 'public', 'vendor', 'pixi.min.js'))) throw new Error('public/vendor/pixi.min.js missing after npm ci (postinstall tools/vendor.mjs)'); } - const hits = scanStage(stage); - if (hits.length) { - throw new Error(`refusing to pack; home-directory paths in:\n${hits.join('\n')}`); + const problems = []; + const planned = new Set(p.files); + const staged = listTree(stage, '', { junk: true }); + const rest = staged.filter((f) => !f.startsWith('node_modules/') && !f.startsWith('public/vendor/')); + for (const f of rest) if (!planned.has(f)) problems.push(`in the stage but not planned: ${f}`); + const have = new Set(rest); + for (const f of p.files) if (!have.has(f)) problems.push(`planned but not staged: ${f}`); + for (const r of REFUSE) if (fs.existsSync(path.join(stage, r))) problems.push(`refused: ${r} is in the stage`); + const names = scanNames(env); + for (const h of scanFiles(stage, rest, { home: true, names })) problems.push(`personal info: ${h}`); + const libs = staged.filter((f) => f.startsWith('node_modules/') || f.startsWith('public/vendor/')); + for (const h of scanFiles(stage, libs, { home: false, names })) problems.push(`personal info: ${h}`); + if (problems.length) throw new Error(`refusing to zip ${stage}:\n${problems.map((x) => ` ${x}`).join('\n')}`); + log(`zipping ${staged.length} files (${MB(sumBytes(stage, staged))}) → ${zipPath}`); + zipFolder(work, zipPath); + if (!keepStage) fs.rmSync(work, { recursive: true, force: true }); + return { zipPath, zipBytes: fileBytes(zipPath), files: staged.length, stage: keepStage ? stage : null }; +} + +const USAGE = 'usage: node tools/package.mjs [--lite] [--dry-run [--list]] [--out ] [--force] [--keep-stage] [--no-install] [--allow-dirty] [--root ]'; + +export function parseArgs(argv) { + const o = { lite: false, dryRun: false, list: false, out: '', force: false, keepStage: false, install: true, allowDirty: false, root: REPO, help: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--lite') o.lite = true; + else if (a === '--dry-run') o.dryRun = true; + else if (a === '--list') o.list = true; + else if (a === '--force') o.force = true; + else if (a === '--keep-stage') o.keepStage = true; + else if (a === '--no-install') o.install = false; + else if (a === '--allow-dirty') o.allowDirty = true; + else if (a === '--help' || a === '-h') o.help = true; + else if (a === '--out' || a === '--root') { + const v = argv[++i]; + if (!v || v.startsWith('--')) throw new Error(`${a} needs a directory\n${USAGE}`); + o[a.slice(2)] = path.resolve(v); + } else throw new Error(`unknown argument ${a}\n${USAGE}`); } - const zipPath = path.join(out, `${folder}.zip`); - const zip = spawnSync('zip', ['-r', '-q', zipPath, folder], { cwd: out }); - if (zip.error || zip.status !== 0) throw new Error('zip failed'); - return { stage, zipPath }; + return o; } function main(argv) { - let lite = false; - let dry = false; - let noInstall = false; - let out = ''; - for (let i = 0; i < argv.length; i++) { - const a = argv[i]; - if (a === '--lite') lite = true; - else if (a === '--dry-run') dry = true; - else if (a === '--no-install') noInstall = true; - else if (a === '--out') { - out = argv[++i]; - if (!out || out.startsWith('--')) { - console.error('package: --out needs a directory'); - return 1; - } - } else if (a === '--help' || a === '-h') { - console.log('usage: node tools/package.mjs [--dry-run] [--lite] [--out ] [--no-install]'); - return 0; - } else { - console.error(`package: unknown argument ${a}`); - return 1; - } + let o; + try { o = parseArgs(argv); } catch (e) { console.error(`package: ${e.message}`); return 2; } + if (o.help) { console.log(USAGE); return 0; } + let p; + try { p = plan(o.root, { lite: o.lite, allowDirty: o.allowDirty }); } catch (e) { console.error(`package: ${e.message}`); return 1; } + process.stdout.write(formatSummary(p)); + if (o.dryRun) { + if (o.list) for (const f of p.files) process.stdout.write(`file ${f}\n`); + return p.problems.length ? 1 : 0; } - const m = measure(ROOT, { lite }); - process.stdout.write(formatSummary(m)); - if (dry) { - process.stdout.write('dry-run: yes\n'); - for (const rel of m.tracked) process.stdout.write(`file ${rel}\n`); - for (const rel of m.extras) process.stdout.write(`extra ${rel}\n`); + if (p.problems.length) return 1; + try { + const r = build(o.root, p, { out: o.out || path.join(os.tmpdir(), 'stronghold-protocol-release'), install: o.install, force: o.force, keepStage: o.keepStage }); + console.log(`zip: ${r.zipPath} (${MB(r.zipBytes)}, ${r.files} files)${r.stage ? ` · stage kept: ${r.stage}` : ''}`); return 0; + } catch (e) { + console.error(`package: ${e.message}`); + return 1; } - const dest = out || path.join(os.tmpdir(), `stronghold-protocol-package-${m.version}`); - const built = build(ROOT, m, { out: dest, noInstall }); - console.log(`zip: ${built.zipPath}`); - return 0; } -const invoked = process.argv[1] - && import.meta.url === pathToFileURL(fs.realpathSync(process.argv[1])).href; -if (invoked) process.exit(main(process.argv.slice(2))); +const invoked = (() => { try { return pathToFileURL(fs.realpathSync(process.argv[1] || '')).href; } catch { return null; } })(); +if (invoked === import.meta.url) process.exitCode = main(process.argv.slice(2));