feedback5: merge R6 (tooling) — correctness-only ESLint, a first checkJs slice + core typedefs, tools/check-imports.mjs, tools/package.mjs (+ --lite), CI tooling job
This commit is contained in:
commit
3288afc787
14 files changed
+2174
-7
No files matched your search
@@ -29,7 +29,7 @@ function splitEntry(line) {
|
||||
* @returns {{ lines: string[], pages: AtlasPage[] }}
|
||||
*/
|
||||
export function parseAtlas(text) {
|
||||
const lines = String(text).replace(/^/, '').replace(/\r\n?/g, '\n').split('\n');
|
||||
const lines = String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n').split('\n');
|
||||
/** @type {AtlasPage[]} */
|
||||
const pages = [];
|
||||
let page = null;
|
||||
@@ -108,7 +108,7 @@ export function normalizeAtlas(text, { pageSize, pma = false, renamePage = null
|
||||
}
|
||||
}
|
||||
const result = out.join('\n');
|
||||
const original = String(text).replace(/^/, '').replace(/\r\n?/g, '\n');
|
||||
const original = String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
||||
return { text: result, changed: result !== original, pages: pages.map((p) => (replace.get(p.line) ?? p.name)), missingSize, fixedSize };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
// Import boundaries for the sim and the match layer.
|
||||
//
|
||||
// server/sim is served to the browser, so it must not import the match, the lobby, the HTTP entry,
|
||||
// the net layer, public/, or any Node builtin. server/match must not import public/ or that net layer.
|
||||
// server/data.js is allowed (the Node loader reaches it on purpose).
|
||||
//
|
||||
// Default exit code is 0 even when violations are listed. --strict exits 1 when any are listed.
|
||||
// Usage: node tools/check-imports.mjs [--strict] [--root <dir>]
|
||||
|
||||
import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
|
||||
import { builtinModules } from 'node:module';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const HTTP_SPECS = new Set([
|
||||
'http', 'https', 'http2', 'net', 'tls', 'dgram', 'ws',
|
||||
'node:http', 'node:https', 'node:http2', 'node:net', 'node:tls', 'node:dgram',
|
||||
]);
|
||||
|
||||
const builtins = new Set(builtinModules);
|
||||
|
||||
/** Drop comments. Strings, including the import specifiers, stay. Newlines stay so line numbers match. */
|
||||
export function maskComments(src) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i];
|
||||
const n = src[i + 1];
|
||||
if (c === '/' && n === '/') {
|
||||
i += 2;
|
||||
while (i < src.length && src[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && n === '*') {
|
||||
i += 2;
|
||||
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) {
|
||||
out += src[i] === '\n' ? '\n' : ' ';
|
||||
i++;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') {
|
||||
const end = endOfString(src, i);
|
||||
out += src.slice(i, end);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
if (c === '`') {
|
||||
const end = endOfTemplate(src, i);
|
||||
out += src.slice(i, end);
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function endOfString(src, i) {
|
||||
const q = src[i];
|
||||
let j = i + 1;
|
||||
while (j < src.length && src[j] !== q) {
|
||||
if (src[j] === '\\') { j += 2; continue; }
|
||||
if (src[j] === '\n') break;
|
||||
j++;
|
||||
}
|
||||
return Math.min(src.length, j + 1);
|
||||
}
|
||||
|
||||
function endOfTemplate(src, i) {
|
||||
let j = i + 1;
|
||||
while (j < src.length && src[j] !== '`') {
|
||||
if (src[j] === '\\') { j += 2; continue; }
|
||||
if (src[j] === '$' && src[j + 1] === '{') {
|
||||
j += 2;
|
||||
let depth = 1;
|
||||
while (j < src.length && depth > 0) {
|
||||
if (src[j] === "'" || src[j] === '"') { j = endOfString(src, j); continue; }
|
||||
if (src[j] === '`') { j = endOfTemplate(src, j); continue; }
|
||||
if (src[j] === '{') depth++;
|
||||
else if (src[j] === '}') depth--;
|
||||
if (depth > 0) j++;
|
||||
}
|
||||
j++;
|
||||
continue;
|
||||
}
|
||||
j++;
|
||||
}
|
||||
return Math.min(src.length, j + 1);
|
||||
}
|
||||
|
||||
function lineOf(src, index) {
|
||||
let line = 1;
|
||||
for (let i = 0; i < index && i < src.length; i++) if (src[i] === '\n') line++;
|
||||
return line;
|
||||
}
|
||||
|
||||
function skipWs(src, i) {
|
||||
while (i < src.length && /\s/.test(src[i])) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
function readQuoted(src, i) {
|
||||
if (src[i] !== "'" && src[i] !== '"') return null;
|
||||
const q = src[i];
|
||||
let j = i + 1;
|
||||
let spec = '';
|
||||
while (j < src.length && src[j] !== q) {
|
||||
if (src[j] === '\\') { spec += src[j + 1] ?? ''; j += 2; continue; }
|
||||
if (src[j] === '\n') return null;
|
||||
spec += src[j];
|
||||
j++;
|
||||
}
|
||||
if (src[j] !== q) return null;
|
||||
return { spec, quoteAt: i, end: j + 1 };
|
||||
}
|
||||
|
||||
/** The `from 'spec'` of an import/export clause, or null when the statement is not one. */
|
||||
function readFrom(src, i) {
|
||||
let depth = 0;
|
||||
const start = i;
|
||||
while (i < src.length && i - start < 8000) {
|
||||
const c = src[i];
|
||||
if (c === "'" || c === '"') {
|
||||
const str = readQuoted(src, i);
|
||||
if (!str) return null;
|
||||
i = str.end;
|
||||
continue;
|
||||
}
|
||||
if (c === '{') depth++;
|
||||
else if (c === '}') depth = Math.max(0, depth - 1);
|
||||
else if (c === ';' && depth === 0) return null;
|
||||
else if (depth === 0 && src.startsWith('from', i) && !/\w/.test(src[i - 1] ?? '') && !/\w/.test(src[i + 4] ?? '')) {
|
||||
const str = readQuoted(src, skipWs(src, i + 4));
|
||||
return str;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} src raw source
|
||||
* @returns {{ spec: string, line: number }[]}
|
||||
*/
|
||||
export function findSpecifiers(src) {
|
||||
const masked = maskComments(src);
|
||||
/** @type {{ spec: string, line: number }[]} */
|
||||
const found = [];
|
||||
const re = /\b(import|export|require)\b/g;
|
||||
let m;
|
||||
while ((m = re.exec(masked))) {
|
||||
const kw = m[1];
|
||||
let i = skipWs(masked, m.index + kw.length);
|
||||
if (kw === 'require') {
|
||||
if (masked[i] !== '(') continue;
|
||||
const str = readQuoted(masked, skipWs(masked, i + 1));
|
||||
if (str) found.push({ spec: str.spec, line: lineOf(masked, str.quoteAt) });
|
||||
continue;
|
||||
}
|
||||
if (kw === 'import' && masked[i] === '.') continue; // import.meta
|
||||
if (kw === 'import' && masked[i] === '(') {
|
||||
const str = readQuoted(masked, skipWs(masked, i + 1));
|
||||
if (str) found.push({ spec: str.spec, line: lineOf(masked, str.quoteAt) });
|
||||
continue;
|
||||
}
|
||||
if (kw === 'import' && (masked[i] === "'" || masked[i] === '"')) {
|
||||
const str = readQuoted(masked, i);
|
||||
if (str) found.push({ spec: str.spec, line: lineOf(masked, str.quoteAt) });
|
||||
continue;
|
||||
}
|
||||
if (kw === 'export') {
|
||||
if (masked.startsWith('type', i) && /\s/.test(masked[i + 4] ?? '')) i = skipWs(masked, i + 4);
|
||||
const c = masked[i];
|
||||
if (c !== '{' && c !== '*') continue;
|
||||
}
|
||||
const from = readFrom(masked, i);
|
||||
if (from) found.push({ spec: from.spec, line: lineOf(masked, from.quoteAt) });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function isBuiltin(spec) {
|
||||
if (spec.startsWith('node:')) return true;
|
||||
return builtins.has(spec);
|
||||
}
|
||||
|
||||
function isHttp(spec, resolved) {
|
||||
if (HTTP_SPECS.has(spec)) return true;
|
||||
if (spec === 'ws' || spec.startsWith('ws/')) return true;
|
||||
if (resolved === 'server/net.js' || resolved === 'server/index.js') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} spec
|
||||
* @param {string} fromFile posix path relative to the root
|
||||
* @param {'sim'|'match'} area
|
||||
* @returns {{ code: string, resolved: string } | { note: true, resolved: string } | null}
|
||||
*/
|
||||
export function classify(spec, fromFile, area) {
|
||||
const fromDir = path.posix.dirname(fromFile);
|
||||
let resolved = spec;
|
||||
if (spec.startsWith('.')) {
|
||||
resolved = path.posix.normalize(path.posix.join(fromDir, spec));
|
||||
}
|
||||
const escaped = resolved.startsWith('..') || path.posix.isAbsolute(spec);
|
||||
if (area === 'sim') {
|
||||
if (resolved === 'server/match' || resolved.startsWith('server/match/')) return { code: 'sim-match', resolved };
|
||||
if (resolved === 'server/lobby.js' || resolved.startsWith('server/lobby/')) return { code: 'sim-lobby', resolved };
|
||||
if (resolved === 'server/index.js') return { code: 'sim-entry', resolved };
|
||||
if (isHttp(spec, resolved)) return { code: 'sim-http', resolved };
|
||||
if (resolved === 'public' || resolved.startsWith('public/')) return { code: 'sim-client', resolved };
|
||||
if (escaped || isBuiltin(spec)) return { code: 'sim-node', resolved };
|
||||
if (resolved === 'server/data.js') return { note: true, resolved };
|
||||
return null;
|
||||
}
|
||||
if (resolved === 'public' || resolved.startsWith('public/')) return { code: 'match-client', resolved };
|
||||
if (isHttp(spec, resolved)) return { code: 'match-http', resolved };
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkJs(dir, rel, out) {
|
||||
let entries;
|
||||
try { entries = readdirSync(dir); } catch { return; }
|
||||
for (const name of entries) {
|
||||
if (name === 'node_modules') continue;
|
||||
const abs = path.join(dir, name);
|
||||
const child = rel ? `${rel}/${name}` : name;
|
||||
let st;
|
||||
try { st = statSync(abs); } catch { continue; }
|
||||
if (st.isDirectory()) walkJs(abs, child, out);
|
||||
else if (/\.(?:js|mjs|cjs)$/.test(name)) out.push(child.split(path.sep).join('/'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} root absolute repository root
|
||||
* @returns {{ violations: { file: string, line: number, spec: string, code: string, resolved: string }[], notes: { file: string, line: number, spec: string, resolved: string }[] }}
|
||||
*/
|
||||
export function scan(root) {
|
||||
const files = [];
|
||||
walkJs(path.join(root, 'server', 'sim'), 'server/sim', files);
|
||||
walkJs(path.join(root, 'server', 'match'), 'server/match', files);
|
||||
/** @type {{ file: string, line: number, spec: string, code: string, resolved: string }[]} */
|
||||
const violations = [];
|
||||
/** @type {{ file: string, line: number, spec: string, resolved: string }[]} */
|
||||
const notes = [];
|
||||
for (const file of files) {
|
||||
const area = file.startsWith('server/sim/') ? 'sim' : 'match';
|
||||
let src;
|
||||
try { src = readFileSync(path.join(root, file), 'utf8'); } catch { continue; }
|
||||
for (const hit of findSpecifiers(src)) {
|
||||
const result = classify(hit.spec, file, area);
|
||||
if (!result) continue;
|
||||
if (result.note) notes.push({ file, line: hit.line, spec: hit.spec, resolved: result.resolved });
|
||||
else violations.push({ file, line: hit.line, spec: hit.spec, code: result.code, resolved: result.resolved });
|
||||
}
|
||||
}
|
||||
const byPos = (a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.spec.localeCompare(b.spec);
|
||||
violations.sort(byPos);
|
||||
notes.sort(byPos);
|
||||
return { violations, notes };
|
||||
}
|
||||
|
||||
export function formatReport({ violations, notes }) {
|
||||
const lines = [];
|
||||
if (violations.length === 0) lines.push('no import-boundary violations');
|
||||
else {
|
||||
lines.push(`${violations.length} import-boundary violation(s):`);
|
||||
for (const v of violations) lines.push(`${v.file}:${v.line}: ${v.spec} [${v.code}]`);
|
||||
}
|
||||
if (notes.length) {
|
||||
lines.push(`${notes.length} allowed note(s):`);
|
||||
for (const n of notes) lines.push(`note ${n.file}:${n.line}: ${n.spec} -> ${n.resolved}`);
|
||||
}
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function main(argv) {
|
||||
let strict = false;
|
||||
let root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--strict') strict = true;
|
||||
else if (a === '--root') {
|
||||
const dir = argv[++i];
|
||||
if (!dir || dir.startsWith('--')) {
|
||||
console.error('check-imports: --root needs a directory');
|
||||
return 1;
|
||||
}
|
||||
root = path.resolve(dir);
|
||||
} else if (a === '--help' || a === '-h') {
|
||||
console.log('usage: node tools/check-imports.mjs [--strict] [--root <dir>]');
|
||||
return 0;
|
||||
} else {
|
||||
console.error(`check-imports: unknown argument ${a}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const report = scan(root);
|
||||
process.stdout.write(formatReport(report));
|
||||
if (strict && report.violations.length) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const invoked = process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
|
||||
if (invoked) process.exit(main(process.argv.slice(2)));
|
||||
@@ -0,0 +1,343 @@
|
||||
// Player package: git-tracked runtime files, production dependencies, and (unless --lite) local art.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// node tools/package.mjs [--dry-run] [--lite] [--out <dir>] [--no-install]
|
||||
//
|
||||
// --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.
|
||||
|
||||
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';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
const ROOT_FILES = new Set([
|
||||
'package.json', 'package-lock.json', 'LICENSE', 'NOTICE.md', 'THIRD-PARTY-NOTICES.md',
|
||||
'README.md', 'CHANGELOG.md', 'Dockerfile', '.dockerignore',
|
||||
]);
|
||||
|
||||
const PLAYER_DOCS = new Set([
|
||||
'docs/PLAYING.md', 'docs/DEPLOY.md', 'docs/WINDOWS.md', 'docs/ASSETS.md',
|
||||
]);
|
||||
|
||||
const RUNTIME_TOOLS = new Set([
|
||||
'tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs',
|
||||
'tools/doctor.mjs', 'tools/crop-board-atlas.mjs',
|
||||
]);
|
||||
|
||||
const DROP_PREFIXES = [
|
||||
'test/', 'public/dev/', 'public/assets/', 'public/fonts/', 'docs/research/',
|
||||
'handoff/', 'node_modules/', '.cache/', 'pv/', '3,9,11回合情况/',
|
||||
];
|
||||
|
||||
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) {
|
||||
const p = posixRel(rel);
|
||||
const base = p.slice(p.lastIndexOf('/') + 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));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection plus uncompressed byte counts. Does not copy or install.
|
||||
* @param {string} root
|
||||
* @param {{ lite?: boolean, paths?: string[] }} [opts]
|
||||
*/
|
||||
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 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';
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function scanStage(stage) {
|
||||
const hits = [];
|
||||
function walk(dir, rel) {
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
walk(stage, '');
|
||||
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)');
|
||||
}
|
||||
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);
|
||||
}
|
||||
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');
|
||||
fs.rmSync(path.join(stage, 'node_modules', '.cache'), { recursive: true, force: true });
|
||||
}
|
||||
const hits = scanStage(stage);
|
||||
if (hits.length) {
|
||||
throw new Error(`refusing to pack; home-directory paths in:\n${hits.join('\n')}`);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
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 <dir>] [--no-install]');
|
||||
return 0;
|
||||
} else {
|
||||
console.error(`package: unknown argument ${a}`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
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`);
|
||||
return 0;
|
||||
}
|
||||
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)));
|
||||
Reference in new issue
Block a user