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
@@ -67,10 +67,11 @@ English summary: [below](#english).
|
||||
|
||||
### 方式一:整合包(推荐)
|
||||
|
||||
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一:
|
||||
[Releases](../../releases/latest) 里有两种整合包,代码和运行依赖完全相同,二选一;已经装好 0.2.x 的,升级时只下载更新包即可:
|
||||
|
||||
- **完整包** `Stronghold-Protocol-v<版本>.zip`(约 430 MB,解压后约 625 MB):附带全部美术 / 音频(含官方 3D 棋盘贴图),解压就能玩,不需要再下载任何东西。**推荐。**
|
||||
- **精简包** `Stronghold-Protocol-v<版本>-lite.zip`(约 22 MB):不带素材,第一次启动时自动从公开镜像下载美术、Spine 模型、音频、字体、表情和「玩法说明」教程图(约 460 MB,可中断,再次启动会续传);官方 3D 棋盘等本地客户端素材不在其中(见下面的「本地客户端素材」)。适合下载大文件不方便的情况。
|
||||
- **更新包** `Stronghold-Protocol-v<版本>-update.zip`(0.2.1 起提供,大小看改动多少,通常只有几 MB):只含比之前的 0.2.x 版本改动过的文件,用来把已经装好的 0.2.x(完整包或精简包装的都行)升级到新版本,不用重新下载整个包。用法:先停止服务器(关掉窗口;装了开机自启的运行 `scripts\install-service-windows.ps1 -Stop`),把 zip 里 `Stronghold-Protocol` 文件夹的全部内容合并到安装文件夹、覆盖同名文件(Windows 资源管理器里复制粘贴即可;macOS 不要用访达拖放,它会整个替换文件夹,请用 `unzip -o`,见 [docs/DEPLOY.md](docs/DEPLOY.md) 第 1.5 节),再照常启动。启动时会先核对全部程序文件、删除新版本不再用的旧文件,然后正常运行;如果这个文件夹不是更新包对应的版本(例如 0.1.x,或者程序文件被改过),会提示下载完整包,服务器不启动。全新安装请用完整包或精简包。
|
||||
|
||||
两种包都只含运行和部署需要的文件(服务器、客户端、数据、启动脚本、setup / doctor / 素材下载工具、许可证与说明、[docs/PLAYING.md](docs/PLAYING.md) 和 [docs/DEPLOY.md](docs/DEPLOY.md));测试、开发工具和设计文档只在源码仓库里。
|
||||
|
||||
@@ -250,7 +251,7 @@ GOLDEN_FULL=1 node --test test/golden.test.js # 黄金结果:固定
|
||||
|
||||
An **unofficial, non-commercial fan remake** of Arknights' seasonal auto-chess tower-defense mode *Stronghold Protocol: Alliance*, played in the browser: solo, or 1–4 player co-op (AI teammates can fill seats). Combat is simulated in each player's browser, so a low-power PC can host.
|
||||
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~430 MB, all the art inside) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~460 MB) on its first start — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~460 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Run:** download the full bundle `Stronghold-Protocol-v<version>.zip` (~430 MB, all the art inside) from [Releases](../../releases/latest) — or the lite one, `…-lite.zip` (~22 MB), which downloads the art (~460 MB) on its first start; from 0.2.1 on, `…-update.zip` holds only the files changed since the earlier 0.2.x releases: stop the server, extract it over an existing 0.2.x folder and start again (the first start deletes the files the new version dropped and verifies the install) — install Node.js 22 or 24, then double-click `scripts\start-windows.bat` (Windows) or run `./scripts/start.sh` (macOS / Linux) and open <http://localhost:3000>. From source: `npm install && npm run setup && npm start` (setup downloads ~460 MB of art from public mirrors, the emotes and the how-to-play pages included; the official 3D board, some official HUD icons, two enemy models and 39 summon models are extracted from a local Arknights client — without one the game uses the 2D board and look-alike stand-ins (the summons show their avatars), and a server can copy `public/assets/local/` and `data/local-assets.json` from the full bundle of the same version).
|
||||
- **Languages:** Chinese (the default), English, 日本語, 한국어 and 繁體中文 — switch on the title screen or in Settings. Game texts come from the official clients; the Japanese, Korean and Traditional Chinese interface strings are machine translations (corrections welcome: [docs/I18N.md](docs/I18N.md)).
|
||||
- **Play with friends:** create a co-op room and share the 4-letter key or the `?room=KEY` link. On a LAN, use the address printed at start; otherwise use a virtual-LAN tool, a tunnel or a VPS — see [docs/DEPLOY.md](docs/DEPLOY.md).
|
||||
- **Disclaimer:** not affiliated with or endorsed by Hypergryph or Yostar. All Arknights names, art, audio, text and data are © their respective owners and are **not** covered by this project's GPL licence. For study and personal non-commercial use only — no selling, paid distribution, paid servers or monetisation of any kind. Content will be removed on request of the rights holders. Provided "as is", without warranty.
|
||||
|
||||
@@ -73,11 +73,12 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
| path | what |
|
||||
|---|---|
|
||||
| `server/index.js` | the process entry (`npm start`); `startServer()` wires `server/http/` |
|
||||
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `common.js`, `boot.js` (banner, shutdown) |
|
||||
| `server/http/` | `config.js` (environment), `websocket.js` (sessions, `/ws`), `static.js` (the mounts), `media.js`, `files.js` (MIME, gzip, ETag, ranges), `buildTag.js`, `routes.js` (`/healthz`), `common.js`, `boot.js` (a pending update package first, banner, shutdown) |
|
||||
| `server/net.js` | sessions and reconnect tokens, rate limits, message validation |
|
||||
| `server/lobby.js` | rooms, seats, AI seats, spectators; starts a `Match` |
|
||||
| `server/data.js` | loads `data/*.json` once (frozen) |
|
||||
| `server/packs.js` | the content packs (PACKS.md): finds the language packs of `public/i18n/` and the pack folders of `packs/`, validates them, answers `/packs/index.json` and which pack files may be served; re-reads the folders when they change |
|
||||
| `server/update.js` | the update package on the player's machine (DEPLOY.md §1.5): before the server starts, an extracted `UPDATE.json` is finished — the install verified against `MANIFEST.json`, the files the new version dropped deleted, or the start refused when the install is another version; doctor's `MANIFEST.json` check |
|
||||
| `server/match/Match.js` | one match: the phase machine, timers, the round loop, co-op, the views; its methods are in `server/match/match/` (`phases.js`, `prep.js`, `combat.js`, `clientCombat.js`, `reports.js`, `unitePhase.js`, `bossRounds.js`, `settle.js`, `views.js`, `intents.js` …) |
|
||||
| `server/match/PlayerState.js` | one player's shop, hand, board, items, bonds and LP, and every prep intent; its methods are in `server/match/player/` (`economy.js`, `acquire.js`, `placement.js`, `items.js`, `pieces.js`, `prep.js`, `round.js`, `diy.js`, `views.js` …) |
|
||||
| `server/match/` (the rest) | `pool.js` (the shared chess pool), `board.js` (placement), `bondsMeta.js`, `effectsMeta.js` and `builtinMeta.js` (prep-phase effects), `choices.js` (机变), `waves.js`, `unite.js` (联防), `finalAssault.js`, `results.js`, `bot.js` (AI players and AI 托管), `fields.js` (the battles of a combat phase), `scheduler.js`, `gamedata.js` |
|
||||
@@ -109,7 +110,7 @@ so old imports keep working: `public/js/ui/gameLogic.js` (`public/js/ui/gameLogi
|
||||
|---|---|
|
||||
| `data/*.json` | generated by `tools/build-data.mjs` and committed — never edited by hand (DATA.md); `data/backups.json` holds the 补位 and 自选 data, `data/i18n/<code>.json` the game texts of a language pack (English), `data/assets.json` the art manifest |
|
||||
| `packs/` | content packs installed as folders, `packs/<id>/pack.json` + files (PACKS.md); empty in the repository but for its readme |
|
||||
| `tools/` | `build-data.mjs`, `build-i18n.mjs`, `i18n.mjs` (UI strings, language packs), `packs.mjs` (content packs), `setup.mjs` / `fetch-assets.mjs` / `tools/assets/` (art and audio), `tools/local-extract/` (art from a local game client), `vendor.mjs`, `golden.mjs`, `check-imports.mjs`, `package.mjs` (the release zip), `doctor.mjs`; sweeps: `matchrun.mjs`, `simrun.mjs`, `botbench.mjs`, `balance.mjs` |
|
||||
| `tools/` | `build-data.mjs`, `build-i18n.mjs`, `i18n.mjs` (UI strings, language packs), `packs.mjs` (content packs), `setup.mjs` / `fetch-assets.mjs` / `tools/assets/` (art and audio), `tools/local-extract/` (art from a local game client), `vendor.mjs`, `golden.mjs`, `check-imports.mjs`, `package.mjs` / `package-update.mjs` (the release zips, the update package), `doctor.mjs`; sweeps: `matchrun.mjs`, `simrun.mjs`, `botbench.mjs`, `balance.mjs` |
|
||||
| `test/` | `node:test` suites by area: `test/content/` (kits, enemies, bonds, items), `test/sim/` (the engine), `test/match/` (match engine, bots), `test/ui/`, `test/render/`, `test/golden/` (the stored digests), `test/helpers/` (`battleHarness.js`; `designDocs.js`, the design document in § order for the doc tests), `test/e2e/` (browser and bot runs) |
|
||||
| `types/` | JSDoc typedefs of the type-checked slice (`types/README.md`) |
|
||||
| `docs/` | the documents; `docs/DESIGN.md` the index of the design document, `docs/design/` its current rules, `docs/history/` its per-release revisions; `docs/research/` the research on the official mode |
|
||||
|
||||
+28
-3
@@ -127,7 +127,29 @@ node tools/setup.mjs # 补下载新增的素材(已有文件会
|
||||
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Restart
|
||||
```
|
||||
|
||||
没装开机自启的话,最后一步改成重新双击 `start-windows.bat`。用 Releases 整合包的:停止服务器,把新版本的整合包解压到新目录后从那里启动即可(完整包已含素材;装了开机自启的,在新目录重新运行一次 `install-service-windows.ps1`)。用精简包或 GitHub「Download ZIP」源码包的:解压新版本后,把旧目录里的 `public\assets`、`public\fonts`、`.cache` 和 `data\local-assets.json`(若有)复制过去,可避免重新下载(setup 只补下新增的素材)。
|
||||
没装开机自启的话,最后一步改成重新双击 `start-windows.bat`。
|
||||
|
||||
#### 用整合包安装的:更新包
|
||||
|
||||
0.2.1 起,Releases 里每个版本除了完整包和精简包,还有更新包 `Stronghold-Protocol-v<版本>-update.zip`:只含比之前的 0.2.x 版本改动过的文件(程序、数据、运行依赖,以及改动过的素材),通常只有几 MB。它用来升级用 0.2.0 及以后的完整包或精简包装好的文件夹(适用的版本写在 Releases 说明里);全新安装、0.1.x 和 GitHub「Download ZIP」源码包请用完整包或精简包,`git clone` 的用上面的 `git pull`。
|
||||
|
||||
```powershell
|
||||
cd C:\Stronghold-Protocol
|
||||
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Stop # 装了开机自启时;没装就关掉服务器窗口
|
||||
Expand-Archive -Force <下载目录>\Stronghold-Protocol-v<版本>-update.zip C:\ # 解压到安装文件夹的上一层,合并进 C:\Stronghold-Protocol、覆盖同名文件
|
||||
powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 -Restart
|
||||
```
|
||||
|
||||
没装开机自启的,最后一步改成双击 `start-windows.bat`。也可以在资源管理器里打开 zip,把其中 `Stronghold-Protocol` 文件夹的全部内容复制到安装文件夹,选「替换目标中的文件」。macOS / Linux:停止服务器后运行 `unzip -o Stronghold-Protocol-v<版本>-update.zip -d <安装文件夹的上一层>`(安装文件夹名为 `Stronghold-Protocol`);不要用访达拖放,它会把同名文件夹整个替换掉。
|
||||
|
||||
启动时(双击启动、`npm start`、开机自启的计划任务、NSSM / systemd 都一样)服务器先完成更新:按 `MANIFEST.json` 核对全部程序文件(代码、数据、运行依赖、前端库和说明文档;素材归 setup 管,不在其中),删除 `UPDATE.json` 列出的、新版本不再使用的旧文件(只删内容与旧版本发布时完全一样的文件:自己改过的文件、自己装的内容包、`logs`、`.cache` 都不会动),再把 `UPDATE.json` 改名为 `.update-applied.json`,然后照常启动,日志里有一行「已更新到 v<版本>」。
|
||||
|
||||
- **文件夹不是更新包对应的版本**(例如 0.1.x、只解压了一部分、程序文件被改过):服务器**不启动**,提示「这个更新包只能覆盖在 v0.2.0 … 的整合包安装上(检测到 N 个文件与 v<版本> 不一致或缺失)」并举出几个文件(开机自启的,`-Status` 显示的日志里能看到)。新旧文件混在一起运行,容易出现看起来像游戏 bug 的错误,所以宁可不启动。下载完整包重新安装,或把更新包重新完整解压一遍再启动(`UPDATE.json` 还在,会重新检查;在此之前什么都不删)。
|
||||
- 只有说明文档、`scripts\`、`tools\` 里的文件不一致:照常启动,日志里列出是哪些文件。`UPDATE.json` 损坏:跳过并提示,按现有文件启动。
|
||||
- **精简包装的也能用**:程序部分与完整包完全相同;更新包带着完整包里改动过的素材,其余素材仍由 setup 补齐。如果更新包带来了新的本地客户端素材清单(`data/local-assets.json`)而这台电脑没有对应的素材,启动时会提示:从同一版本的完整包复制 `public/assets/local/`(第 6 节),或运行 `node tools/setup.mjs --local` 提取,不需要时删掉 `data/local-assets.json` 即可。
|
||||
- 更新包不联网、不会自动更新,和完整包一样从 Releases 手动下载。`npm run doctor` 的「文件校验 MANIFEST.json」一行随时显示全部程序文件是否与这个版本一致(源码目录没有这个文件,不做校验)。
|
||||
|
||||
不用更新包也可以:停止服务器,把新版本的整合包解压到新目录后从那里启动(完整包已含素材;装了开机自启的,在新目录重新运行一次 `install-service-windows.ps1`)。用精简包或 GitHub「Download ZIP」源码包的:解压新版本后,把旧目录里的 `public\assets`、`public\fonts`、`.cache` 和 `data\local-assets.json`(若有)复制过去,可避免重新下载(setup 只补下新增的素材)。
|
||||
|
||||
## 2. 让不在同一网络的朋友加入
|
||||
|
||||
@@ -292,15 +314,18 @@ services:
|
||||
|
||||
## 7. 打包发布(维护者)
|
||||
|
||||
Releases 的两个 zip 由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具):
|
||||
Releases 的 zip(完整包、精简包,0.2.1 起还有更新包)由 `tools/package.mjs` 生成,在**源码仓库**里运行(整合包里没有这个工具):
|
||||
|
||||
```bash
|
||||
npm run package -- --dry-run --list # 只检查:列出每个文件和大小,不写任何文件(精简包加 --lite)
|
||||
npm run package -- --out <目录> # 完整包 Stronghold-Protocol-v<版本>.zip
|
||||
npm run package:lite -- --out <目录> # 精简包 Stronghold-Protocol-v<版本>-lite.zip
|
||||
npm run package -- --update --from <旧版本的完整包>[,<…>] --out <目录> # 更新包 Stronghold-Protocol-v<版本>-update.zip
|
||||
```
|
||||
|
||||
- **打进去的**:`git ls-files` 里的 `server/`、`shared/`、`data/`、`public/`(不含 `public/dev/`)、`packs/`(随仓库提交的内容包;只在本机安装、没提交的不打进去)、启动脚本、玩家会运行的工具(setup、vendor、fetch-assets 与 `tools/assets/`、doctor,以及 setup 调用的 `tools/local-extract/` 和 `crop-board-atlas.mjs`)、服务器和 fetch-assets 读取的 4 张研究数据表(`docs/research/` 的 `03-operators`、`05-enemies`、`05-maps`、`07-assets` 四个 JSON)、`package.json` / `package-lock.json`、许可证与说明(`LICENSE`、`NOTICE.md`、`THIRD-PARTY-NOTICES.md`、`README.md`、`CHANGELOG.md`)、`docs/PLAYING.md` 和本文;然后在临时目录里生成 `packs/index.json`(打进去的语言包和内容包的列表,供纯静态托管使用;服务器自己会实时列出,见 [PACKS.md](PACKS.md)),再 `npm ci --omit=dev` 装上运行依赖和 `public/vendor`。完整包再加上 `data/assets.json` 列出的素材、`public/fonts`,以及本地提取的 `public/assets/local/` 和 `data/local-assets.json`。磁盘上有、清单却没列出的文件不打进去(例如 0.2.0 移出自选的焰狐龙梓兰的旧素材)。
|
||||
- **不打进去的**:`test/`、维护用的工具(数据构建、golden、botbench、i18n、导入检查、本工具等)、`scripts/make-windows-bundle.mjs`(Windows 便携包,见 [WINDOWS.md](WINDOWS.md))、其他文档、研究笔记和 `docs/img/`、`handoff/`、`.github/`、`types/`、lint / 编辑器 / Docker 配置。和 0.1.x 的整树打包(全部跟踪文件加上 `public/assets` 的全部内容)相比,0.2.0 的完整包少了约 640 个文件、解压后小约 26 MB,zip 小约 8 MB。
|
||||
- **打包前的检查**(`--dry-run` 也全部做一遍):拒绝名单(`pv`、`review`、`.cache`、`.claude`、`.git`、`logs`、`.env`、`scripts/service.env.cmd`、`handoff`、`test` 等);每个打进去的模块的相对导入、玩家用的 npm 脚本(start / setup / doctor / launch / postinstall / vendor / assets)都指向包里的文件;完整包里 `data/assets.json` 和 `data/local-assets.json` 列出的文件都在(缺了先运行 `node tools/fetch-assets.mjs`);没有只差大小写的两个路径;包里的文件(二进制素材也查)不含个人目录路径(`/Users/…`、`C:\Users\…`、`/home/…`)或本机的账户名(运行时从系统读取;`SP_PACKAGE_SCAN_NAMES=a,b` 可以再加名字);打进去的已跟踪文件没有未提交的改动(重新生成的 `data/assets.json` 要先提交)。有任何问题都会列出原因、以非零状态结束,不写 zip;正式打包时还会核对临时目录里的文件和计划完全一致。
|
||||
- **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。
|
||||
- **MANIFEST.json**:三种包的根目录都有(`npm ci` 之后写入):除素材(`public/assets/`、`public/fonts/`、`data/assets.json`、`data/local-assets.json`,归 setup 管)以外每个文件的大小和 sha256,同一版本的三种包内容相同。`npm run doctor` 和更新包的启动检查(`server/update.js`)用它核对安装。
|
||||
- **更新包**(0.2.1 起,每个版本都发):`--from` 后面列出**之前每个 0.2.x 版本的完整包**(Releases 上的 zip,或它解压出来、没动过的文件夹;逗号分隔或写多个 `--from`),例如发布 0.2.2 时 `--from Stronghold-Protocol-v0.2.0.zip,Stronghold-Protocol-v0.2.1.zip`。工具照常构建完整包的临时目录(`npm ci` 等),逐个文件(大小 + sha256)和每个旧版本比较:和任何一个旧版本不同、或旧版本没有的文件都打进去,所以一个更新包能覆盖在列出的每个版本上;旧版本有、新版本没有的文件记进 `UPDATE.json` 的 `removed`(连同各旧版本里的 sha256,玩家那边只删内容一致的文件;只差大小写的同名文件和 `.env` 之类的本机文件名不删,摘要里会列出)。旧版本必须比当前版本旧、带素材(不能是精简包或更新包)、每个版本只给一次,`--dry-run` 只读取并检查旧版本。更新包里还有新版本的 `MANIFEST.json` 和 `UPDATE.json`(适用的旧版本、文件数、字节数、文件列表和 `removed`),同样经过拒绝名单和个人信息检查;摘要列出和每个旧版本相比改动 / 新增 / 删除的文件数和更新包里各类文件的数量。发布时在 Releases 说明里写明更新包适用的版本(即 `--from` 列出的版本)。`--keep-stage` 保留更新包目录和完整包的临时目录(`<目录>/Stronghold-Protocol-v<版本>-update/` 里的 `Stronghold-Protocol/` 和 `.full/`)。
|
||||
- **需要**:已下载素材的仓库(完整包)——打包前先联网运行一次 `node tools/fetch-assets.mjs`,补齐清单计划但本机还没有的素材(清单只列出磁盘上有的文件,打包工具看不出缺了哪些;`data/assets.json` 有变化就先提交);能访问 npm 的网络(`npm ci`);`zip`(或 bsdtar 的 `tar`,Windows 10 起自带);更新包还要之前各版本的完整包(工具自己读 zip,不需要 `unzip`;Releases 上可以重新下载)。`--out` 默认是系统临时目录下的 `stronghold-protocol-release`,不能在仓库里面;`--force` 覆盖已有的 zip,`--keep-stage` 保留打包用的目录供检查。
|
||||
+12
-3
@@ -5,9 +5,11 @@
|
||||
// node scripts/launch.mjs [--port 3000] [--host 0.0.0.0] [--no-open] [--no-setup] [setup options…]
|
||||
//
|
||||
// 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless).
|
||||
// 2. node tools/setup.mjs --quiet (dependencies, vendor libs, art download / resume, optional local extraction);
|
||||
// 2. An update package extracted over the folder (UPDATE.json) is finished (server/update.js; the server does it too,
|
||||
// for the routes that skip this script); one that does not fit this install stops here with the message.
|
||||
// 3. node tools/setup.mjs --quiet (dependencies, vendor libs, art download / resume, optional local extraction);
|
||||
// setup options such as --no-assets, --no-local, --local, --game <dir>, -y are passed through.
|
||||
// 3. node server/index.js (PORT / HOST from the options or the environment; SP_COMBAT / SP_VERIFY / TRUST_PROXY /
|
||||
// 4. node server/index.js (PORT / HOST from the options or the environment; SP_COMBAT / SP_VERIFY / TRUST_PROXY /
|
||||
// DEBUG are inherited), then — once /healthz answers — prints the addresses to share and opens
|
||||
// http://localhost:<port> (not with --no-open, SP_NO_BROWSER=1, or on a Linux box without a display).
|
||||
// Ctrl+C stops the server (it gets the signal from the terminal itself); the exit code is the server's.
|
||||
@@ -29,6 +31,7 @@ if (Number(process.versions.node.split('.')[0]) < 22) {
|
||||
|
||||
const { c, mark } = await import('../tools/setup.mjs');
|
||||
const { probePort, classifyAddresses, KIND_LABEL } = await import('../tools/doctor.mjs');
|
||||
const { applyPendingUpdate, UPDATE_FILE } = await import('../server/update.js');
|
||||
|
||||
function parseArgs(argv) {
|
||||
const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false };
|
||||
@@ -81,7 +84,7 @@ async function main() {
|
||||
const o = parseArgs(process.argv.slice(2));
|
||||
if (o.help) {
|
||||
const src = fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n');
|
||||
console.log(src.slice(1, 13).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
|
||||
console.log(src.slice(1, 15).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'));
|
||||
return 0;
|
||||
}
|
||||
const localUrl = `http://localhost:${o.port}`;
|
||||
@@ -89,6 +92,9 @@ async function main() {
|
||||
const before = await probePort(o.port, o.host);
|
||||
if (before.state === 'ours') {
|
||||
console.log(`${mark.ok} 服务器已经在运行(端口 ${o.port}),直接打开浏览器。`);
|
||||
if (fs.existsSync(path.join(ROOT, UPDATE_FILE))) {
|
||||
console.log(c.warn(` 解压过的更新包还没有应用:正在运行的仍是旧版本。先停止它(关掉它的窗口;开机自启用 install-service-windows.ps1 -Restart),再重新启动即可。`));
|
||||
}
|
||||
printShare(o.port);
|
||||
if (o.open) openBrowser(localUrl);
|
||||
return 0;
|
||||
@@ -99,6 +105,9 @@ async function main() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// before setup: setup and the server then see the new version's files (its message explains a refusal)
|
||||
if (applyPendingUpdate(ROOT).state === 'failed') return 1;
|
||||
|
||||
if (o.setup) {
|
||||
const r = spawnSync(process.execPath, [path.join(ROOT, 'tools', 'setup.mjs'), '--quiet', ...o.setupArgs], { cwd: ROOT, stdio: 'inherit' });
|
||||
if (r.status !== 0) { console.error(`${mark.err} 准备步骤失败(见上方)。`); return r.status || 1; }
|
||||
|
||||
+11
-2
@@ -1,5 +1,8 @@
|
||||
// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs):
|
||||
// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs, the
|
||||
// Windows service's scripts/run-server.cmd, NSSM, systemd):
|
||||
//
|
||||
// * first, an update package extracted over the install (UPDATE.json) is finished — old files deleted, the install
|
||||
// verified (server/update.js); one that does not match its MANIFEST.json keeps the server from starting (exit 1);
|
||||
// * the boot banner: release version, the Local URL, the LAN URLs when listening on every interface, the
|
||||
// cloudflared command for internet play;
|
||||
// * a server that cannot start exits 1 (with a hint when the port is in use); unhandled rejections and uncaught
|
||||
@@ -11,6 +14,8 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { APP_VERSION, DEV_BUILD } from '../../shared/constants.js';
|
||||
import { applyPendingUpdate } from '../update.js';
|
||||
import { ROOT } from './config.js';
|
||||
|
||||
/** Non-internal IPv4 addresses as http URLs. @param {number} port */
|
||||
export function lanUrls(port) {
|
||||
@@ -45,12 +50,16 @@ export function printBanner(srv) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The process main: start the server, print the banner, stop gracefully on SIGINT / SIGTERM.
|
||||
* The process main: finish a pending update package, start the server, print the banner, stop gracefully on SIGINT /
|
||||
* SIGTERM.
|
||||
* @param {() => Promise<{ url: string, host: string, port: number, close: () => Promise<void> }>} start index.js startServer
|
||||
*/
|
||||
export async function runMain(start) {
|
||||
process.on('unhandledRejection', (e) => console.error('[process] unhandled rejection', e));
|
||||
process.on('uncaughtException', (e) => console.error('[process] uncaught exception', e));
|
||||
// before the data, the packs or the browser runtime are read: the files must be the new version's (server/update.js).
|
||||
// Nothing is listening yet, so returning ends the process with exit code 1 once the message is written.
|
||||
if (applyPendingUpdate(ROOT).state === 'failed') { process.exitCode = 1; return; }
|
||||
let srv;
|
||||
try {
|
||||
srv = await start();
|
||||
|
||||
+3
-1
@@ -12,7 +12,9 @@
|
||||
// http/buildTag.js the build tag of the served browser runtime (/healthz `build`, public/js/ui/buildGuard.js)
|
||||
// http/routes.js the request listener: security headers, 414 / 400 / 405, GET /healthz → JSON status, else static
|
||||
// http/common.js what every answer shares: security headers, URL split, error page, JSON replies, bare 400
|
||||
// http/boot.js banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT / SIGTERM
|
||||
// http/boot.js a pending update package first (update.js: old files deleted, the install verified against
|
||||
// MANIFEST.json), banner (Local / LAN / tunnel URLs), port-in-use hint, graceful shutdown on SIGINT /
|
||||
// SIGTERM
|
||||
//
|
||||
// Per-network limits for internet clients (see net.js clientAddress; local/LAN peers are exempt): open sockets
|
||||
// (maxConnectionsPerAddr, refused at upgrade with 429), rooms and running matches (lobby.js).
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// server/update.js — the update package (docs/DEPLOY.md §1.5 and §7): the boot step that finishes an update extracted
|
||||
// over an install, and the install check `npm run doctor` shows. Node builtins only, so it loads whatever else the
|
||||
// install holds.
|
||||
//
|
||||
// Every release zip (full, lite, update) carries MANIFEST.json at its root: {format, app, files: {<path>: {size,
|
||||
// sha256}}} — every shipped file except the art setup manages (public/assets/, public/fonts/, data/assets.json,
|
||||
// data/local-assets.json; the lite zip has none of it): the code, the data, node_modules, public/vendor, the docs. The
|
||||
// update zip (Stronghold-Protocol-v<version>-update.zip, `node tools/package.mjs --update --from <earlier full zips>`)
|
||||
// holds only the files that differ from at least one of those zips, MANIFEST.json and UPDATE.json:
|
||||
// {format, app, from: [<base versions>], count, bytes, files: {<path>: {size, sha256}} (what it ships),
|
||||
// removed: [{path, sha256: [<the bytes the bases shipped>]}] (files a base shipped that this version does not)}.
|
||||
//
|
||||
// The player stops the server, extracts the zip over the install and starts it again. applyPendingUpdate runs before
|
||||
// the server starts (server/http/boot.js runMain — every route: npm start, scripts/launch.mjs, the Windows service's
|
||||
// run-server.cmd, NSSM, systemd; scripts/launch.mjs runs it once more before setup):
|
||||
// 1. UPDATE.json unreadable or malformed (an unknown format, a path outside the install …) → logged, left in place,
|
||||
// nothing deleted: the server starts with the files as they are.
|
||||
// 2. The install is verified against MANIFEST.json (and the art files the update brought). A file the server or the
|
||||
// browser runs that is missing or holds other bytes → the bilingual "this update only applies over v… — download
|
||||
// the full package" message, nothing deleted, UPDATE.json kept (the next start checks again) and the server does
|
||||
// not start: a mix of two versions fails in ways that look like game bugs (a stale module, old data under new
|
||||
// code). Differences only in docs, licences, scripts/ or tools/ are named, and the update counts as applied.
|
||||
// 3. Each `removed` file is deleted when it still holds bytes a base shipped — a file changed on this machine, a pack
|
||||
// the player installed, a symlink, a folder, a path outside the install or a per-machine name (.env, .venv*, logs,
|
||||
// the caches, the service settings) is never touched — then the folders it leaves empty.
|
||||
// 4. UPDATE.json is renamed to .update-applied.json; the next start finds nothing to do.
|
||||
// No network, no automatic update: the update zip is a manual download from GitHub Releases, the same trust as the
|
||||
// full zip (whose code runs anyway).
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
export const MANIFEST_FILE = 'MANIFEST.json';
|
||||
export const UPDATE_FILE = 'UPDATE.json';
|
||||
export const APPLIED_FILE = '.update-applied.json';
|
||||
/** The format of MANIFEST.json and UPDATE.json this code writes and reads. */
|
||||
export const UPDATE_FORMAT = 1;
|
||||
const META = new Set([MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]);
|
||||
|
||||
/** The art setup downloads or extracts (and the lite zip leaves out): never in MANIFEST.json, never fatal. */
|
||||
export const isSetupArt = (rel) => rel.startsWith('public/assets/') || rel.startsWith('public/fonts/')
|
||||
|| rel === 'data/assets.json' || rel === 'data/local-assets.json';
|
||||
/** Whether MANIFEST.json lists a shipped file. */
|
||||
export const inManifest = (rel) => !META.has(rel) && !isSetupArt(rel);
|
||||
|
||||
const DOC = /(?:^|\/)(?:LICEN[CS]E|COPYING|NOTICE|AUTHORS|CHANGELOG|CHANGES|HISTORY|README)(?:[.-][^/]*)?$|\.(?:md|markdown|txt|map|d\.[cm]?ts)$/i;
|
||||
const CODE = /\.(?:[cm]?js|json|wasm|node|css|html?)$/i;
|
||||
/**
|
||||
* Whether the server or the browser runs (or serves) a file: a missing or different one stops an update. Docs and
|
||||
* licences anywhere (README, LICENSE, *.md, *.txt, *.d.ts, source maps), scripts/, tools/ (setup, doctor — not the
|
||||
* server), package-lock.json and npm's own node_modules/.package-lock.json only get a warning.
|
||||
*/
|
||||
export function affectsRuntime(rel) {
|
||||
if (rel.startsWith('scripts/') || rel.startsWith('tools/')) return false;
|
||||
if (rel === 'package-lock.json' || rel === 'node_modules/.package-lock.json') return false;
|
||||
return CODE.test(rel) || !DOC.test(rel);
|
||||
}
|
||||
|
||||
/** Size and SHA-256 (hex) of a regular file, or null. */
|
||||
export function digestFile(abs) {
|
||||
let buf;
|
||||
try { buf = fs.readFileSync(abs); } catch { return null; }
|
||||
return { size: buf.length, sha256: crypto.createHash('sha256').update(buf).digest('hex') };
|
||||
}
|
||||
|
||||
/**
|
||||
* The '/assets/…' and '/fonts/…' URLs of an art manifest (data/assets.json, data/local-assets.json) as public/… paths
|
||||
* (the walk of tools/setup.mjs checkAssets; tools/package.mjs plans the full zip's art with it).
|
||||
*/
|
||||
export function listedArtFiles(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;
|
||||
}
|
||||
|
||||
/** Per-machine places an update never deletes from (the user part of tools/package.mjs's refusal list). */
|
||||
const KEEP = ['.git', '.cache', '.claude', 'logs', 'handoff', 'node_modules/.cache', 'scripts/service.env.cmd'];
|
||||
|
||||
/**
|
||||
* Why a path of MANIFEST.json / UPDATE.json cannot be used (null when it can): it must be relative, in `/` form and
|
||||
* inside the install — no `..` or `.` part, no empty part, no part ending in a dot or a space, no backslash, drive or
|
||||
* stream colon, control or wildcard character.
|
||||
*/
|
||||
export function pathProblem(rel) {
|
||||
if (typeof rel !== 'string' || !rel) return 'not a path';
|
||||
if (rel.length > 1024) return 'too long';
|
||||
if (/[\x00-\x1f\\:*?"<>|]/.test(rel)) return 'not a plain relative path';
|
||||
if (rel.startsWith('/')) return 'an absolute path';
|
||||
if (rel.split('/').some((s) => s === '' || s === '.' || s === '..' || /[. ]$/.test(s))) return 'outside the install (an empty, . or .. part)';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an update may not delete a path (null when it may): pathProblem's rules, and never a per-machine file (`.env`,
|
||||
* `.venv*`, the caches, logs, the Windows service settings) or one of the update's own files.
|
||||
*/
|
||||
export function removalProblem(rel) {
|
||||
const bad = pathProblem(rel);
|
||||
if (bad) return bad;
|
||||
if (META.has(rel)) return 'an update file';
|
||||
const parts = rel.split('/');
|
||||
const base = parts[parts.length - 1];
|
||||
if (base === '.env' || base.startsWith('.env.') || parts.some((s) => s.startsWith('.venv'))) return 'a per-machine file';
|
||||
if (KEEP.some((k) => rel === k || rel.startsWith(`${k}/`))) return 'in a per-machine folder';
|
||||
return null;
|
||||
}
|
||||
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
||||
const isCount = (v) => Number.isSafeInteger(v) && v >= 0;
|
||||
|
||||
/** {path: {size, sha256}} → Map, or throws. */
|
||||
function parseFiles(v, what) {
|
||||
if (!isObj(v)) throw new Error(`${what} is not an object`);
|
||||
const out = new Map();
|
||||
for (const [rel, d] of Object.entries(v)) {
|
||||
const bad = pathProblem(rel);
|
||||
if (bad) throw new Error(`${what}: ${rel} is ${bad}`);
|
||||
if (!isObj(d) || !isCount(d.size) || typeof d.sha256 !== 'string' || !HEX64.test(d.sha256)) throw new Error(`${what}: ${rel} has no size / sha256`);
|
||||
out.set(rel, { size: d.size, sha256: d.sha256 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseHead(text, what) {
|
||||
const j = JSON.parse(text);
|
||||
if (!isObj(j)) throw new Error(`${what} is not an object`);
|
||||
if (j.format !== UPDATE_FORMAT) throw new Error(`${what} has format ${JSON.stringify(j.format)}, this server reads ${UPDATE_FORMAT}`);
|
||||
if (typeof j.app !== 'string' || !VERSION.test(j.app)) throw new Error(`${what} names no version`);
|
||||
return j;
|
||||
}
|
||||
|
||||
/** MANIFEST.json's text → {app, files: Map}, or throws with the reason. */
|
||||
export function parseManifest(text) {
|
||||
const j = parseHead(text, MANIFEST_FILE);
|
||||
return { app: j.app, files: parseFiles(j.files, 'files') };
|
||||
}
|
||||
|
||||
/** UPDATE.json's text → {app, from, count, bytes, files: Map, removed: [{path, sha256}]}, or throws with the reason. */
|
||||
export function parseUpdate(text) {
|
||||
const j = parseHead(text, UPDATE_FILE);
|
||||
if (!Array.isArray(j.from) || !j.from.length || !j.from.every((v) => typeof v === 'string' && VERSION.test(v))) throw new Error('from names no base version');
|
||||
if (!isCount(j.count) || !isCount(j.bytes)) throw new Error('count / bytes missing');
|
||||
const files = parseFiles(j.files, 'files');
|
||||
if (!Array.isArray(j.removed)) throw new Error('removed is not a list');
|
||||
const removed = [];
|
||||
const seen = new Set();
|
||||
for (const r of j.removed) {
|
||||
const rel = isObj(r) ? r.path : undefined;
|
||||
const bad = removalProblem(rel);
|
||||
if (bad) throw new Error(`removed: ${typeof rel === 'string' ? rel : JSON.stringify(rel)} is ${bad}`);
|
||||
if (seen.has(rel) || files.has(rel)) throw new Error(`removed: ${rel} is listed twice or also shipped`);
|
||||
if (!Array.isArray(r.sha256) || !r.sha256.length || !r.sha256.every((h) => typeof h === 'string' && HEX64.test(h))) throw new Error(`removed: ${rel} has no sha256`);
|
||||
seen.add(rel);
|
||||
removed.push({ path: rel, sha256: [...r.sha256] });
|
||||
}
|
||||
return { app: j.app, from: [...j.from], count: j.count, bytes: j.bytes, files, removed };
|
||||
}
|
||||
|
||||
const abs = (root, rel) => path.join(root, ...rel.split('/'));
|
||||
|
||||
/** Compare the files on disk with a Map of {size, sha256}: the paths missing (or not a file) and those with other bytes. */
|
||||
export function verifyFiles(root, files) {
|
||||
const missing = [];
|
||||
const mismatched = [];
|
||||
for (const [rel, want] of files) {
|
||||
const p = abs(root, rel);
|
||||
let st;
|
||||
try { st = fs.statSync(p); } catch { missing.push(rel); continue; }
|
||||
if (!st.isFile()) { missing.push(rel); continue; }
|
||||
if (st.size !== want.size) { mismatched.push(rel); continue; }
|
||||
const got = digestFile(p);
|
||||
if (!got || got.sha256 !== want.sha256) mismatched.push(rel);
|
||||
}
|
||||
return { checked: files.size, missing, mismatched };
|
||||
}
|
||||
|
||||
const inside = (root, p) => { const r = path.relative(root, p); return r !== '' && !r.startsWith('..') && !path.isAbsolute(r); };
|
||||
|
||||
/**
|
||||
* Delete the `removed` files that still hold bytes a base shipped, then the folders that leaves empty. `keep`: the
|
||||
* lower-cased paths of the new version — a removed path that differs from one only in case is the same file on
|
||||
* Windows / macOS.
|
||||
* @param {string} root
|
||||
* @param {{ path: string, sha256: string[] }[]} removed
|
||||
* @param {Set<string>} [keep]
|
||||
* @returns {{ deleted: string[], kept: string[], skipped: string[], absent: number }}
|
||||
*/
|
||||
export function deleteRemoved(root, removed, keep = new Set()) {
|
||||
const base = path.resolve(root);
|
||||
let real;
|
||||
try { real = fs.realpathSync(base); } catch { real = base; }
|
||||
const out = { deleted: [], kept: [], skipped: [], absent: 0 };
|
||||
for (const { path: rel, sha256 } of removed) {
|
||||
const bad = removalProblem(rel);
|
||||
if (bad) { out.skipped.push(`${rel} (${bad})`); continue; }
|
||||
if (keep.has(rel.toLowerCase())) { out.skipped.push(`${rel} (a file of the new version)`); continue; }
|
||||
const p = abs(base, rel);
|
||||
let st;
|
||||
try { st = fs.lstatSync(p); } catch { out.absent++; continue; }
|
||||
if (!st.isFile()) { out.skipped.push(`${rel} (not a regular file)`); continue; }
|
||||
let dir;
|
||||
try { dir = fs.realpathSync(path.dirname(p)); } catch { dir = null; }
|
||||
if (!dir || (dir !== real && !inside(real, dir))) { out.skipped.push(`${rel} (outside the install)`); continue; }
|
||||
const got = digestFile(p);
|
||||
if (!got || !sha256.includes(got.sha256)) { out.kept.push(rel); continue; }
|
||||
try { fs.unlinkSync(p); } catch (e) { out.skipped.push(`${rel} (${e?.code || e?.message || e})`); continue; }
|
||||
out.deleted.push(rel);
|
||||
for (let d = path.dirname(p); inside(base, d); d = path.dirname(d)) {
|
||||
try { fs.rmdirSync(d); } catch { break; }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The files data/local-assets.json lists that are not on disk (none when it is absent or unreadable). */
|
||||
export function localArtGaps(root) {
|
||||
let listed;
|
||||
try { listed = listedArtFiles(JSON.parse(fs.readFileSync(path.join(root, 'data', 'local-assets.json'), 'utf8'))); } catch { return []; }
|
||||
return [...listed].filter((rel) => { try { return !fs.statSync(abs(root, rel)).isFile(); } catch { return true; } }).sort();
|
||||
}
|
||||
|
||||
const NAMED = 5;
|
||||
const named = (list, sep) => list.slice(0, NAMED).join(sep) + (list.length > NAMED ? ' …' : '');
|
||||
|
||||
/**
|
||||
* Verify an install against its MANIFEST.json (npm run doctor; read-only).
|
||||
* @returns {{ state: 'none'|'broken'|'ok'|'mismatch', pending: boolean, app?: string, error?: string, checked?: number,
|
||||
* missing?: string[], mismatched?: string[], runtime?: string[], other?: string[] }}
|
||||
* none: no MANIFEST.json (a source checkout); runtime / other: the bad paths that do / do not stop an update
|
||||
*/
|
||||
export function checkInstall(root) {
|
||||
const base = path.resolve(root);
|
||||
const pending = fs.existsSync(path.join(base, UPDATE_FILE));
|
||||
let m;
|
||||
try { m = parseManifest(fs.readFileSync(path.join(base, MANIFEST_FILE), 'utf8')); } catch (e) {
|
||||
return e?.code === 'ENOENT' ? { state: 'none', pending } : { state: 'broken', pending, error: e?.message || String(e) };
|
||||
}
|
||||
const v = verifyFiles(base, m.files);
|
||||
const bad = [...v.missing, ...v.mismatched].sort();
|
||||
return {
|
||||
state: bad.length ? 'mismatch' : 'ok', pending, app: m.app, checked: v.checked, missing: v.missing, mismatched: v.mismatched,
|
||||
runtime: bad.filter(affectsRuntime), other: bad.filter((f) => !affectsRuntime(f)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finish an update package extracted over the install at `root` (the header's steps 1–4). Never throws.
|
||||
* @param {string} root
|
||||
* @param {{ log?: { log: Function, warn: Function, error: Function } }} [opts]
|
||||
* @returns {{ state: 'none'|'malformed'|'failed'|'applied', [k: string]: any }} failed: the server must not start
|
||||
*/
|
||||
export function applyPendingUpdate(root, { log = console } = {}) {
|
||||
try {
|
||||
return applyUpdate(path.resolve(root), log);
|
||||
} catch (e) {
|
||||
// a bug here must not keep a server down: report it and start as before
|
||||
log.error(`[update] 更新检查出错,已跳过 · the update check failed and was skipped: ${e?.stack || e}`);
|
||||
return { state: 'malformed', error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function applyUpdate(base, log) {
|
||||
const file = path.join(base, UPDATE_FILE);
|
||||
let text;
|
||||
try { text = fs.readFileSync(file, 'utf8'); } catch (e) {
|
||||
if (e?.code === 'ENOENT') return { state: 'none' };
|
||||
log.warn(`[update] ${UPDATE_FILE} 无法读取,已跳过,服务器按现有文件启动(${e?.code || e?.message})· could not read ${UPDATE_FILE}: skipped, the server starts with the files as they are`);
|
||||
return { state: 'malformed', error: String(e?.code || e?.message) };
|
||||
}
|
||||
let u;
|
||||
try { u = parseUpdate(text); } catch (e) {
|
||||
log.warn(`[update] ${UPDATE_FILE} 格式不对,已跳过,服务器按现有文件启动(${e.message})· ${UPDATE_FILE} is malformed: skipped, the server starts with the files as they are`);
|
||||
return { state: 'malformed', error: e.message };
|
||||
}
|
||||
const app = `v${u.app}`;
|
||||
const fromZh = u.from.map((v) => `v${v}`).join('、');
|
||||
const fromEn = u.from.map((v) => `v${v}`).join(' / ');
|
||||
log.log(`[update] 正在应用更新包 ${app}(适用于 ${fromZh}),校验文件… · applying the ${app} update package (for ${fromEn}): verifying the install…`);
|
||||
|
||||
let manifest = null;
|
||||
let reason = null;
|
||||
try { manifest = parseManifest(fs.readFileSync(path.join(base, MANIFEST_FILE), 'utf8')); } catch (e) {
|
||||
reason = e?.code === 'ENOENT' ? `${MANIFEST_FILE} is missing` : e?.message || String(e);
|
||||
}
|
||||
if (manifest && manifest.app !== u.app) reason = `${MANIFEST_FILE} belongs to v${manifest.app}`;
|
||||
if (reason) {
|
||||
log.error(`[update] 更新包 ${app} 没有完整解压(${reason})。服务器没有启动:请把更新包重新完整解压到安装文件夹,或下载 ${app} 的完整包。`);
|
||||
log.error(`[update] The ${app} update package is incomplete (${reason}). The server did not start: extract the whole update package over the install again, or download the full ${app} package.`);
|
||||
return { state: 'failed', reason };
|
||||
}
|
||||
|
||||
// the manifest (every non-art file of the new version) and the art files the update brought
|
||||
const want = new Map(manifest.files);
|
||||
for (const [rel, d] of u.files) if (!want.has(rel) && isSetupArt(rel)) want.set(rel, d);
|
||||
const v = verifyFiles(base, want);
|
||||
const bad = [...v.missing, ...v.mismatched].sort();
|
||||
const fatal = bad.filter((f) => !isSetupArt(f) && affectsRuntime(f));
|
||||
const notes = bad.filter((f) => !isSetupArt(f) && !affectsRuntime(f));
|
||||
const art = bad.filter(isSetupArt);
|
||||
if (fatal.length) {
|
||||
log.error(`[update] 这个更新包只能覆盖在 ${fromZh} 的整合包安装上(检测到 ${fatal.length} 个文件与 ${app} 不一致或缺失,例:${named(fatal, '、')})。服务器没有启动:请下载 ${app} 的完整包重新安装,或确认更新包已完整解压。`);
|
||||
log.error(`[update] This update package only applies over a ${fromEn} install (${fatal.length} files differ from ${app} or are missing, e.g. ${named(fatal, ', ')}). The server did not start: download the full ${app} package, or extract the update package again.`);
|
||||
return { state: 'failed', checked: v.checked, missing: v.missing, mismatched: v.mismatched, fatal, notes, art };
|
||||
}
|
||||
|
||||
const keep = new Set([...want.keys()].map((f) => f.toLowerCase()));
|
||||
const removal = deleteRemoved(base, u.removed, keep);
|
||||
let renamed = true;
|
||||
try { fs.renameSync(file, path.join(base, APPLIED_FILE)); } catch (e) {
|
||||
renamed = false;
|
||||
log.warn(`[update] ${UPDATE_FILE} 无法改名为 ${APPLIED_FILE}(${e?.code || e?.message}),下次启动会再检查一遍 · could not rename ${UPDATE_FILE}: the next start checks again`);
|
||||
}
|
||||
log.log(`[update] 已更新到 ${app}:${v.checked} 个文件校验通过,删除 ${removal.deleted.length} 个旧文件 · updated to ${app}: ${v.checked} files verified, ${removal.deleted.length} old files deleted`);
|
||||
if (notes.length) log.warn(`[update] ${notes.length} 个说明 / 脚本文件与 ${app} 不一致(不影响运行,例:${named(notes, '、')})· ${notes.length} doc / script files differ from ${app} (the server is not affected)`);
|
||||
if (art.length) log.warn(`[update] ${art.length} 个素材文件没有完整解压(例:${named(art, '、')}),画面会缺图:重新解压更新包即可 · ${art.length} art files of the update are missing or damaged: extract the update package again`);
|
||||
if (removal.kept.length) log.warn(`[update] 保留了 ${removal.kept.length} 个在这台电脑上改过的旧文件(新版本已不再使用,可以手动删除):${named(removal.kept, '、')} · kept ${removal.kept.length} old files changed on this machine (no longer used)`);
|
||||
if (removal.skipped.length) log.warn(`[update] ${removal.skipped.length} 个旧文件没有删除:${named(removal.skipped, '、')} · ${removal.skipped.length} old files were not deleted`);
|
||||
const gaps = u.files.has('data/local-assets.json') ? localArtGaps(base) : [];
|
||||
if (gaps.length) {
|
||||
log.warn(`[update] 本地客户端素材不完整:data/local-assets.json 列出的 ${gaps.length} 个文件不在这台电脑上(这个安装原来没有本地素材?例:${named(gaps, '、')})。从 ${app} 的完整包复制 public/assets/local 和 data/local-assets.json,或运行 node tools/setup.mjs --local 重新提取;不需要时删除 data/local-assets.json 即可 · the local-client art is incomplete (${gaps.length} files data/local-assets.json lists are missing): copy public/assets/local and data/local-assets.json from the full ${app} package, re-extract with node tools/setup.mjs --local, or delete data/local-assets.json`);
|
||||
}
|
||||
return { state: 'applied', renamed, checked: v.checked, notes, art, localGaps: gaps, ...removal };
|
||||
}
|
||||
@@ -247,7 +247,8 @@ test('a --no-install build of the temporary checkout zips exactly the plan in on
|
||||
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());
|
||||
// and MANIFEST.json, written after npm ci (server/update.js; test/update-package.test.js checks its content)
|
||||
assert.deepEqual(entries.sort(), [...SHIPPED_TRACKED, ...ART, 'MANIFEST.json'].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');
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// test/update-boot.test.js — the update package on the player's machine (server/update.js, docs/DEPLOY.md §1.5): the
|
||||
// paths UPDATE.json may name, the boot step that finishes an update (deletes, renames, refuses a mismatched install,
|
||||
// skips a malformed file), and the MANIFEST.json check `npm run doctor` shows. Temporary folders only.
|
||||
// Run: node --test test/update-boot.test.js (the packaging side: test/update-package.test.js)
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, affectsRuntime, applyPendingUpdate, checkInstall, deleteRemoved, digestFile,
|
||||
inManifest, isSetupArt, parseManifest, parseUpdate, pathProblem, removalProblem, verifyFiles,
|
||||
} from '../server/update.js';
|
||||
|
||||
const sha = (body) => crypto.createHash('sha256').update(body).digest('hex');
|
||||
const dg = (body) => ({ size: Buffer.byteLength(body), sha256: sha(body) });
|
||||
const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-update-${tag}-`));
|
||||
const put = (dir, rel, body = `// ${rel}\n`) => {
|
||||
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), body);
|
||||
};
|
||||
const has = (dir, rel) => fs.existsSync(path.join(dir, rel));
|
||||
const read = (dir, rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
|
||||
const quiet = () => {
|
||||
const lines = [];
|
||||
return { lines, log: { log: (m) => lines.push(['log', m]), warn: (m) => lines.push(['warn', m]), error: (m) => lines.push(['error', m]) } };
|
||||
};
|
||||
/** MANIFEST.json / UPDATE.json as tools/package-update.mjs writes them (the same keys; its line layout is not needed here). */
|
||||
const manifestText = ({ app, files }) => JSON.stringify({ format: 1, app, files: Object.fromEntries(files) });
|
||||
const updateText = ({ app, from, files, removed }) => JSON.stringify({ format: 1, app, from, count: files.size,
|
||||
bytes: [...files.values()].reduce((n, d) => n + d.size, 0), files: Object.fromEntries(files), removed });
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// The paths an update may name
|
||||
|
||||
test('refusal rules: traversal, absolute and drive paths, backslashes, per-machine folders and the update files are never removed', () => {
|
||||
for (const p of ['../x', 'a/../../x', 'server/../../etc/passwd', 'a/./b', 'a//b', 'a/', '', '..', '.']) assert.ok(pathProblem(p), `traversal: ${JSON.stringify(p)}`);
|
||||
for (const p of ['/etc/passwd', 'C:/Windows/x', 'C:\\Windows\\x', 'c:x', '\\\\server\\share\\x', 'server\\index.js', 'a\0b', 'a/b.', 'a/b ']) {
|
||||
assert.ok(pathProblem(p), `absolute / not plain: ${JSON.stringify(p)}`);
|
||||
}
|
||||
for (const p of [null, undefined, 3, {}]) assert.ok(pathProblem(p) && removalProblem(p), String(p));
|
||||
for (const p of ['.git/config', '.cache/assets-report.json', 'logs/server.log', '.venv-extract/bin/python', 'tools/local-extract/.venv/x',
|
||||
'data/.env', '.env.local', 'scripts/service.env.cmd', 'node_modules/.cache/x', 'handoff/HANDOFF.md', '.claude/settings.json',
|
||||
MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) {
|
||||
assert.ok(removalProblem(p), `never removed: ${p}`);
|
||||
assert.equal(pathProblem(p), null, `${p} may still be listed (a manifest path is only read)`);
|
||||
}
|
||||
for (const p of ['../x', '/etc/passwd', 'C:/x']) assert.ok(removalProblem(p), p);
|
||||
for (const p of ['server/old.js', 'packs/qab/pack.json', 'node_modules/three/build/three.cjs', 'public/assets/ui/b c.png', 'data/i18n/zh-TW.json',
|
||||
'docs/research/07-assets.json', 'README.md']) {
|
||||
assert.equal(removalProblem(p), null, p);
|
||||
}
|
||||
// a dependency's own .env.example can be verified (MANIFEST.json) but an update never deletes it
|
||||
const m = parseManifest(JSON.stringify({ format: 1, app: '0.2.1', files: { 'node_modules/x/.env.example': dg('x') } }));
|
||||
assert.deepEqual([...m.files.keys()], ['node_modules/x/.env.example']);
|
||||
});
|
||||
|
||||
const H = sha('x');
|
||||
const goodUpdate = (patch = {}) => JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 1, bytes: 1,
|
||||
files: { 'server/a.js': { size: 1, sha256: H } }, removed: [{ path: 'server/old.js', sha256: [H] }], ...patch });
|
||||
|
||||
test('UPDATE.json is read strictly: one bad entry and the whole file is refused', () => {
|
||||
const u = parseUpdate(goodUpdate());
|
||||
assert.equal(u.app, '0.2.1');
|
||||
assert.deepEqual(u.removed, [{ path: 'server/old.js', sha256: [H] }]);
|
||||
assert.deepEqual([...u.files.keys()], ['server/a.js']);
|
||||
const bad = {
|
||||
'not JSON': '{"format": 1,',
|
||||
'not an object': '[]',
|
||||
'another format': goodUpdate({ format: 2 }),
|
||||
'no version': goodUpdate({ app: 'latest' }),
|
||||
'no base': goodUpdate({ from: [] }),
|
||||
'no count': goodUpdate({ count: -1 }),
|
||||
'files not an object': goodUpdate({ files: [] }),
|
||||
'a file without a hash': goodUpdate({ files: { 'server/a.js': { size: 1 } } }),
|
||||
'a file outside the install': goodUpdate({ files: { '../a.js': { size: 1, sha256: H } } }),
|
||||
'removed not a list': goodUpdate({ removed: {} }),
|
||||
'traversal': goodUpdate({ removed: [{ path: '../../outside.txt', sha256: [H] }] }),
|
||||
'absolute': goodUpdate({ removed: [{ path: '/etc/hosts', sha256: [H] }] }),
|
||||
'a user folder': goodUpdate({ removed: [{ path: 'logs/server.log', sha256: [H] }] }),
|
||||
'twice': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [H] }, { path: 'server/old.js', sha256: [H] }] }),
|
||||
'shipped and removed': goodUpdate({ removed: [{ path: 'server/a.js', sha256: [H] }] }),
|
||||
'no hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: [] }] }),
|
||||
'a bad hash': goodUpdate({ removed: [{ path: 'server/old.js', sha256: ['abc'] }] }),
|
||||
'a bare path': goodUpdate({ removed: ['server/old.js'] }),
|
||||
};
|
||||
for (const [what, text] of Object.entries(bad)) assert.throws(() => parseUpdate(text), undefined, what);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// The boot step
|
||||
|
||||
/** An install of the new version: `files` on disk, MANIFEST.json over the non-art ones, UPDATE.json as given. */
|
||||
function newInstall(onDisk, { app = '0.2.1', from = ['0.2.0'], shipped = [], removed = [], manifest = null } = {}) {
|
||||
const dir = tmp('inst');
|
||||
for (const [rel, body] of Object.entries(onDisk)) put(dir, rel, body);
|
||||
const listed = new Map();
|
||||
for (const rel of manifest ?? Object.keys(onDisk).filter(inManifest)) listed.set(rel, digestFile(path.join(dir, rel)));
|
||||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app, files: listed }));
|
||||
const ship = new Map(shipped.map((rel) => [rel, digestFile(path.join(dir, rel))]));
|
||||
fs.writeFileSync(path.join(dir, UPDATE_FILE), updateText({ app, from, files: ship, removed }));
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('boot apply: deletes the removed files that hold a base\'s bytes, keeps changed ones and players\' packs, renames UPDATE.json, then does nothing', () => {
|
||||
const dir = newInstall({
|
||||
'server/index.js': 'new', 'shared/constants.js': "APP_VERSION='0.2.1'", 'README.md': 'readme',
|
||||
// left over from 0.2.0
|
||||
'server/old.js': 'old', 'server/lib/only/gone.js': 'gone', 'data/old.json': 'edited here', 'packs/qab/pack.json': 'shipped pack',
|
||||
'packs/mine/pack.json': 'a player\'s own pack',
|
||||
}, {
|
||||
shipped: ['server/index.js', 'shared/constants.js'],
|
||||
manifest: ['server/index.js', 'shared/constants.js', 'README.md'],
|
||||
removed: [
|
||||
{ path: 'server/old.js', sha256: [sha('old')] },
|
||||
{ path: 'server/lib/only/gone.js', sha256: [sha('gone')] },
|
||||
{ path: 'data/old.json', sha256: [sha('as shipped')] },
|
||||
{ path: 'packs/qab/pack.json', sha256: [sha('shipped pack')] },
|
||||
{ path: 'packs/mine/pack.json', sha256: [sha('the 0.2.0 pack of that name')] },
|
||||
{ path: 'server/never-here.js', sha256: [sha('x')] },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { lines, log } = quiet();
|
||||
const r = applyPendingUpdate(dir, { log });
|
||||
assert.equal(r.state, 'applied', JSON.stringify(lines));
|
||||
assert.deepEqual(r.deleted.sort(), ['packs/qab/pack.json', 'server/lib/only/gone.js', 'server/old.js']);
|
||||
assert.deepEqual(r.kept.sort(), ['data/old.json', 'packs/mine/pack.json'], 'other bytes than any base shipped: left alone');
|
||||
assert.equal(r.absent, 1);
|
||||
assert.ok(!has(dir, 'server/old.js') && !has(dir, 'server/lib'), 'the folders it leaves empty go too');
|
||||
assert.ok(has(dir, 'server/index.js') && has(dir, 'packs/mine/pack.json') && has(dir, 'data/old.json'));
|
||||
assert.ok(!has(dir, 'packs/qab') && has(dir, 'packs'), 'an emptied pack folder goes, the packs folder with another pack stays');
|
||||
assert.ok(!has(dir, UPDATE_FILE) && has(dir, APPLIED_FILE), 'UPDATE.json → .update-applied.json');
|
||||
assert.equal(parseUpdate(read(dir, APPLIED_FILE)).app, '0.2.1');
|
||||
assert.match(lines.map((l) => l[1]).join('\n'), /已更新到 v0\.2\.1:3 个文件校验通过,删除 3 个旧文件 · updated to v0\.2\.1/);
|
||||
assert.ok(lines.some(([k, m]) => k === 'warn' && /保留了 2 个/.test(m)), 'the kept files are named');
|
||||
// idempotent: nothing pending, nothing changes
|
||||
const before = fs.readdirSync(dir, { recursive: true }).sort();
|
||||
assert.equal(applyPendingUpdate(dir, { log }).state, 'none');
|
||||
assert.deepEqual(fs.readdirSync(dir, { recursive: true }).sort(), before);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('boot apply: a malformed UPDATE.json is logged and skipped — nothing deleted, the file kept, never a throw', () => {
|
||||
const outside = tmp('outside');
|
||||
put(outside, 'victim.txt', 'keep me');
|
||||
const dir = newInstall({ 'server/index.js': 'new', 'server/old.js': 'old' });
|
||||
try {
|
||||
const cases = [
|
||||
'{"format": 1, "app": "0.2.1"', // truncated
|
||||
JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {},
|
||||
removed: [{ path: 'server/old.js', sha256: [sha('old')] }, { path: `../${path.basename(outside)}/victim.txt`, sha256: [sha('keep me')] }] }),
|
||||
JSON.stringify({ format: 1, app: '0.2.1', from: ['0.2.0'], count: 0, bytes: 0, files: {}, removed: [{ path: path.join(outside, 'victim.txt'), sha256: [sha('keep me')] }] }),
|
||||
JSON.stringify({ format: 99 }),
|
||||
'null',
|
||||
];
|
||||
for (const text of cases) {
|
||||
fs.writeFileSync(path.join(dir, UPDATE_FILE), text);
|
||||
const { lines, log } = quiet();
|
||||
const r = applyPendingUpdate(dir, { log });
|
||||
assert.equal(r.state, 'malformed', text);
|
||||
assert.ok(has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE), 'left in place');
|
||||
assert.ok(has(dir, 'server/old.js'), 'one bad entry: not even the good ones are deleted');
|
||||
assert.equal(read(outside, 'victim.txt'), 'keep me');
|
||||
assert.match(lines[0][1], /UPDATE\.json 格式不对,已跳过,服务器按现有文件启动/);
|
||||
}
|
||||
fs.rmSync(path.join(dir, UPDATE_FILE));
|
||||
fs.mkdirSync(path.join(dir, UPDATE_FILE)); // unreadable (a folder)
|
||||
assert.equal(applyPendingUpdate(dir, quiet()).state, 'malformed');
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('boot apply: a file the server runs that does not match MANIFEST.json stops the start; nothing is deleted, UPDATE.json stays', () => {
|
||||
const dir = newInstall({ 'server/index.js': 'new', 'data/chess.json': '{}', 'server/old.js': 'old' }, {
|
||||
manifest: ['server/index.js', 'data/chess.json'],
|
||||
removed: [{ path: 'server/old.js', sha256: [sha('old')] }],
|
||||
});
|
||||
try {
|
||||
put(dir, 'data/chess.json', '{"old": 1}'); // the base's copy: the update was extracted over another version
|
||||
fs.rmSync(path.join(dir, 'server/index.js'));
|
||||
const { lines, log } = quiet();
|
||||
const r = applyPendingUpdate(dir, { log });
|
||||
assert.equal(r.state, 'failed');
|
||||
assert.deepEqual(r.fatal, ['data/chess.json', 'server/index.js']);
|
||||
assert.ok(has(dir, 'server/old.js') && has(dir, UPDATE_FILE) && !has(dir, APPLIED_FILE));
|
||||
const text = lines.filter(([k]) => k === 'error').map((l) => l[1]).join('\n');
|
||||
assert.match(text, /这个更新包只能覆盖在 v0\.2\.0 的整合包安装上(检测到 2 个文件与 v0\.2\.1 不一致或缺失,例:data\/chess\.json、server\/index\.js)。服务器没有启动:请下载 v0\.2\.1 的完整包/);
|
||||
assert.match(text, /This update package only applies over a v0\.2\.0 install \(2 files differ from v0\.2\.1 or are missing/);
|
||||
// fixed (the right files extracted): the next start applies it
|
||||
put(dir, 'data/chess.json', '{}');
|
||||
put(dir, 'server/index.js', 'new');
|
||||
assert.equal(applyPendingUpdate(dir, quiet()).state, 'applied');
|
||||
assert.ok(!has(dir, 'server/old.js'));
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('boot apply: a missing or foreign MANIFEST.json stops the start; docs-only differences and missing art are warnings', () => {
|
||||
const a = newInstall({ 'server/index.js': 'new' });
|
||||
const b = newInstall({ 'server/index.js': 'new' });
|
||||
const c = newInstall({ 'server/index.js': 'new', 'README.md': 'readme', 'scripts/start.sh': 'sh', 'node_modules/ws/README.md': 'r', 'public/assets/x.png': 'x' },
|
||||
{ shipped: ['public/assets/x.png'] });
|
||||
try {
|
||||
fs.rmSync(path.join(a, MANIFEST_FILE));
|
||||
let q = quiet();
|
||||
assert.equal(applyPendingUpdate(a, { log: q.log }).state, 'failed');
|
||||
assert.match(q.lines.map((l) => l[1]).join('\n'), /没有完整解压(MANIFEST\.json is missing)/);
|
||||
fs.writeFileSync(path.join(b, MANIFEST_FILE), manifestText({ app: '0.2.2', files: new Map() }));
|
||||
q = quiet();
|
||||
assert.equal(applyPendingUpdate(b, { log: q.log }).state, 'failed');
|
||||
assert.match(q.lines.map((l) => l[1]).join('\n'), /MANIFEST\.json belongs to v0\.2\.2/);
|
||||
put(c, 'README.md', 'edited');
|
||||
put(c, 'scripts/start.sh', 'edited');
|
||||
put(c, 'node_modules/ws/README.md', 'edited');
|
||||
fs.rmSync(path.join(c, 'public/assets/x.png'));
|
||||
q = quiet();
|
||||
const r = applyPendingUpdate(c, { log: q.log });
|
||||
assert.equal(r.state, 'applied');
|
||||
assert.deepEqual(r.notes, ['README.md', 'node_modules/ws/README.md', 'scripts/start.sh']);
|
||||
assert.deepEqual(r.art, ['public/assets/x.png']);
|
||||
const warned = q.lines.filter(([k]) => k === 'warn').map((l) => l[1]).join('\n');
|
||||
assert.match(warned, /3 个说明 \/ 脚本文件与 v0\.2\.1 不一致(不影响运行/);
|
||||
assert.match(warned, /1 个素材文件没有完整解压/);
|
||||
} finally {
|
||||
for (const d of [a, b, c]) fs.rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('boot apply: a local-art manifest the update brought to an install without that art is named (a lite install)', () => {
|
||||
const local = JSON.stringify({ groups: { g: { x: { path: '/assets/local/g/x.webp' }, y: { path: '/assets/local/g/y.webp' } } } });
|
||||
const dir = newInstall({ 'server/index.js': 'new', 'data/local-assets.json': local, 'public/assets/local/g/y.webp': 'y' },
|
||||
{ shipped: ['data/local-assets.json', 'public/assets/local/g/y.webp'] });
|
||||
try {
|
||||
const q = quiet();
|
||||
const r = applyPendingUpdate(dir, { log: q.log });
|
||||
assert.equal(r.state, 'applied');
|
||||
assert.deepEqual(r.localGaps, ['public/assets/local/g/x.webp']);
|
||||
assert.match(q.lines.map((l) => l[1]).join('\n'), /本地客户端素材不完整:data\/local-assets\.json 列出的 1 个文件不在这台电脑上/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('deleting removed files: never through a symlink, never a folder, never a file of the new version that differs only in case', () => {
|
||||
const dir = tmp('del');
|
||||
const outside = tmp('outside');
|
||||
try {
|
||||
put(outside, 'x.js', 'old');
|
||||
put(dir, 'server/dir.js/inner.js', 'old');
|
||||
put(dir, 'public/assets/X.png', 'png');
|
||||
let links = true;
|
||||
try {
|
||||
fs.symlinkSync(path.join(outside, 'x.js'), path.join(dir, 'server', 'link.js'));
|
||||
fs.symlinkSync(outside, path.join(dir, 'linked'), 'dir');
|
||||
} catch { links = false; } // Windows without the symlink privilege
|
||||
const r = deleteRemoved(dir, [
|
||||
{ path: 'server/link.js', sha256: [sha('old')] },
|
||||
{ path: 'linked/x.js', sha256: [sha('old')] },
|
||||
{ path: 'server/dir.js', sha256: [sha('old')] },
|
||||
{ path: 'public/assets/X.png', sha256: [sha('png')] },
|
||||
{ path: '../escape.js', sha256: [sha('old')] },
|
||||
], new Set(['public/assets/x.png']));
|
||||
assert.deepEqual(r.deleted, []);
|
||||
assert.equal(read(outside, 'x.js'), 'old', 'the symlink targets are untouched');
|
||||
assert.ok(has(dir, 'public/assets/X.png') && has(dir, 'server/dir.js/inner.js'));
|
||||
const why = r.skipped.join('\n');
|
||||
if (links) {
|
||||
assert.match(why, /server\/link\.js \(not a regular file\)/);
|
||||
assert.match(why, /linked\/x\.js \(outside the install\)/);
|
||||
}
|
||||
assert.match(why, /server\/dir\.js \(not a regular file\)/);
|
||||
assert.match(why, /public\/assets\/X\.png \(a file of the new version\)/);
|
||||
assert.match(why, /\.\.\/escape\.js/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
fs.rmSync(outside, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// The manifest check
|
||||
|
||||
test('manifest verification: missing files, other sizes, same size other bytes, a folder in place of a file; doctor\'s states', () => {
|
||||
const dir = tmp('verify');
|
||||
try {
|
||||
put(dir, 'a.js', 'aaaa');
|
||||
put(dir, 'b.js', 'bbbb');
|
||||
put(dir, 'c.js', 'cccc');
|
||||
fs.mkdirSync(path.join(dir, 'd.js'));
|
||||
const v = verifyFiles(dir, new Map([['a.js', dg('aaaa')], ['b.js', dg('bbbbb')], ['c.js', dg('CCCC')], ['d.js', dg('d')], ['e.js', dg('e')]]));
|
||||
assert.deepEqual(v, { checked: 5, missing: ['d.js', 'e.js'], mismatched: ['b.js', 'c.js'] });
|
||||
|
||||
assert.deepEqual(checkInstall(dir), { state: 'none', pending: false }, 'no MANIFEST.json: a source checkout');
|
||||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), '{');
|
||||
assert.equal(checkInstall(dir).state, 'broken');
|
||||
const listed = new Map([['a.js', dg('aaaa')], ['README.md', dg('readme')]]);
|
||||
put(dir, 'README.md', 'readme');
|
||||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), manifestText({ app: '0.2.1', files: listed }));
|
||||
assert.deepEqual(checkInstall(dir), { state: 'ok', pending: false, app: '0.2.1', checked: 2, missing: [], mismatched: [], runtime: [], other: [] });
|
||||
put(dir, 'README.md', 'edited');
|
||||
put(dir, 'a.js', 'AAAA');
|
||||
put(dir, UPDATE_FILE, '{}');
|
||||
const c = checkInstall(dir);
|
||||
assert.equal(c.state, 'mismatch');
|
||||
assert.deepEqual([c.runtime, c.other, c.pending], [['a.js'], ['README.md'], true]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('what MANIFEST.json lists and what stops an update', () => {
|
||||
for (const f of ['public/assets/char/a.png', 'public/fonts/fonts.css', 'data/assets.json', 'data/local-assets.json']) {
|
||||
assert.ok(isSetupArt(f) && !inManifest(f), f);
|
||||
}
|
||||
for (const f of [MANIFEST_FILE, UPDATE_FILE, APPLIED_FILE]) assert.ok(!inManifest(f), f);
|
||||
for (const f of ['server/index.js', 'data/chess.json', 'node_modules/ws/index.js', 'public/vendor/pixi.min.js', 'README.md', 'packs/index.json']) {
|
||||
assert.ok(inManifest(f), f);
|
||||
}
|
||||
for (const f of ['server/index.js', 'shared/constants.js', 'data/chess.json', 'public/js/main.js', 'public/vendor/pixi.min.js', 'packs/index.json',
|
||||
'node_modules/ws/package.json', 'node_modules/ws/lib/websocket.js', 'node_modules/x/license.js', 'docs/research/03-operators.json', 'package.json']) {
|
||||
assert.ok(affectsRuntime(f), `${f} stops an update`);
|
||||
}
|
||||
for (const f of ['README.md', 'CHANGELOG.md', 'LICENSE', 'NOTICE.md', 'docs/DEPLOY.md', 'node_modules/ws/README.md', 'node_modules/three/LICENSE',
|
||||
'node_modules/preact/src/index.d.ts', 'node_modules/x/dist/x.js.map', 'scripts/start-windows.bat', 'tools/setup.mjs',
|
||||
'tools/local-extract/LICENSE-Ark-Unpacker.txt', 'package-lock.json', 'node_modules/.package-lock.json']) {
|
||||
assert.ok(!affectsRuntime(f), `${f} only warns`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
// test/update-package.test.js — the update package's packaging side (tools/package.mjs --update, tools/package-update.mjs,
|
||||
// docs/DEPLOY.md §7): the comparison with the bases, reading a base (full zip or folder) and refusing what cannot be one,
|
||||
// the zip reader, MANIFEST.json / UPDATE.json in the personal-info scan, MANIFEST.json in every zip and a whole update
|
||||
// round trip on a small temporary checkout (--no-install), finished by the boot step. No network, no npm install.
|
||||
// Run: node --test test/update-package.test.js (the player's side: test/update-boot.test.js)
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import zlib from 'node:zlib';
|
||||
import { APPLIED_FILE, MANIFEST_FILE, UPDATE_FILE, applyPendingUpdate, checkInstall, parseUpdate, removalProblem } from '../server/update.js';
|
||||
import { compareVersions, diffBases, readBase, readZip } from '../tools/package-update.mjs';
|
||||
import { FOLDER, readBases, scanFiles, stageProblems } from '../tools/package.mjs';
|
||||
|
||||
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const TOOL = path.join(ROOT, 'tools', 'package.mjs');
|
||||
const ENV = { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'plantedname' };
|
||||
|
||||
const sha = (body) => crypto.createHash('sha256').update(body).digest('hex');
|
||||
const dg = (body) => ({ size: Buffer.byteLength(body), sha256: sha(body) });
|
||||
const tmp = (tag) => fs.mkdtempSync(path.join(os.tmpdir(), `sp-update-${tag}-`));
|
||||
const put = (dir, rel, body = `// ${rel}\n`) => {
|
||||
fs.mkdirSync(path.join(dir, path.dirname(rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, rel), body);
|
||||
};
|
||||
const has = (dir, rel) => fs.existsSync(path.join(dir, rel));
|
||||
const read = (dir, rel) => fs.readFileSync(path.join(dir, rel), 'utf8');
|
||||
const quiet = () => {
|
||||
const lines = [];
|
||||
return { lines, log: { log: (m) => lines.push(['log', m]), warn: (m) => lines.push(['warn', m]), error: (m) => lines.push(['error', m]) } };
|
||||
};
|
||||
const files = (obj) => new Map(Object.entries(obj).map(([rel, body]) => [rel, dg(body)]));
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// The comparison
|
||||
|
||||
test('diff: a file ships when it is new or differs from the base; removed = what the base shipped and the new one does not', () => {
|
||||
const next = files({ 'server/a.js': 'A2', 'server/b.js': 'B', 'server/new.js': 'N', 'public/assets/x.png': 'X' });
|
||||
const base = { version: '0.2.0', files: files({ 'server/a.js': 'A1', 'server/b.js': 'B', 'server/gone.js': 'G', 'public/assets/x.png': 'X' }) };
|
||||
const d = diffBases(next, [base]);
|
||||
assert.deepEqual(d.ship, ['server/a.js', 'server/new.js'], 'changed + added; unchanged files and art stay out');
|
||||
assert.deepEqual(d.removed, [{ path: 'server/gone.js', sha256: [sha('G')] }]);
|
||||
assert.deepEqual(d.perBase, [{ version: '0.2.0', added: 1, changed: 1, unchanged: 2, removed: 1 }]);
|
||||
assert.deepEqual(d.caseOnly, []);
|
||||
// same size, other bytes (a version string 0.2.0 → 0.2.1) still ships
|
||||
const same = diffBases(files({ 'shared/constants.js': "v='0.2.1'" }), [{ version: '0.2.0', files: files({ 'shared/constants.js': "v='0.2.0'" }) }]);
|
||||
assert.deepEqual(same.ship, ['shared/constants.js']);
|
||||
});
|
||||
|
||||
test('diff with two bases: cumulative — one update works over each; a file changed in the later base and reverted ships', () => {
|
||||
const v020 = { version: '0.2.0', files: files({ a: 'a0', b: 'b0', c: 'c0', d: 'd0', g: 'g0', 'P/x.png': 'p' }) };
|
||||
const v021 = { version: '0.2.1', files: files({ a: 'a1', b: 'b0', c: 'c1', e: 'e1', g: 'g1', 'P/x.png': 'p' }) };
|
||||
// 0.2.2: a reverted to 0.2.0's bytes, b unchanged everywhere, c new bytes, d re-added after 0.2.1 dropped it,
|
||||
// e (only 0.2.1 had it) and g (both had it, with other bytes) dropped, P/X.png renamed by case only
|
||||
const next = files({ a: 'a0', b: 'b0', c: 'c2', d: 'd0', 'P/X.png': 'p' });
|
||||
const d = diffBases(next, [v020, v021]);
|
||||
assert.deepEqual(d.ship, ['P/X.png', 'a', 'c', 'd'], 'a differs from 0.2.1, d is missing in 0.2.1, c differs from both');
|
||||
assert.ok(!d.ship.includes('b'), 'the same in every base: not shipped');
|
||||
assert.deepEqual(d.removed, [
|
||||
{ path: 'e', sha256: [sha('e1')] },
|
||||
{ path: 'g', sha256: [sha('g0'), sha('g1')].sort() },
|
||||
], 'each base\'s bytes, so the copy of either install is recognised');
|
||||
assert.deepEqual(d.caseOnly, ['P/x.png'], 'one file on Windows / macOS: never deleted');
|
||||
assert.deepEqual(d.perBase.map((b) => [b.version, b.added, b.changed, b.unchanged, b.removed]), [
|
||||
['0.2.0', 1, 1, 3, 2], // P/X.png is new to both bases (compared case-sensitively); removed: g and P/x.png
|
||||
['0.2.1', 2, 2, 1, 3], // added: d and P/X.png; changed: a and c; removed: e, g and P/x.png
|
||||
]);
|
||||
});
|
||||
|
||||
test('diff: a name no update deletes (node_modules/x/.env.example) is left out of removed and listed as left', () => {
|
||||
const base = { version: '0.2.0', files: files({ 'node_modules/x/index.js': 'x', 'node_modules/x/.env.example': 'E=1', 'server/a.js': 'a' }) };
|
||||
const d = diffBases(files({ 'server/a.js': 'a' }), [base], { removable: (rel) => !removalProblem(rel) });
|
||||
assert.deepEqual(d.removed.map((r) => r.path), ['node_modules/x/index.js']);
|
||||
assert.deepEqual(d.left, ['node_modules/x/.env.example']);
|
||||
assert.deepEqual(diffBases(files({}), [base]).left, [], 'everything is removable by default');
|
||||
});
|
||||
|
||||
test('versions compare numerically, a pre-release before its release', () => {
|
||||
assert.equal(compareVersions('0.2.0', '0.2.1'), -1);
|
||||
assert.equal(compareVersions('0.10.0', '0.9.9'), 1);
|
||||
assert.equal(compareVersions('0.2.1-test', '0.2.1'), -1);
|
||||
assert.equal(compareVersions('0.2.0', '0.2.1-test'), -1);
|
||||
assert.equal(compareVersions('1.0.0', '1.0.0'), 0);
|
||||
assert.throws(() => compareVersions('x', '1.0.0'));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// The checks MANIFEST.json and UPDATE.json pass
|
||||
|
||||
test('MANIFEST.json and UPDATE.json pass the personal-info scan: paths are scanned, sha256 values (a hex-only name could match) are not', () => {
|
||||
const dir = tmp('scan');
|
||||
try {
|
||||
const hex = `${'0'.repeat(30)}beef${'0'.repeat(30)}`;
|
||||
fs.writeFileSync(path.join(dir, MANIFEST_FILE), `{"format":1,"app":"9.9.9","files":{\n"a.js":{"size":1,"sha256":"${hex}"}\n}}\n`);
|
||||
assert.deepEqual(scanFiles(dir, [MANIFEST_FILE], { names: ['beef'] }), [`${MANIFEST_FILE} (account name)`], 'the plain scan would refuse it');
|
||||
assert.deepEqual(scanFiles(dir, [MANIFEST_FILE], { names: ['beef'], strip: /[0-9a-f]{64}/g }), []);
|
||||
assert.deepEqual(stageProblems(dir, new Set([MANIFEST_FILE]), { env: { SP_PACKAGE_SCAN_USER: '0', SP_PACKAGE_SCAN_NAMES: 'beef' } }).problems, []);
|
||||
fs.writeFileSync(path.join(dir, UPDATE_FILE), `{"removed":[{"path":"${['', 'home', 'someone'].join('/')}/x"}]}`);
|
||||
assert.deepEqual(stageProblems(dir, new Set([MANIFEST_FILE, UPDATE_FILE]), { env: ENV }).problems, [`personal info: ${UPDATE_FILE} (home-directory path)`]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------------------------------------------------
|
||||
// Bases and a whole round trip on a temporary checkout (tools/package.mjs, --no-install)
|
||||
|
||||
const hasZipTool = !spawnSync('zip', ['-v'], { encoding: 'utf8' }).error
|
||||
|| /bsdtar/.test(spawnSync('tar', ['--version'], { encoding: 'utf8' }).stdout || '');
|
||||
const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
|
||||
|
||||
function fakeCheckout(version) {
|
||||
const dir = tmp('co');
|
||||
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' };
|
||||
put(dir, 'package.json', JSON.stringify({ name: 'sp-test', version, main: 'server/index.js', scripts, dependencies: {} }));
|
||||
put(dir, 'package-lock.json', JSON.stringify({ name: 'sp-test', version, lockfileVersion: 3, packages: { '': { name: 'sp-test', version } } }));
|
||||
put(dir, 'server/index.js', "import './sim/rng.js';\n");
|
||||
put(dir, 'server/sim/rng.js', 'export const rng = 1;\n');
|
||||
put(dir, 'server/old.js', 'export const old = 1;\n');
|
||||
put(dir, 'shared/constants.js', `export const APP_VERSION = '${version}';\n`);
|
||||
put(dir, 'public/index.html', '<!doctype html>\n');
|
||||
put(dir, 'data/chess.json', '{}\n');
|
||||
put(dir, 'data/assets.json', JSON.stringify({ chars: { a: { avatar: '/assets/char/a.png' } }, fonts: { css: '/fonts/fonts.css' } }));
|
||||
for (const f of ['tools/setup.mjs', 'tools/vendor.mjs', 'tools/fetch-assets.mjs', 'tools/doctor.mjs', 'scripts/launch.mjs', 'tools/golden.mjs']) put(dir, f);
|
||||
put(dir, 'README.md', '# readme\n');
|
||||
put(dir, '.gitignore', 'public/assets/\npublic/fonts/\ndata/local-assets.json\n');
|
||||
put(dir, 'public/assets/char/a.png', PNG);
|
||||
put(dir, 'public/fonts/fonts.css', '@font-face{}\n');
|
||||
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, git };
|
||||
}
|
||||
|
||||
function bump({ dir }, version) {
|
||||
for (const f of ['package.json', 'package-lock.json']) put(dir, f, read(dir, f).replaceAll(/"version":"[^"]+"/g, `"version":"${version}"`));
|
||||
put(dir, 'shared/constants.js', `export const APP_VERSION = '${version}';\n`);
|
||||
}
|
||||
|
||||
const runTool = (args, env = ENV) => spawnSync(process.execPath, [TOOL, ...args], { encoding: 'utf8', env: { ...process.env, ...env }, maxBuffer: 16 * 1024 * 1024 });
|
||||
|
||||
/** Extract a release zip into `dir` (over what is there), as a player would: readZip writes every file. */
|
||||
function extract(zipPath, dir) {
|
||||
readZip(zipPath, (name, data) => {
|
||||
const to = path.join(dir, ...name.split('/'));
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.writeFileSync(to, data);
|
||||
});
|
||||
}
|
||||
|
||||
test('a whole update: base zip v9.9.8 → --update v9.9.9 ships only what changed, the boot step finishes it over the extracted base', { skip: !hasZipTool && 'no zip / tar' }, () => {
|
||||
const co = fakeCheckout('9.9.8');
|
||||
const out = tmp('out');
|
||||
const inst = tmp('player');
|
||||
try {
|
||||
let r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const baseZip = path.join(out, `${FOLDER}-v9.9.8.zip`);
|
||||
// the next version: a code change, a new module, a dropped one, other art, the version
|
||||
bump(co, '9.9.9');
|
||||
put(co.dir, 'server/sim/rng.js', 'export const rng = 2;\n');
|
||||
put(co.dir, 'server/new.js', 'export const n = 1;\n');
|
||||
put(co.dir, 'public/assets/char/a.png', Buffer.concat([PNG, Buffer.from('v2')]));
|
||||
fs.rmSync(path.join(co.dir, 'server/old.js'));
|
||||
assert.equal(co.git('add', '-A').status, 0);
|
||||
|
||||
r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--from', baseZip]);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
assert.match(r.stdout, /^base: v9\.9\.8 · \d+ files \(1 art\) · /m);
|
||||
assert.match(r.stdout, /^update: v9\.9\.9 over v9\.9\.8$/m);
|
||||
assert.match(r.stdout, /^ {2}vs v9\.9\.8: 5 changed, 1 added, \d+ unchanged, 1 removed$/m);
|
||||
assert.match(r.stdout, /^removed: 1 file\(s\) \(server\/old\.js\)$/m);
|
||||
const upZip = path.join(out, `${FOLDER}-v9.9.9-update.zip`);
|
||||
assert.deepEqual(fs.readdirSync(out).sort(), [`${FOLDER}-v9.9.8.zip`, `${FOLDER}-v9.9.9-update.zip`], 'both stages removed');
|
||||
const got = new Map();
|
||||
readZip(upZip, (name, data) => got.set(name, data));
|
||||
const SHIP = ['package-lock.json', 'package.json', 'public/assets/char/a.png', 'server/new.js', 'server/sim/rng.js', 'shared/constants.js'];
|
||||
assert.deepEqual([...got.keys()].sort(), [...SHIP, MANIFEST_FILE, UPDATE_FILE].map((f) => `${FOLDER}/${f}`).sort());
|
||||
const u = parseUpdate(got.get(`${FOLDER}/${UPDATE_FILE}`).toString('utf8'));
|
||||
assert.deepEqual([u.app, u.from, u.count, [...u.files.keys()]], ['9.9.9', ['9.9.8'], 6, SHIP]);
|
||||
assert.equal(u.bytes, SHIP.reduce((n, f) => n + got.get(`${FOLDER}/${f}`).length, 0));
|
||||
assert.deepEqual(u.removed, [{ path: 'server/old.js', sha256: [sha('export const old = 1;\n')] }]);
|
||||
|
||||
// the full and lite zips of the same version carry the same MANIFEST.json, listing the non-art files
|
||||
r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--lite']);
|
||||
assert.equal(r.status, 0, r.stdout + r.stderr);
|
||||
const manifestOf = (zip) => { let m = null; readZip(path.join(out, zip), (n, d) => { if (n === `${FOLDER}/${MANIFEST_FILE}`) m = d.toString('utf8'); }); return m; };
|
||||
const m = manifestOf(`${FOLDER}-v9.9.9.zip`);
|
||||
assert.equal(manifestOf(`${FOLDER}-v9.9.9-lite.zip`), m);
|
||||
assert.equal(got.get(`${FOLDER}/${MANIFEST_FILE}`).toString('utf8'), m);
|
||||
const listed = JSON.parse(m);
|
||||
assert.equal(listed.app, '9.9.9');
|
||||
assert.deepEqual(Object.keys(listed.files).sort(), ['README.md', 'data/chess.json', 'package-lock.json', 'package.json', 'public/index.html',
|
||||
'scripts/launch.mjs', 'server/index.js', 'server/new.js', 'server/sim/rng.js', 'shared/constants.js', 'tools/doctor.mjs',
|
||||
'tools/fetch-assets.mjs', 'tools/setup.mjs', 'tools/vendor.mjs'], 'no art, no data/assets.json');
|
||||
assert.deepEqual(listed.files['server/new.js'], dg('export const n = 1;\n'));
|
||||
|
||||
// the player: the base extracted, a pack of their own, the update extracted over it, the server started
|
||||
extract(baseZip, inst);
|
||||
const root = path.join(inst, FOLDER);
|
||||
put(root, 'packs/mine/pack.json', '{"type":"lang"}');
|
||||
extract(upZip, inst);
|
||||
assert.equal(checkInstall(root).state, 'ok', 'the extracted update already matches its manifest');
|
||||
const q = quiet();
|
||||
const done = applyPendingUpdate(root, { log: q.log });
|
||||
assert.equal(done.state, 'applied', JSON.stringify(q.lines));
|
||||
assert.deepEqual(done.deleted, ['server/old.js']);
|
||||
assert.ok(!has(root, 'server/old.js') && has(root, APPLIED_FILE) && !has(root, UPDATE_FILE) && has(root, 'packs/mine/pack.json'));
|
||||
assert.equal(read(root, 'shared/constants.js'), "export const APP_VERSION = '9.9.9';\n");
|
||||
// the bases a later update may name: the full zip and the folder it was extracted to read the same
|
||||
const asZip = readBase(path.join(out, `${FOLDER}-v9.9.9.zip`));
|
||||
const fresh = tmp('fresh');
|
||||
try {
|
||||
extract(path.join(out, `${FOLDER}-v9.9.9.zip`), fresh);
|
||||
put(fresh, `${FOLDER}/.DS_Store`, 'finder');
|
||||
const asFolder = readBases([fresh], '9.9.10')[0];
|
||||
assert.deepEqual([...asFolder.files.keys()].sort(), [...asZip.files.keys()].filter((f) => f !== MANIFEST_FILE).sort(), 'MANIFEST.json and clutter left out');
|
||||
assert.equal(asFolder.version, '9.9.9');
|
||||
} finally {
|
||||
fs.rmSync(fresh, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
for (const d of [co.dir, out, inst]) fs.rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('bases are refused when they cannot be one: lite, an update zip, not older, twice, not a release; the options go together', { skip: !hasZipTool && 'no zip / tar' }, () => {
|
||||
const co = fakeCheckout('9.9.8');
|
||||
const out = tmp('out');
|
||||
try {
|
||||
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']).status, 0);
|
||||
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--lite']).status, 0);
|
||||
const full = path.join(out, `${FOLDER}-v9.9.8.zip`);
|
||||
const lite = path.join(out, `${FOLDER}-v9.9.8-lite.zip`);
|
||||
bump(co, '9.9.9');
|
||||
put(co.dir, 'server/sim/rng.js', 'export const rng = 2;\n');
|
||||
assert.equal(co.git('add', '-A').status, 0);
|
||||
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--from', full]).status, 0);
|
||||
const update = path.join(out, `${FOLDER}-v9.9.9-update.zip`);
|
||||
const refuse = (from, re) => {
|
||||
const r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--force', '--dry-run', '--from', from]);
|
||||
assert.equal(r.status, 1, r.stdout);
|
||||
assert.match(r.stderr, re);
|
||||
};
|
||||
refuse(lite, /has no game art \(a lite zip\?\): pass the full zip, Stronghold-Protocol-v9\.9\.8\.zip/);
|
||||
refuse(update, /is an update package: pass the earlier release's full zip/);
|
||||
refuse(`${full},${full}`, /two bases are v9\.9\.8/);
|
||||
refuse(path.join(out, 'nothing.zip'), /not found/);
|
||||
fs.writeFileSync(path.join(out, 'other.zip'), 'not a zip');
|
||||
refuse(path.join(out, 'other.zip'), /not a zip file/);
|
||||
assert.throws(() => readBases([full], '9.9.8'), /is v9\.9\.8, not older than v9\.9\.8/);
|
||||
const dry = runTool(['--root', co.dir, '--out', out, '--allow-dirty', '--update', '--dry-run', '--from', full]);
|
||||
assert.equal(dry.status, 0, dry.stderr);
|
||||
assert.match(dry.stdout, /^update: the bases are fine/m);
|
||||
for (const args of [['--from', full], ['--update'], ['--update', '--lite', '--from', full]]) {
|
||||
const r = runTool(['--root', co.dir, '--dry-run', ...args]);
|
||||
assert.equal(r.status, 2, args.join(' '));
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(co.dir, { recursive: true, force: true });
|
||||
fs.rmSync(out, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a base folder: npm\'s files are compared like the stage\'s, per-machine and clutter files left out; no update without npm ci', { skip: !hasZipTool && 'no zip / tar' }, () => {
|
||||
const co = fakeCheckout('9.9.8');
|
||||
const out = tmp('out');
|
||||
const base = tmp('base');
|
||||
try {
|
||||
assert.equal(runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty']).status, 0);
|
||||
extract(path.join(out, `${FOLDER}-v9.9.8.zip`), base);
|
||||
const root = path.join(base, FOLDER);
|
||||
put(root, 'logs/server.log', 'a log'); // per-machine: never part of a base
|
||||
put(root, 'scripts/service.env.cmd', 'set PORT=3000');
|
||||
put(root, 'public/.DS_Store', 'finder');
|
||||
put(root, 'node_modules/ws/.DS_Store', 'npm ships what it ships');
|
||||
const b = readBases([root], '9.9.9')[0];
|
||||
assert.ok(![...b.files.keys()].some((f) => /^(?:logs|scripts\/service|public\/\.DS)/.test(f)));
|
||||
assert.ok(b.files.has('node_modules/ws/.DS_Store'), 'node_modules is taken as npm wrote it, like the stage');
|
||||
// a base with node_modules and a build without npm ci: refused, it would delete them everywhere
|
||||
bump(co, '9.9.9');
|
||||
assert.equal(co.git('add', '-A').status, 0);
|
||||
const r = runTool(['--root', co.dir, '--out', out, '--no-install', '--allow-dirty', '--update', '--from', root]);
|
||||
assert.equal(r.status, 1, r.stdout);
|
||||
assert.match(r.stderr, /v9\.9\.8 ships node_modules\/ but this build has none \(--no-install\?\): an update built so would delete it from every install/);
|
||||
} finally {
|
||||
for (const d of [co.dir, out, base]) fs.rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('the zip reader: stored and deflated entries, zip64, UTF-8 names; damaged and foreign zips are refused', { skip: !hasZipTool && 'no zip / tar' }, () => {
|
||||
const dir = tmp('zip');
|
||||
try {
|
||||
put(dir, `${FOLDER}/package.json`, '{"version":"0.2.0"}');
|
||||
put(dir, `${FOLDER}/public/assets/卫戍 a.png`, Buffer.alloc(5000, 7));
|
||||
put(dir, `${FOLDER}/server/empty.js`, '');
|
||||
const zipped = [];
|
||||
const make = (name, args) => {
|
||||
const z = spawnSync('zip', ['-q', '-r', ...args, name, FOLDER], { cwd: dir });
|
||||
if (!z.error && z.status === 0) zipped.push(name);
|
||||
};
|
||||
make('plain.zip', ['-X']);
|
||||
make('stored.zip', ['-X', '-0']);
|
||||
make('zip64.zip', ['-X', '-fz']);
|
||||
if (!zipped.length) {
|
||||
const t = spawnSync('tar', ['-c', '--format', 'zip', '-f', 'plain.zip', FOLDER], { cwd: dir });
|
||||
if (!t.error && t.status === 0) zipped.push('plain.zip');
|
||||
}
|
||||
assert.ok(zipped.length > 0);
|
||||
for (const z of zipped) {
|
||||
const b = readBase(path.join(dir, z));
|
||||
assert.deepEqual([b.version, b.art, b.update], ['0.2.0', 1, false], z);
|
||||
assert.deepEqual([...b.files.keys()].sort(), ['package.json', 'public/assets/卫戍 a.png', 'server/empty.js'], z);
|
||||
assert.deepEqual(b.files.get('public/assets/卫戍 a.png'), dg(Buffer.alloc(5000, 7)), z);
|
||||
assert.deepEqual(b.files.get('server/empty.js'), dg(''), z);
|
||||
}
|
||||
// one damaged byte in a stored entry: the CRC refuses it (zlib.crc32, Node ≥ 22.2)
|
||||
if (zipped.includes('stored.zip') && typeof zlib.crc32 === 'function') {
|
||||
const bytes = fs.readFileSync(path.join(dir, 'stored.zip'));
|
||||
bytes[bytes.indexOf(Buffer.alloc(64, 7)) + 100] ^= 0xff;
|
||||
fs.writeFileSync(path.join(dir, 'bad.zip'), bytes);
|
||||
assert.throws(() => readBase(path.join(dir, 'bad.zip')), /bad\.zip: Stronghold-Protocol\/public\/assets\/卫戍 a\.png: CRC mismatch/);
|
||||
}
|
||||
const cut = fs.readFileSync(path.join(dir, zipped[0]));
|
||||
fs.writeFileSync(path.join(dir, 'cut.zip'), cut.subarray(0, cut.length - 30));
|
||||
assert.throws(() => readBase(path.join(dir, 'cut.zip')), /cut\.zip: (?:not a zip file|damaged|unexpected end)/, 'a truncated download');
|
||||
// entries outside the one folder: not a release zip
|
||||
put(dir, 'other/x.txt', 'x');
|
||||
const z = spawnSync('zip', ['-q', '-r', 'foreign.zip', 'other'], { cwd: dir });
|
||||
if (!z.error && z.status === 0) assert.throws(() => readBase(path.join(dir, 'foreign.zip')), /outside the Stronghold-Protocol\/ folder/);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+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