feedback6: merge fb5-patch-pkg — from 0.2.1 on every release also ships an update zip (only the files changed since the earlier 0.2.x releases; the server checks the install against MANIFEST.json and deletes the files the new version dropped on its first start; manual download, no auto-update — the owner's decision of 2026-10-07); README English summary: both the update-zip sentence and the Languages line kept
This commit is contained in:
commit
ee43fb7c14
13 files changed
+1588
-67
No files matched your search
+17
-1
@@ -3,7 +3,9 @@
|
||||
//
|
||||
// node tools/doctor.mjs [--port 3000] [--host 0.0.0.0]
|
||||
//
|
||||
// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, downloaded art/audio, optional local-client art,
|
||||
// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, the shipped files against the release's
|
||||
// MANIFEST.json (server/update.js checkInstall; a source checkout has none) and an update package not applied yet,
|
||||
// downloaded art/audio, optional local-client art,
|
||||
// Python (only needed for the optional extraction), the port (free / our server running → /healthz / another
|
||||
// program), LAN addresses friends can use (virtual adapters and VPNs labelled), firewall hints per OS, tunnel tools
|
||||
// (Tailscale, ZeroTier, cloudflared) and the env vars the server reads.
|
||||
@@ -20,6 +22,7 @@ import {
|
||||
checkNode, checkDeps, checkVendor, checkData, checkAssets, checkLocal, findClient, findPython,
|
||||
LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT,
|
||||
} from './setup.mjs';
|
||||
import { checkInstall, MANIFEST_FILE, UPDATE_FILE } from '../server/update.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------
|
||||
// LAN addresses (also used by scripts/launch.mjs)
|
||||
@@ -184,6 +187,19 @@ async function main() {
|
||||
row(vendor.ok ? 'ok' : 'err', '前端库 public/vendor', vendor.ok ? (vendor.optionalMissing.length ? 'three.js 缺失(3D 棋盘回退 2D)' : '') : `缺少 ${vendor.missing.join(', ')} → node tools/vendor.mjs`);
|
||||
const data = checkData();
|
||||
row(data.ok ? 'ok' : 'err', '游戏数据 data/*.json', data.ok ? '' : `缺少/损坏:${[...data.missing, ...data.broken].join(', ')}`);
|
||||
// the release's file list (every package has one; art is setup's and is checked below)
|
||||
const inst = checkInstall(ROOT);
|
||||
const instLabel = `文件校验 ${MANIFEST_FILE}`;
|
||||
if (inst.state === 'none') row('skip', instLabel, '没有:源码目录,不校验');
|
||||
else if (inst.state === 'broken') row('warn', instLabel, `无法读取(${inst.error})→ 重新解压同一版本的整合包`);
|
||||
else if (inst.state === 'ok') row('ok', instLabel, `${inst.checked} 个文件与 v${inst.app} 一致`);
|
||||
else {
|
||||
const bad = [...inst.runtime, ...inst.other];
|
||||
const eg = bad.slice(0, 3).join(' ') + (bad.length > 3 ? ' …' : '');
|
||||
row(inst.runtime.length ? 'err' : 'warn', instLabel, `${bad.length} 个文件与 v${inst.app} 不一致或缺失(例:${eg})`
|
||||
+ (inst.runtime.length ? ` → 重新解压 v${inst.app} 的完整包(或它的更新包)` : ':只是说明 / 脚本文件,不影响运行'));
|
||||
}
|
||||
if (inst.pending) row('warn', `更新包 ${UPDATE_FILE}`, '已解压、还没有应用:下次启动服务器时自动应用');
|
||||
const assets = checkAssets();
|
||||
row(assets.ok ? 'ok' : 'warn', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件`
|
||||
: !assets.present ? '未下载 → node tools/setup.mjs(游戏仍可运行,使用占位图)' : `缺 ${assets.missing}/${assets.total}(例:${assets.sample.join(' ')})→ node tools/setup.mjs`);
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// tools/package-update.mjs — what `node tools/package.mjs --update --from <base>[,<base>…]` (docs/DEPLOY.md §7) needs
|
||||
// besides the stage: read an earlier release — its full zip, or the folder that zip was extracted to — into
|
||||
// {path → size + sha256}, compare the new full stage with every base, and write MANIFEST.json / UPDATE.json.
|
||||
// tools/package.mjs builds the stage, copies what ships, checks and zips it; server/update.js finishes the update on
|
||||
// the player's machine (and reads the two files back).
|
||||
//
|
||||
// A file ships when it is new or differs from at least one base, so one update zip works over each of them (0.2.0 and
|
||||
// every later 0.2.x given as a base); `removed` lists the files some base shipped that the new version does not, with
|
||||
// the bytes each base had (the player's copy is deleted only when it still holds them). A removed path that differs
|
||||
// from a new one only in case is the same file on Windows / macOS: it is left out of `removed` (`caseOnly`).
|
||||
// The zip reader handles what tools/package.mjs and GitHub hand out: stored and deflated entries, zip64, UTF-8 names.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import zlib from 'node:zlib';
|
||||
import { UPDATE_FILE, UPDATE_FORMAT, digestFile } from '../server/update.js';
|
||||
|
||||
/** Read `len` bytes at `pos` of an open file. */
|
||||
function readAt(fd, pos, len) {
|
||||
const buf = Buffer.alloc(len);
|
||||
let got = 0;
|
||||
while (got < len) {
|
||||
const n = fs.readSync(fd, buf, got, len - got, pos + got);
|
||||
if (n <= 0) throw new Error('unexpected end of the zip file');
|
||||
got += n;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
const SIG = { eocd: 0x06054b50, loc64: 0x07064b50, eocd64: 0x06064b50, central: 0x02014b50, local: 0x04034b50 };
|
||||
const U32 = 0xffffffff;
|
||||
|
||||
/**
|
||||
* Call `onFile(name, data)` for every file entry of a zip (directories skipped), in central-directory order.
|
||||
* @param {string} zipPath
|
||||
* @param {(name: string, data: Buffer) => void} onFile
|
||||
*/
|
||||
export function readZip(zipPath, onFile) {
|
||||
const fd = fs.openSync(zipPath, 'r');
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size;
|
||||
const tailLen = Math.min(size, 22 + 65535);
|
||||
const tail = readAt(fd, size - tailLen, tailLen);
|
||||
let e = -1;
|
||||
for (let i = tailLen - 22; i >= 0; i--) if (tail.readUInt32LE(i) === SIG.eocd) { e = i; break; }
|
||||
if (e < 0) throw new Error('not a zip file (no end of central directory)');
|
||||
let count = tail.readUInt16LE(e + 10);
|
||||
let cdSize = tail.readUInt32LE(e + 12);
|
||||
let cdOffset = tail.readUInt32LE(e + 16);
|
||||
if (count === 0xffff || cdSize === U32 || cdOffset === U32) {
|
||||
const locPos = size - tailLen + e - 20;
|
||||
const loc = locPos >= 0 ? readAt(fd, locPos, 20) : null;
|
||||
if (!loc || loc.readUInt32LE(0) !== SIG.loc64) throw new Error('zip64 locator missing');
|
||||
const rec = readAt(fd, Number(loc.readBigUInt64LE(8)), 56);
|
||||
if (rec.readUInt32LE(0) !== SIG.eocd64) throw new Error('zip64 end record missing');
|
||||
count = Number(rec.readBigUInt64LE(32));
|
||||
cdSize = Number(rec.readBigUInt64LE(40));
|
||||
cdOffset = Number(rec.readBigUInt64LE(48));
|
||||
}
|
||||
const cd = readAt(fd, cdOffset, cdSize);
|
||||
let p = 0;
|
||||
for (let n = 0; n < count; n++) {
|
||||
if (p + 46 > cd.length || cd.readUInt32LE(p) !== SIG.central) throw new Error('damaged central directory');
|
||||
const madeBy = cd.readUInt16LE(p + 4);
|
||||
const flags = cd.readUInt16LE(p + 8);
|
||||
const method = cd.readUInt16LE(p + 10);
|
||||
const crc = cd.readUInt32LE(p + 16);
|
||||
let packedSize = cd.readUInt32LE(p + 20);
|
||||
let rawSize = cd.readUInt32LE(p + 24);
|
||||
const nameLen = cd.readUInt16LE(p + 28);
|
||||
const extraLen = cd.readUInt16LE(p + 30);
|
||||
const commentLen = cd.readUInt16LE(p + 32);
|
||||
const external = cd.readUInt32LE(p + 38);
|
||||
let offset = cd.readUInt32LE(p + 42);
|
||||
const name = cd.toString('utf8', p + 46, p + 46 + nameLen);
|
||||
if (packedSize === U32 || rawSize === U32 || offset === U32) {
|
||||
// the zip64 extra field carries the values the header marks 0xFFFFFFFF, in this order
|
||||
for (let q = p + 46 + nameLen, end = q + extraLen; q + 4 <= end;) {
|
||||
const id = cd.readUInt16LE(q);
|
||||
const len = cd.readUInt16LE(q + 2);
|
||||
if (id === 1) {
|
||||
let r = q + 4;
|
||||
if (rawSize === U32) { rawSize = Number(cd.readBigUInt64LE(r)); r += 8; }
|
||||
if (packedSize === U32) { packedSize = Number(cd.readBigUInt64LE(r)); r += 8; }
|
||||
if (offset === U32) { offset = Number(cd.readBigUInt64LE(r)); r += 8; }
|
||||
break;
|
||||
}
|
||||
q += 4 + len;
|
||||
}
|
||||
}
|
||||
p += 46 + nameLen + extraLen + commentLen;
|
||||
if (name.endsWith('/')) continue;
|
||||
if ((madeBy >> 8) === 3 && ((external >>> 16) & 0o170000) === 0o120000) throw new Error(`${name}: a symbolic link (release zips have none)`);
|
||||
if (flags & 1) throw new Error(`${name}: encrypted`);
|
||||
const local = readAt(fd, offset, 30);
|
||||
if (local.readUInt32LE(0) !== SIG.local) throw new Error(`${name}: damaged local header`);
|
||||
const packed = readAt(fd, offset + 30 + local.readUInt16LE(26) + local.readUInt16LE(28), packedSize);
|
||||
let data;
|
||||
if (method === 0) data = packed;
|
||||
else if (method === 8) {
|
||||
try { data = zlib.inflateRawSync(packed); } catch (err) { throw new Error(`${name}: ${err.message}`, { cause: err }); }
|
||||
} else throw new Error(`${name}: compression method ${method} (only stored and deflated entries)`);
|
||||
if (data.length !== rawSize) throw new Error(`${name}: ${data.length} bytes, the directory says ${rawSize}`);
|
||||
if (typeof zlib.crc32 === 'function' && (zlib.crc32(data) >>> 0) !== crc) throw new Error(`${name}: CRC mismatch (a damaged zip)`);
|
||||
onFile(name, data);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
/** -1 / 0 / 1 for two x.y.z[-pre] versions (a pre-release sorts before its release). */
|
||||
export function compareVersions(a, b) {
|
||||
const parse = (v) => {
|
||||
const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v));
|
||||
if (!m) throw new Error(`not a version: ${v}`);
|
||||
return { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
|
||||
};
|
||||
const x = parse(a);
|
||||
const y = parse(b);
|
||||
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] < y.n[i] ? -1 : 1;
|
||||
if (x.pre === y.pre) return 0;
|
||||
if (x.pre === null) return 1;
|
||||
if (y.pre === null) return -1;
|
||||
return x.pre < y.pre ? -1 : 1;
|
||||
}
|
||||
|
||||
const sha = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
|
||||
|
||||
/** Files under `dir` (following symlinks) as posix paths relative to it. */
|
||||
function walk(dir, rel = '', out = []) {
|
||||
for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
|
||||
const child = rel ? `${rel}/${e.name}` : e.name;
|
||||
let st;
|
||||
try { st = fs.statSync(path.join(dir, child)); } catch { continue; }
|
||||
if (st.isDirectory()) walk(dir, child, out);
|
||||
else if (st.isFile()) out.push(child);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* An earlier release as a base: its full zip, or the folder it was extracted to (the Stronghold-Protocol folder or the
|
||||
* one holding it — an unmodified extraction). `skip(rel)` leaves paths out (per-machine files, OS clutter, the update
|
||||
* files themselves).
|
||||
* @param {string} source
|
||||
* @param {{ folder?: string, skip?: (rel: string) => boolean }} [opts]
|
||||
* @returns {{ source: string, version: string, files: Map<string, { size: number, sha256: string }>, art: number, update: boolean }}
|
||||
*/
|
||||
export function readBase(source, { folder = 'Stronghold-Protocol', skip = () => false } = {}) {
|
||||
const abs = path.resolve(source);
|
||||
let st;
|
||||
try { st = fs.statSync(abs); } catch { throw new Error(`base ${source}: not found`); }
|
||||
const files = new Map();
|
||||
let pkg = null;
|
||||
let update = false;
|
||||
const add = (rel, d, read) => {
|
||||
if (rel === 'package.json') { try { pkg = JSON.parse(read().toString('utf8')); } catch { pkg = {}; } }
|
||||
if (rel === UPDATE_FILE) update = true;
|
||||
if (!skip(rel)) files.set(rel, d);
|
||||
};
|
||||
if (st.isDirectory()) {
|
||||
let root = abs;
|
||||
if (!fs.existsSync(path.join(root, 'package.json')) && fs.existsSync(path.join(root, folder, 'package.json'))) root = path.join(root, folder);
|
||||
if (!fs.existsSync(path.join(root, 'package.json'))) throw new Error(`base ${source}: no package.json (pass a release's full zip or the folder it was extracted to)`);
|
||||
for (const rel of walk(root)) {
|
||||
const p = path.join(root, ...rel.split('/'));
|
||||
const d = digestFile(p);
|
||||
if (!d) throw new Error(`base ${source}: cannot read ${rel}`);
|
||||
add(rel, d, () => fs.readFileSync(p));
|
||||
}
|
||||
} else {
|
||||
const prefix = `${folder}/`;
|
||||
try {
|
||||
readZip(abs, (name, data) => {
|
||||
if (!name.startsWith(prefix)) throw new Error(`${name} is outside the ${folder}/ folder (not a release zip)`);
|
||||
add(name.slice(prefix.length), { size: data.length, sha256: sha(data) }, () => data);
|
||||
});
|
||||
} catch (e) {
|
||||
throw new Error(`base ${source}: ${e.message}`, { cause: e });
|
||||
}
|
||||
if (!pkg) throw new Error(`base ${source}: no ${folder}/package.json (not a release zip)`);
|
||||
}
|
||||
const version = pkg?.version;
|
||||
if (typeof version !== 'string' || !/^\d+\.\d+\.\d+/.test(version)) throw new Error(`base ${source}: package.json names no version`);
|
||||
let art = 0;
|
||||
for (const rel of files.keys()) if (rel.startsWith('public/assets/')) art++;
|
||||
return { source: abs, version, files, art, update };
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare the new full stage with the bases. `removable(rel)`: false for a path no update deletes (it goes to `left`).
|
||||
* @param {Map<string, { size: number, sha256: string }>} next every file of the new version (the update files left out)
|
||||
* @param {{ version: string, files: Map<string, { size: number, sha256: string }> }[]} bases
|
||||
* @param {{ removable?: (rel: string) => boolean }} [opts]
|
||||
* @returns {{ ship: string[], removed: { path: string, sha256: string[] }[], caseOnly: string[], left: string[],
|
||||
* perBase: { version: string, added: number, changed: number, unchanged: number, removed: number }[] }}
|
||||
*/
|
||||
export function diffBases(next, bases, { removable = () => true } = {}) {
|
||||
const ship = new Set();
|
||||
const perBase = [];
|
||||
for (const b of bases) {
|
||||
const n = { version: b.version, added: 0, changed: 0, unchanged: 0, removed: 0 };
|
||||
for (const [rel, d] of next) {
|
||||
const o = b.files.get(rel);
|
||||
if (!o) { n.added++; ship.add(rel); } else if (o.size !== d.size || o.sha256 !== d.sha256) { n.changed++; ship.add(rel); } else n.unchanged++;
|
||||
}
|
||||
for (const rel of b.files.keys()) if (!next.has(rel)) n.removed++;
|
||||
perBase.push(n);
|
||||
}
|
||||
const lower = new Set([...next.keys()].map((f) => f.toLowerCase()));
|
||||
const removed = new Map();
|
||||
const caseOnly = new Set();
|
||||
const left = new Set();
|
||||
for (const b of bases) {
|
||||
for (const [rel, d] of b.files) {
|
||||
if (next.has(rel)) continue;
|
||||
if (lower.has(rel.toLowerCase())) { caseOnly.add(rel); continue; }
|
||||
if (!removable(rel)) { left.add(rel); continue; }
|
||||
if (!removed.has(rel)) removed.set(rel, new Set());
|
||||
removed.get(rel).add(d.sha256);
|
||||
}
|
||||
}
|
||||
return {
|
||||
ship: [...ship].sort(),
|
||||
removed: [...removed.keys()].sort().map((rel) => ({ path: rel, sha256: [...removed.get(rel)].sort() })),
|
||||
caseOnly: [...caseOnly].sort(),
|
||||
left: [...left].sort(),
|
||||
perBase,
|
||||
};
|
||||
}
|
||||
|
||||
const fileLine = ([rel, d]) => `${JSON.stringify(rel)}:{"size":${d.size},"sha256":"${d.sha256}"}`;
|
||||
const block = (lines) => (lines.length ? `\n${lines.join(',\n')}\n` : '');
|
||||
|
||||
/** {path → {size, sha256}} as a JSON object, one file per line (sorted): readable, small diffs between releases. */
|
||||
function filesJson(files) {
|
||||
return `{${block([...files].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)).map(fileLine))}}`;
|
||||
}
|
||||
|
||||
/** MANIFEST.json's text (server/update.js parseManifest reads it). @param {{ app: string, files: Map }} m */
|
||||
export function manifestText({ app, files }) {
|
||||
return `{"format":${UPDATE_FORMAT},"app":${JSON.stringify(app)},"files":${filesJson(files)}}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE.json's text (server/update.js parseUpdate reads it).
|
||||
* @param {{ app: string, from: string[], files: Map, removed: { path: string, sha256: string[] }[] }} u
|
||||
*/
|
||||
export function updateText({ app, from, files, removed }) {
|
||||
let bytes = 0;
|
||||
for (const d of files.values()) bytes += d.size;
|
||||
const gone = removed.map((r) => `{"path":${JSON.stringify(r.path)},"sha256":${JSON.stringify(r.sha256)}}`);
|
||||
return `{"format":${UPDATE_FORMAT},"app":${JSON.stringify(app)},"from":${JSON.stringify(from)},"count":${files.size},"bytes":${bytes},`
|
||||
+ `\n"removed":[${block(gone)}],\n"files":${filesJson(files)}}\n`;
|
||||
}
|
||||
+243
-52
@@ -1,13 +1,16 @@
|
||||
#!/usr/bin/env node
|
||||
// tools/package.mjs — the two player release zips (docs/DEPLOY.md §7; README「方式一」says which one to pick).
|
||||
// tools/package.mjs — the player release zips (docs/DEPLOY.md §7; README「方式一」says which one to pick).
|
||||
//
|
||||
// npm run package → Stronghold-Protocol-v<version>.zip full: what runs the game + the game art
|
||||
// npm run package:lite → Stronghold-Protocol-v<version>-lite.zip the same without the art (`npm run setup`
|
||||
// npm run package → Stronghold-Protocol-v<version>.zip full: what runs the game + the game art
|
||||
// npm run package:lite → Stronghold-Protocol-v<version>-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 <dir>] [--force] [--keep-stage] [--no-install]
|
||||
// [--allow-dirty] [--root <dir>]
|
||||
// node tools/package.mjs --update --from <base>[,<base>…]
|
||||
// → Stronghold-Protocol-v<version>-update.zip only the files that differ from the earlier full
|
||||
// zips given as bases, to extract over an install of one of them (server/update.js)
|
||||
// node tools/package.mjs [--lite | --update --from <bases>] [--dry-run [--list]] [--out <dir>] [--force] [--keep-stage]
|
||||
// [--no-install] [--allow-dirty] [--allow-dev] [--root <dir>]
|
||||
//
|
||||
// Both zips hold one folder, Stronghold-Protocol/, with only what a player runs — an allowlist over `git ls-files`, so
|
||||
// The 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/),
|
||||
// packs/ (the content packs that ship with the repository, docs/PACKS.md; a pack installed on this machine and not
|
||||
// committed stays out),
|
||||
@@ -34,6 +37,18 @@
|
||||
// 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.
|
||||
//
|
||||
// Every zip carries MANIFEST.json at its root (written after npm ci; server/update.js): the size and sha256 of each
|
||||
// shipped file except the art setup manages (public/assets/, public/fonts/, data/assets.json, data/local-assets.json) —
|
||||
// what npm run doctor and an update's boot step verify; the full, lite and update zips of one version list the same.
|
||||
//
|
||||
// --update builds the full stage as above (npm ci and all) in <out>/…-update/.full/, reads every base — an earlier
|
||||
// release's full zip or the folder it was extracted to (tools/package-update.mjs: older than this version, not a lite or
|
||||
// update zip, each version once) — and ships each file that is new or differs (size + sha256) from at least one base,
|
||||
// plus MANIFEST.json and UPDATE.json (the base versions, what ships, and `removed`: the files a base shipped that this
|
||||
// version does not, with the bytes each base had). The update stage gets the same checks (the refusal list, the
|
||||
// personal-info scan; it must hold exactly that) before it is zipped. --dry-run reads and checks the bases; the
|
||||
// comparison needs the built stage.
|
||||
//
|
||||
// --out defaults to <tmp>/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+).
|
||||
|
||||
@@ -44,6 +59,11 @@ import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { findSpecifiers } from './check-imports.mjs';
|
||||
import { writePackIndex } from './packs.mjs';
|
||||
import { compareVersions, diffBases, manifestText, readBase, updateText } from './package-update.mjs';
|
||||
import {
|
||||
APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, affectsRuntime, digestFile, inManifest, isSetupArt, listedArtFiles,
|
||||
parseManifest, parseUpdate, pathProblem, removalProblem,
|
||||
} from '../server/update.js';
|
||||
|
||||
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
/** The one folder inside both zips (DEPLOY §1.1: "解压后把里面的 Stronghold-Protocol 文件夹放到…"). */
|
||||
@@ -180,16 +200,7 @@ export function listTree(root, rel, { junk = false } = {}) {
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
export const manifestFiles = listedArtFiles;
|
||||
|
||||
/**
|
||||
* The art of a full package: every file data/assets.json lists, public/fonts, public/assets/local/ and
|
||||
@@ -284,14 +295,16 @@ 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.
|
||||
* `names` (case-insensitive). Returns "path (what)" lines, never the matched text. `strip`: text removed before the
|
||||
* scan (the sha256 values of MANIFEST.json / UPDATE.json, which a hex-only name would match).
|
||||
*/
|
||||
export function scanFiles(root, files, { home = true, names = [] } = {}) {
|
||||
export function scanFiles(root, files, { home = true, names = [], strip = null } = {}) {
|
||||
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 (strip) text = text.replace(strip, '');
|
||||
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)`);
|
||||
}
|
||||
@@ -418,30 +431,64 @@ function zipFolder(cwd, zipPath) {
|
||||
if (t.error || t.status !== 0 || !isFile(zipPath)) throw new Error('zipping failed: install `zip` (or a bsdtar `tar`)');
|
||||
}
|
||||
|
||||
/** Copy the plan into <out>/<name>/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]) {
|
||||
const isLib = (f) => f.startsWith('node_modules/') || f.startsWith('public/vendor/');
|
||||
const META_FILES = new Set([MANIFEST_FILE, UPDATE_FILE]);
|
||||
const HASHES = /[0-9a-f]{64}/g;
|
||||
|
||||
/**
|
||||
* What is wrong with a stage before it is zipped: a file not planned, a planned one missing (node_modules and
|
||||
* public/vendor, npm ci's output, only count when `libsPlanned`), a refused path, personal info (home paths and names
|
||||
* in every file, names only in the libraries; MANIFEST.json / UPDATE.json without their sha256 values).
|
||||
* @param {string} stage
|
||||
* @param {Set<string>} planned
|
||||
* @returns {{ staged: string[], problems: string[] }}
|
||||
*/
|
||||
export function stageProblems(stage, planned, { env = process.env, libsPlanned = false } = {}) {
|
||||
const problems = [];
|
||||
const staged = listTree(stage, '', { junk: true });
|
||||
const held = libsPlanned ? staged : staged.filter((f) => !isLib(f));
|
||||
for (const f of held) if (!planned.has(f)) problems.push(`in the stage but not planned: ${f}`);
|
||||
const have = new Set(held);
|
||||
for (const f of planned) 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);
|
||||
const rest = staged.filter((f) => !isLib(f));
|
||||
for (const h of scanFiles(stage, rest.filter((f) => !META_FILES.has(f)), { home: true, names })) problems.push(`personal info: ${h}`);
|
||||
for (const h of scanFiles(stage, rest.filter((f) => META_FILES.has(f)), { home: true, names, strip: HASHES })) problems.push(`personal info: ${h}`);
|
||||
for (const h of scanFiles(stage, staged.filter(isLib), { home: false, names })) problems.push(`personal info: ${h}`);
|
||||
return { staged, problems };
|
||||
}
|
||||
|
||||
function digestOf(root, rel) {
|
||||
const d = digestFile(path.join(root, rel));
|
||||
if (!d) throw new Error(`cannot read ${path.join(root, rel)}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
function copyInto(from, to, rel) {
|
||||
const src = path.join(from, rel);
|
||||
const dst = path.join(to, rel);
|
||||
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
||||
fs.copyFileSync(src, dst, fs.constants.COPYFILE_FICLONE);
|
||||
fs.chmodSync(dst, fs.statSync(src).mode & 0o777);
|
||||
}
|
||||
|
||||
function freshTargets(targets, force) {
|
||||
for (const t of targets) {
|
||||
if (!fs.existsSync(t)) continue;
|
||||
if (!force) throw new Error(`${t} already exists (--force replaces it)`);
|
||||
fs.rmSync(t, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the plan into `stage`, write the pack index, npm ci (node_modules, public/vendor), write MANIFEST.json, check the
|
||||
* stage: what `build` zips and `buildUpdate` compares. Throws on a problem. Returns the staged files.
|
||||
*/
|
||||
function makeStage(root, p, stage, { install, env, log }) {
|
||||
const generated = new Set(p.generated || []);
|
||||
log(`copying ${p.files.length - generated.size} files → ${stage}`);
|
||||
for (const rel of p.files) {
|
||||
if (generated.has(rel)) continue;
|
||||
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);
|
||||
}
|
||||
for (const rel of p.files) if (!generated.has(rel)) copyInto(root, stage, rel);
|
||||
if (generated.has(GENERATED_PACK_INDEX)) {
|
||||
const index = writePackIndex(stage, path.join(stage, GENERATED_PACK_INDEX));
|
||||
log(`${GENERATED_PACK_INDEX}: ${index.packs.map((x) => `${x.type} ${x.id}`).join(', ') || 'no pack'}`);
|
||||
@@ -454,35 +501,157 @@ export function build(root, p, { out, install = true, force = false, keepStage =
|
||||
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 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}`);
|
||||
const files = new Map();
|
||||
for (const f of listTree(stage, '', { junk: true })) if (inManifest(f)) files.set(f, digestOf(stage, f));
|
||||
fs.writeFileSync(path.join(stage, MANIFEST_FILE), manifestText({ app: p.version, files }));
|
||||
parseManifest(fs.readFileSync(path.join(stage, MANIFEST_FILE), 'utf8')); // as doctor and an update read it back
|
||||
log(`${MANIFEST_FILE}: ${files.size} files (the art is setup's)`);
|
||||
const { staged, problems } = stageProblems(stage, new Set([...p.files, MANIFEST_FILE]), { env });
|
||||
if (problems.length) throw new Error(`refusing to zip ${stage}:\n${problems.map((x) => ` ${x}`).join('\n')}`);
|
||||
return staged;
|
||||
}
|
||||
|
||||
/** Copy the plan into <out>/<name>/Stronghold-Protocol, npm ci, write MANIFEST.json, 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`);
|
||||
freshTargets([work, zipPath], force);
|
||||
const staged = makeStage(root, p, stage, { install, env, log });
|
||||
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 <dir>] [--force] [--keep-stage] [--no-install] [--allow-dirty] [--allow-dev] [--root <dir>]';
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// The update zip
|
||||
|
||||
/** OS clutter a folder can gather (a release zip has none; the extension rules of isJunk would hit node_modules files). */
|
||||
const isClutter = (rel) => {
|
||||
const parts = posixRel(rel).split('/');
|
||||
const base = parts[parts.length - 1];
|
||||
return parts.includes('__pycache__') || base === '.DS_Store' || base === 'Thumbs.db' || base === 'desktop.ini' || base.startsWith('._');
|
||||
};
|
||||
/**
|
||||
* Never part of a base: the update files, and outside node_modules / public/vendor (npm's output, which the stage takes
|
||||
* as npm writes it) per-machine files (the refusal list) and OS clutter.
|
||||
*/
|
||||
const baseSkip = (rel) => rel === MANIFEST_FILE || rel === UPDATE_FILE || rel === APPLIED_FILE || (!isLib(rel) && (isRefused(rel) || isClutter(rel)));
|
||||
|
||||
/**
|
||||
* Read and check the bases of an update to `version`: each an earlier release's full zip or its extracted folder, older
|
||||
* than `version`, with art (not a lite zip), not an update zip, each version once. Sorted by version.
|
||||
* @param {string[]} sources
|
||||
* @param {string} version
|
||||
*/
|
||||
export function readBases(sources, version, { log = () => {} } = {}) {
|
||||
const bases = [];
|
||||
for (const src of sources) {
|
||||
log(`reading base ${src} …`);
|
||||
const b = readBase(src, { folder: FOLDER, skip: baseSkip });
|
||||
if (b.update) throw new Error(`base ${src} is an update package: pass the earlier release's full zip`);
|
||||
if (!b.art) throw new Error(`base ${src} has no game art (a lite zip?): pass the full zip, ${FOLDER}-v${b.version}.zip`);
|
||||
if (compareVersions(b.version, version) >= 0) throw new Error(`base ${src} is v${b.version}, not older than v${version}`);
|
||||
const twin = bases.find((x) => x.version === b.version);
|
||||
if (twin) throw new Error(`two bases are v${b.version}: ${twin.source} and ${b.source}`);
|
||||
bases.push(b);
|
||||
}
|
||||
return bases.sort((a, b) => compareVersions(a.version, b.version));
|
||||
}
|
||||
|
||||
/**
|
||||
* The update zip: build the full stage (as `build` does) in <out>/<name>/.full/, compare every file with each base, copy
|
||||
* what is new or differs from at least one base into <out>/<name>/Stronghold-Protocol with MANIFEST.json and
|
||||
* UPDATE.json, check that stage and zip it as <name>.zip (<name> = Stronghold-Protocol-v<version>-update).
|
||||
*/
|
||||
export function buildUpdate(root, p, bases, { 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)');
|
||||
if (p.lite) throw new Error('an update is compared from the full stage (no --lite)');
|
||||
if (!bases.length) throw new Error('an update needs at least one base (--from)');
|
||||
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}-update`;
|
||||
const work = path.join(dest, name);
|
||||
const full = path.join(work, '.full', FOLDER);
|
||||
const stage = path.join(work, FOLDER);
|
||||
const zipPath = path.join(dest, `${name}.zip`);
|
||||
freshTargets([work, zipPath], force);
|
||||
const staged = makeStage(root, p, full, { install, env, log });
|
||||
log(`comparing ${staged.length} files with ${bases.map((b) => `v${b.version}`).join(', ')} …`);
|
||||
const next = new Map();
|
||||
for (const f of staged) if (f !== MANIFEST_FILE) next.set(f, digestOf(full, f));
|
||||
// without npm ci the stage has no node_modules / public/vendor, and the update would delete the bases' copies
|
||||
for (const lib of ['node_modules/', 'public/vendor/']) {
|
||||
const inBase = bases.find((b) => [...b.files.keys()].some((f) => f.startsWith(lib)));
|
||||
if (inBase && ![...next.keys()].some((f) => f.startsWith(lib))) {
|
||||
throw new Error(`v${inBase.version} ships ${lib} but this build has none (--no-install?): an update built so would delete it from every install`);
|
||||
}
|
||||
}
|
||||
// a path no update deletes (a per-machine name such as node_modules/x/.env.example) stays behind, named in the summary
|
||||
const diff = diffBases(next, bases, { removable: (rel) => !removalProblem(rel) });
|
||||
for (const rel of diff.left) {
|
||||
const bad = pathProblem(rel);
|
||||
if (bad) throw new Error(`a base ships ${rel}, which is ${bad}`);
|
||||
}
|
||||
for (const rel of diff.ship) copyInto(full, stage, rel);
|
||||
copyInto(full, stage, MANIFEST_FILE);
|
||||
const update = { app: p.version, from: bases.map((b) => b.version), files: new Map(diff.ship.map((f) => [f, next.get(f)])), removed: diff.removed };
|
||||
fs.writeFileSync(path.join(stage, UPDATE_FILE), updateText(update));
|
||||
// what the player's server will read back
|
||||
parseUpdate(fs.readFileSync(path.join(stage, UPDATE_FILE), 'utf8'));
|
||||
parseManifest(fs.readFileSync(path.join(stage, MANIFEST_FILE), 'utf8'));
|
||||
const { staged: held, problems } = stageProblems(stage, new Set([...diff.ship, MANIFEST_FILE, UPDATE_FILE]), { env, libsPlanned: true });
|
||||
if (problems.length) throw new Error(`refusing to zip ${stage}:\n${problems.map((x) => ` ${x}`).join('\n')}`);
|
||||
log(`zipping ${held.length} files (${MB(sumBytes(stage, held))}) → ${zipPath}`);
|
||||
zipFolder(work, zipPath);
|
||||
if (!keepStage) fs.rmSync(work, { recursive: true, force: true });
|
||||
return {
|
||||
zipPath, zipBytes: fileBytes(zipPath), files: held.length, diff, bytes: diff.ship.reduce((n, f) => n + next.get(f).size, 0),
|
||||
stage: keepStage ? stage : null, full: keepStage ? full : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** The bases as the summary lists them. */
|
||||
export function formatBases(bases) {
|
||||
return bases.map((b) => `base: v${b.version} · ${b.files.size} files (${b.art} art) · ${b.source}\n`).join('');
|
||||
}
|
||||
|
||||
/** What an update ships, by kind, and what it removes. */
|
||||
export function formatUpdate(r, version) {
|
||||
const kinds = { 'code / data': 0, node_modules: 0, 'public/vendor': 0, art: 0, 'docs / scripts / tools': 0 };
|
||||
for (const f of r.diff.ship) {
|
||||
if (isSetupArt(f)) kinds.art++;
|
||||
else if (f.startsWith('node_modules/')) kinds.node_modules++;
|
||||
else if (f.startsWith('public/vendor/')) kinds['public/vendor']++;
|
||||
else if (!affectsRuntime(f)) kinds['docs / scripts / tools']++;
|
||||
else kinds['code / data']++;
|
||||
}
|
||||
const lines = [`update: v${version} over ${r.diff.perBase.map((b) => `v${b.version}`).join(', ')}`];
|
||||
for (const b of r.diff.perBase) lines.push(` vs v${b.version}: ${b.changed} changed, ${b.added} added, ${b.unchanged} unchanged, ${b.removed} removed`);
|
||||
lines.push(`ships: ${r.diff.ship.length} files, ${MB(r.bytes)} — ${Object.entries(kinds).map(([k, n]) => `${k} ${n}`).join(', ')} (+ ${MANIFEST_FILE}, ${UPDATE_FILE})`);
|
||||
const gone = r.diff.removed.map((x) => x.path);
|
||||
lines.push(`removed: ${gone.length} file(s)${gone.length ? ` (${gone.slice(0, 8).join(', ')}${gone.length > 8 ? ' …' : ''})` : ''}`);
|
||||
if (r.diff.caseOnly.length) lines.push(`not removed (a new file differs only in case): ${r.diff.caseOnly.join(', ')}`);
|
||||
if (r.diff.left.length) lines.push(`not removed (an update never deletes these names): ${r.diff.left.join(', ')}`);
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
const USAGE = 'usage: node tools/package.mjs [--lite | --update --from <base zip or folder>[,<…>]] [--dry-run [--list]] [--out <dir>] [--force] [--keep-stage] [--no-install] [--allow-dirty] [--allow-dev] [--root <dir>]';
|
||||
|
||||
/** Whether `version` is a development (pre-release) version, e.g. 0.2.0-dev. */
|
||||
export const isDevVersion = (version) => /-/.test(String(version || ''));
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const o = { lite: false, dryRun: false, list: false, out: '', force: false, keepStage: false, install: true, allowDirty: false, allowDev: false, root: REPO, help: false };
|
||||
const o = { lite: false, update: false, from: [], dryRun: false, list: false, out: '', force: false, keepStage: false, install: true, allowDirty: false, allowDev: 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 === '--update') o.update = true;
|
||||
else if (a === '--dry-run') o.dryRun = true;
|
||||
else if (a === '--list') o.list = true;
|
||||
else if (a === '--force') o.force = true;
|
||||
@@ -491,12 +660,21 @@ export function parseArgs(argv) {
|
||||
else if (a === '--allow-dirty') o.allowDirty = true;
|
||||
else if (a === '--allow-dev') o.allowDev = true;
|
||||
else if (a === '--help' || a === '-h') o.help = true;
|
||||
else if (a === '--out' || a === '--root') {
|
||||
else if (a === '--from') {
|
||||
const v = argv[++i];
|
||||
if (!v || v.startsWith('--')) throw new Error(`--from needs an earlier release's full zip or its folder\n${USAGE}`);
|
||||
for (const one of v.split(',')) if (one.trim()) o.from.push(path.resolve(one.trim()));
|
||||
} 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}`);
|
||||
}
|
||||
if (!o.help) {
|
||||
if (o.from.length && !o.update) throw new Error(`--from goes with --update\n${USAGE}`);
|
||||
if (o.update && !o.from.length) throw new Error(`--update needs --from <earlier full zip or folder>[,<…>]\n${USAGE}`);
|
||||
if (o.update && o.lite) throw new Error(`--update is compared from the full stage: no --lite\n${USAGE}`);
|
||||
}
|
||||
return o;
|
||||
}
|
||||
|
||||
@@ -507,8 +685,14 @@ function main(argv) {
|
||||
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));
|
||||
let bases = [];
|
||||
if (o.update) {
|
||||
try { bases = readBases(o.from, p.version, { log: (m) => console.log(m) }); } catch (e) { console.error(`package: ${e.message}`); return 1; }
|
||||
process.stdout.write(formatBases(bases));
|
||||
}
|
||||
if (o.dryRun) {
|
||||
if (o.list) for (const f of p.files) process.stdout.write(`file ${f}\n`);
|
||||
if (o.update) console.log('update: the bases are fine; which files ship is known after the build (it compares the npm ci stage)');
|
||||
return p.problems.length ? 1 : 0;
|
||||
}
|
||||
if (p.problems.length) return 1;
|
||||
@@ -518,8 +702,15 @@ function main(argv) {
|
||||
console.error(`package: v${p.version} is a development version — a release zip needs a release version; --allow-dev builds a test zip named after it`);
|
||||
return 1;
|
||||
}
|
||||
const opts = { out: o.out || path.join(os.tmpdir(), 'stronghold-protocol-release'), install: o.install, force: o.force, keepStage: o.keepStage };
|
||||
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 });
|
||||
if (o.update) {
|
||||
const r = buildUpdate(o.root, p, bases, opts);
|
||||
process.stdout.write(formatUpdate(r, p.version));
|
||||
console.log(`zip: ${r.zipPath} (${MB(r.zipBytes)}, ${r.files} files)${r.stage ? ` · stages kept: ${r.stage}, ${r.full}` : ''}`);
|
||||
return 0;
|
||||
}
|
||||
const r = build(o.root, p, opts);
|
||||
console.log(`zip: ${r.zipPath} (${MB(r.zipBytes)}, ${r.files} files)${r.stage ? ` · stage kept: ${r.stage}` : ''}`);
|
||||
return 0;
|
||||
} catch (e) {
|
||||
|
||||
Reference in new issue
Block a user