diff --git a/CHANGELOG.md b/CHANGELOG.md index dd75f822..d59699f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ## Unreleased +### O43 / O58 — npm one-line install + +Recommended install is one command after Node ≥ 22.16: + +```bash +npm install -g @rdk-moss/agent@latest +``` + +`package.json` is no longer `"private": true` (publishConfig stays `access: public`). +`scripts/check-node-version.cjs` runs from both `preinstall` and `prepare`, so a Node +older than 22.16 exits immediately with copyable upgrade commands for macOS (brew / +nvm / fnm), Ubuntu/Debian (NodeSource / nvm / fnm), and Windows (`winget`), plus the +success-path `npm install -g @rdk-moss/agent@latest`. A failing +`npm install -g --install-links .` does not leave a broken global `moss` shim. +`MOSS_TEST_FAKE_NODE_VERSION` lets tests exercise the failure path without an old +Node binary. README +(en + zh) leads with the npm one-liner; source clone remains the optional developer +path. No curl native-binary installer in this phase. + ### Install follow-ups `npm ci` runs `scripts/check-node-version.cjs` as `preinstall`. The script is @@ -96,7 +115,7 @@ package is installed, run `npm uninstall -g moss` first; npm otherwise stops wit EEXIST on the shared bin. Do not pass `--force`: both packages stay installed, and a later `npm uninstall -g moss` removes the `moss` command. That uninstall leaves `@rdk-moss/agent` in place when `--force` was not used. -`npm install -g @rdk-moss/agent` is coming soon. +`npm install -g @rdk-moss/agent@latest` is the recommended install path. ### `workspace-write` copy no longer implies an OS sandbox diff --git a/README.md b/README.md index 1c541e35..0acb0bf0 100644 --- a/README.md +++ b/README.md @@ -23,25 +23,28 @@ Moss 是一个精简的跨平台 coding agent harness,也是一套面向机器 ## 环境 -先看 Node 版本: +先看 Node 版本(**硬性前提:Node ≥ 22.16.0**): ```bash node -v ``` -需要 **22.16** 或更高。npm 会先装依赖,再跑根包的 `preinstall`(`scripts/check-node-version.cjs`)。Node 低于 22.16 时脚本打印升级步骤并退出 1,这时依赖已经在磁盘上,还没有可用的 `moss`。升级 Node 后再安装一次。Node 22.16 自带 npm 10,不要按 npm 的提示升级到 npm 12:这个 Node 不支持 npm 12。 +没有合格 Node 时,先装再继续(可复制): -- nvm:`nvm install 22`,装完再跑一次 `node -v` -- NodeSource:见 [nodesource/distributions](https://github.com/nodesource/distributions) -- 国内网络:`npm config set registry https://registry.npmmirror.com`。nvm 下载 Node 可以设 `NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node` +- **macOS:** `brew install node@22`,或 `nvm install 22 && nvm use 22` / `fnm install 22 && fnm use 22` +- **Ubuntu / Debian:** NodeSource([nodesource/distributions](https://github.com/nodesource/distributions))或同上 nvm / fnm +- **Windows(PowerShell):** `winget install OpenJS.NodeJS.LTS` +- **国内网络:** `npm config set registry https://registry.npmmirror.com`;nvm 下载 Node 可设 `NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node` + +Node 低于 22.16 时,`npm install -g @rdk-moss/agent@latest`(以及源码路径的 `preinstall` / `prepare`)会**立刻非零退出**,打印当前 Node 版本和可复制的升级命令(含成功后再跑的 `npm install -g @rdk-moss/agent@latest`),**不会**留下坏掉的全局 `moss`。Node 22.16 自带 npm 10,不要按 npm 的提示升级到 npm 12:这个 Node 不支持 npm 12。 ### macOS -Git 需要 Xcode Command Line Tools。没有 `git` 时运行 `xcode-select --install`。Moss 自己不需要 C 编译器,`cpu-features` 的可选原生构建失败也不影响运行。Node 用 Homebrew 或 nvm,装完用 `node -v` 确认是 22.16 或更高。全局目录没有写权限时,用下面的用户级 prefix。 +Git 需要 Xcode Command Line Tools。没有 `git` 时运行 `xcode-select --install`。Moss 自己不需要 C 编译器,`cpu-features` 的可选原生构建失败也不影响运行。全局目录没有写权限时,用下面的用户级 prefix。 ### Windows -先装 Git for Windows 和 nvm-windows。PowerShell 的执行策略可能拦截 `moss.ps1`。只给当前用户放开脚本: +先装 Git for Windows。PowerShell 的执行策略可能拦截 `moss.ps1`。只给当前用户放开脚本: ```powershell Set-ExecutionPolicy -Scope CurrentUser RemoteSigned @@ -51,19 +54,15 @@ Set-ExecutionPolicy -Scope CurrentUser RemoteSigned ## 快速开始 -从源码安装,运行 `moss`,在界面里完成设置,然后要一个回答。`npm ci` 会跑 `prepare`(也就是 `npm run build`),所以不用再单独构建: +**推荐(一行):** 已装 Node ≥ 22.16 时: ```bash -git clone https://github.com/D-Robotics/moss.git -cd moss -npm ci -npm install -g --install-links . +npm install -g @rdk-moss/agent@latest +moss --version moss ``` -如果以前装过旧的未加 scope 的 `moss` 包,先执行 `npm uninstall -g moss`,否则两个包抢同一个 `moss` 命令,npm 会报 EEXIST。不要加 `--force`:它会同时留下旧包和 `@rdk-moss/agent`,之后再 `npm uninstall -g moss` 会把 `moss` 命令一起删掉。`npm install -g @rdk-moss/agent` 即将发布。 - -npm 11 可能打印 `npm warn install-scripts`。`npm ci` 可能会点名 `ssh2` 和 `cpu-features`。接下来的 `npm install -g --install-links .` 还可能点名 `@rdk-moss/agent`(它的 `preinstall` 和 `prepare`)。这是提示,安装仍然成功:ssh2 没有可选的原生模块也能用,全局副本里已经有 `dist/`。不要运行 `npm audit fix --force`,它会改依赖版本,装完不能用。 +如果以前装过旧的未加 scope 的 `moss` 包,先执行 `npm uninstall -g moss`,否则两个包抢同一个 `moss` 命令,npm 会报 EEXIST。不要加 `--force`:它会同时留下旧包和 `@rdk-moss/agent`,之后再 `npm uninstall -g moss` 会把 `moss` 命令一起删掉。 全局目录没有写权限(EACCES)时,把 prefix 放到用户目录,或改用 nvm(Node 装在家目录里): @@ -73,6 +72,23 @@ npm config set prefix ~/.npm-global 把 `~/.npm-global/bin` 加进 PATH,然后重新打开终端。 +
+从源码开发(可选) + +贡献 / 改代码时用克隆。`npm ci` 会跑 `prepare`(先检查 Node,再 `npm run build`),所以不用再单独构建: + +```bash +git clone https://github.com/D-Robotics/moss.git +cd moss +npm ci +npm install -g --install-links . +moss +``` + +npm 11 可能打印 `npm warn install-scripts`。`npm ci` 可能会点名 `ssh2` 和 `cpu-features`。接下来的 `npm install -g --install-links .` 还可能点名 `@rdk-moss/agent`(它的 `preinstall` 和 `prepare`)。这是提示,安装仍然成功:ssh2 没有可选的原生模块也能用,全局副本里已经有 `dist/`。不要运行 `npm audit fix --force`,它会改依赖版本,装完不能用。 + +
+ 没有可用配置时,`moss` 就在这个界面里设置(按数字选服务商,或按 Enter 使用环境里已有的 key,内容不会显示)。D-Robotics 地瓜网关是第一项,已经预选(地址 `https://ai-api.d-robotics.cc/v1`,默认模型 `deepseek-flash`,只问 key)。不想把 key 写进文件时,先指定服务商再写变量名:`moss config set provider d-robotics`,然后 `moss config set apiKeyEnv <变量名>`。只写 `apiKeyEnv`、不写服务商或地址时,会先问选哪一家,不会把 key 发给默认的 DeepSeek。设好后在同一会话里说: ```text @@ -100,9 +116,15 @@ moss --no-tty # 强制使用 readline REPL ## 升级 -如果 `moss` 命令还来自旧的未加 scope 的包(包括 `npm link`),先执行 `npm uninstall -g moss`。否则 `npm install -g --install-links .` 会报 EEXIST,而这时 `moss --version` 看起来可能已经是新的。 +如果 `moss` 命令还来自旧的未加 scope 的包(包括 `npm link`),先执行 `npm uninstall -g moss`。否则新的全局安装会报 EEXIST,而这时 `moss --version` 看起来可能已经是新的。 + +**npm 全局安装**(推荐路径)直接: + +```bash +npm install -g @rdk-moss/agent@latest +``` -已有 `moss` 克隆时,在它的上一级目录运行: +**已有源码克隆**时,在它的上一级目录运行: ```bash cd moss && git pull && npm ci && npm install -g --install-links . @@ -110,7 +132,7 @@ cd moss && git pull && npm ci && npm install -g --install-links . `npm ci` 会重新构建。`--install-links` 把新的副本装进全局 prefix,而不只是重新编译克隆目录。`moss --version` 带短 commit 和构建日期,例如 `moss v0.26.0 (e8dc2e3, 2026-10-10)`。构建时工作区有未提交改动会写成 `e8dc2e3+dirty`。升级前后可以对上。 -还没有 `moss` 目录时,用上面的安装步骤。`moss update` 找得到克隆就打印这一行,找不到才打印 `git clone`。全局安装只在当前目录和 `./moss` 里找。别的位置用 `moss update --dir <克隆>` 或 `MOSS_SOURCE_DIR`。它只打印命令,不执行。 +`moss update` 按安装方式打印对应命令:npm 全局打印上面的 `@latest` 一行;找得到克隆就打印克隆升级行;都没有才打印 `git clone`。全局安装只在当前目录和 `./moss` 里找克隆。别的位置用 `moss update --dir <克隆>` 或 `MOSS_SOURCE_DIR`。它只打印命令,不执行。 ## 卸载 @@ -396,25 +418,28 @@ mean _the board actually did it_. ### Prerequisites -Check Node first: +Check Node first (**hard requirement: Node ≥ 22.16.0**): ```bash node -v ``` -Moss needs **22.16** or newer. npm installs dependencies before the root `preinstall` (`scripts/check-node-version.cjs`). On Node older than 22.16 that script prints the upgrade steps and exits 1, so those packages can already be on disk and there is no working `moss`. Upgrade Node and run the install again. Node 22.16 ships with npm 10. Do not follow npm's notice to upgrade to npm 12: Node 22.16 does not support npm 12. +If Node is missing or too old, install it first (copyable): -- nvm: `nvm install 22`, then run `node -v` again -- NodeSource: see [nodesource/distributions](https://github.com/nodesource/distributions) -- In China: `npm config set registry https://registry.npmmirror.com`. For nvm's Node downloads, set `NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node` +- **macOS:** `brew install node@22`, or `nvm install 22 && nvm use 22` / `fnm install 22 && fnm use 22` +- **Ubuntu / Debian:** NodeSource ([nodesource/distributions](https://github.com/nodesource/distributions)) or the same nvm / fnm commands +- **Windows (PowerShell):** `winget install OpenJS.NodeJS.LTS` +- **In China:** `npm config set registry https://registry.npmmirror.com`. For nvm's Node downloads, set `NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node` + +On Node older than 22.16, `npm install -g @rdk-moss/agent@latest` (and the source-path `preinstall` / `prepare` hooks) **exits non-zero immediately**, prints the detected Node version and copyable upgrade commands (including the success-path `npm install -g @rdk-moss/agent@latest`), and **does not** leave a broken global `moss`. Node 22.16 ships with npm 10. Do not follow npm's notice to upgrade to npm 12: Node 22.16 does not support npm 12. #### macOS -Git requires the Xcode Command Line Tools. If `git` is missing, run `xcode-select --install`. Moss itself does not need a C compiler, and a failed optional native build of `cpu-features` still leaves a working `moss`. Install Node with Homebrew or nvm, then confirm `node -v` is 22.16 or newer. If the global prefix is not writable, use the user-level prefix below. +Git requires the Xcode Command Line Tools. If `git` is missing, run `xcode-select --install`. Moss itself does not need a C compiler, and a failed optional native build of `cpu-features` still leaves a working `moss`. If the global prefix is not writable, use the user-level prefix below. #### Windows -Install Git for Windows and nvm-windows. PowerShell's execution policy can block `moss.ps1`. Allow scripts for the current user only: +Install Git for Windows. PowerShell's execution policy can block `moss.ps1`. Allow scripts for the current user only: ```powershell Set-ExecutionPolicy -Scope CurrentUser RemoteSigned @@ -424,19 +449,15 @@ Put `%APPDATA%\npm` on PATH. That is npm's default global bin directory on Windo ### Quick start -Install from source, run `moss`, finish setup in the screen, then ask for an answer. `npm ci` runs `prepare` (`npm run build`), so there is no separate build step: +**Recommended (one line)** when Node ≥ 22.16 is already installed: ```bash -git clone https://github.com/D-Robotics/moss.git -cd moss -npm ci -npm install -g --install-links . +npm install -g @rdk-moss/agent@latest +moss --version moss ``` -If an older unscoped `moss` package is already installed, run `npm uninstall -g moss` first. The two packages use the same `moss` bin, and npm stops with EEXIST. Do not pass `--force`. It leaves both the old package and `@rdk-moss/agent` installed, and a later `npm uninstall -g moss` removes the `moss` command. `npm install -g @rdk-moss/agent` is coming soon. - -npm 11 may print `npm warn install-scripts`. `npm ci` may name `ssh2` and `cpu-features`. The following `npm install -g --install-links .` may also name `@rdk-moss/agent` (its `preinstall` and `prepare` scripts). The warning is harmless: ssh2 works without its optional native addon, and the global copy already contains `dist/`. Do not run `npm audit fix --force`. It changes dependency versions and breaks the install. +If an older unscoped `moss` package is already installed, run `npm uninstall -g moss` first. The two packages use the same `moss` bin, and npm stops with EEXIST. Do not pass `--force`. It leaves both the old package and `@rdk-moss/agent` installed, and a later `npm uninstall -g moss` removes the `moss` command. If npm reports EACCES on the global prefix, point prefix at your home directory, or use nvm (Node then lives under your home directory): @@ -446,6 +467,23 @@ npm config set prefix ~/.npm-global Add `~/.npm-global/bin` to PATH and open a new terminal. +
+Develop from source (optional) + +For contributors. `npm ci` runs `prepare` (Node check, then `npm run build`), so there is no separate build step: + +```bash +git clone https://github.com/D-Robotics/moss.git +cd moss +npm ci +npm install -g --install-links . +moss +``` + +npm 11 may print `npm warn install-scripts`. `npm ci` may name `ssh2` and `cpu-features`. The following `npm install -g --install-links .` may also name `@rdk-moss/agent` (its `preinstall` and `prepare` scripts). The warning is harmless: ssh2 works without its optional native addon, and the global copy already contains `dist/`. Do not run `npm audit fix --force`. It changes dependency versions and breaks the install. + +
+ With no usable config, `moss` sets itself up in that screen (press a number to pick a provider, or Enter to use a key already in the environment; the value is not shown). The D-Robotics gateway is listed first and preselected (`https://ai-api.d-robotics.cc/v1`, default model `deepseek-flash`, key only). To keep the key out of the file, set the provider and then the variable name: `moss config set provider d-robotics`, then `moss config set apiKeyEnv `. A file that names only `apiKeyEnv` is not configured: setup asks which provider, and the key is not sent to the default DeepSeek endpoint. Then, in the same session: ```text @@ -470,9 +508,15 @@ moss --no-tty # force the readline REPL ### Upgrade -If the `moss` command still comes from an older unscoped install, including `npm link`, run `npm uninstall -g moss` first. Otherwise `npm install -g --install-links .` stops with EEXIST even when `moss --version` already looks new. +If the `moss` command still comes from an older unscoped install, including `npm link`, run `npm uninstall -g moss` first. Otherwise a new global install stops with EEXIST even when `moss --version` already looks new. + +**npm global install** (recommended path): + +```bash +npm install -g @rdk-moss/agent@latest +``` -From the parent of an existing `moss` clone: +**From an existing source clone**, in the parent of that clone: ```bash cd moss && git pull && npm ci && npm install -g --install-links . @@ -480,7 +524,7 @@ cd moss && git pull && npm ci && npm install -g --install-links . `npm ci` rebuilds. `--install-links` installs that new copy into the global prefix, instead of only rebuilding the clone. `moss --version` includes the short commit and the build date, for example `moss v0.26.0 (e8dc2e3, 2026-10-10)`. A worktree with uncommitted changes at build time is marked `e8dc2e3+dirty`. The before and after strings differ. -If you do not have a `moss` directory yet, use the install steps above. `moss update` prints this upgrade line when it finds a clone, and the `git clone` steps only when it does not. A global install only looks in the current directory and in `./moss`. Point it somewhere else with `moss update --dir ` or `MOSS_SOURCE_DIR`. It prints the commands and does not run them. +`moss update` prints the matching command: `@latest` for an npm global install, the clone upgrade line when it finds a checkout, and `git clone` only when neither applies. A global install only looks for a checkout in the current directory and in `./moss`. Point it somewhere else with `moss update --dir ` or `MOSS_SOURCE_DIR`. It prints the commands and does not run them. ### Uninstall diff --git a/bin/node-version-message.cjs b/bin/node-version-message.cjs index 2bfa09a3..cd62359a 100644 --- a/bin/node-version-message.cjs +++ b/bin/node-version-message.cjs @@ -24,6 +24,30 @@ function preferredLocale(env) { return undefined; } +function upgradeCommands() { + return [ + ' # After Node >= 22.16, install / reinstall Moss with:', + ' npm install -g @rdk-moss/agent@latest', + '', + ' # macOS (Homebrew):', + ' brew install node@22', + ' # or nvm / fnm:', + ' nvm install 22 && nvm use 22', + ' fnm install 22 && fnm use 22', + '', + ' # Ubuntu / Debian (NodeSource):', + ' curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -', + ' sudo apt-get install -y nodejs', + ' # or nvm / fnm (same commands as above)', + '', + ' # Windows (PowerShell):', + ' winget install OpenJS.NodeJS.LTS', + '', + ' # China mirror (nvm Node downloads):', + ' NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node nvm install 22', + ].join('\n'); +} + function nodeVersionProblem(version, env) { var source = env || process.env; var match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version || '').trim()); @@ -34,14 +58,6 @@ function nodeVersionProblem(version, env) { if (major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR) return null; var current = String(version || '').replace(/^v/, ''); var need = MIN_NODE_MAJOR + '.' + MIN_NODE_MINOR + '.0'; - var commands = [ - ' nvm install 22', - ' # NodeSource (Debian/Ubuntu):', - ' curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -', - ' sudo apt-get install -y nodejs', - ' # China mirror (npmmirror):', - ' NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node nvm install 22', - ].join('\n'); var locale = preferredLocale(source); var lead = locale && /^zh/i.test(locale) @@ -59,11 +75,12 @@ function nodeVersionProblem(version, env) { '. The full-screen TUI depends on ink, which requires Node >= 22. This build is verified on Node >= ' + need + '. Upgrade Node, then run moss again:'; - return lead + '\n' + commands; + return lead + '\n' + upgradeCommands(); } module.exports = { MIN_NODE_MAJOR: MIN_NODE_MAJOR, MIN_NODE_MINOR: MIN_NODE_MINOR, nodeVersionProblem: nodeVersionProblem, + upgradeCommands: upgradeCommands, }; diff --git a/package.json b/package.json index 0f7bc27d..edce8e4d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@rdk-moss/agent", "version": "0.26.0", - "private": true, + "private": false, "type": "module", "license": "MIT", "description": "Moss: a minimal cross-platform coding agent harness (agent loop, tools, context management, providers) with a `moss` CLI.", @@ -36,7 +36,7 @@ }, "scripts": { "preinstall": "node scripts/check-node-version.cjs", - "prepare": "npm run build", + "prepare": "node scripts/check-node-version.cjs && npm run build", "prepublishOnly": "npm run build && npm run verify", "clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"", "build": "npm run clean && tsc -p tsconfig.build.json && node scripts/write-build-stamp.mjs && node -e \"const fs=require('fs');fs.chmodSync('dist/cli.js',0o755)\"", diff --git a/scripts/check-node-version.cjs b/scripts/check-node-version.cjs index 9c63ac9e..3fde3d8f 100644 --- a/scripts/check-node-version.cjs +++ b/scripts/check-node-version.cjs @@ -2,10 +2,18 @@ 'use strict'; /** - * `preinstall` gate. Plain CommonJS so a Node older than 22 prints the - * upgrade steps instead of throwing while parsing the script. - * npm installs dependencies before this hook. On Node >= 22.16 it exits 0. - * The script imports nothing. + * Node gate for install scripts. Plain CommonJS so a Node older than 22 + * prints the upgrade steps instead of throwing while parsing the script. + * + * - `preinstall` runs on packed / registry installs (and local npm install). + * - `prepare` also runs this before `npm run build`, so + * `npm install -g --install-links .` exits before linking a broken bin when + * Node is too old (npm `-g` does not apply a project engine-strict config). + * + * On Node >= 22.16 it exits 0. The script imports nothing. + * + * MOSS_TEST_FAKE_NODE_VERSION is a test-only override of the version this + * process checks. Nothing else in Moss reads it. */ function nodeIsSupported(version) { @@ -17,22 +25,70 @@ function nodeIsSupported(version) { return major > 22 || (major === 22 && minor >= 16); } -function upgradeMessage(version) { - var shown = String(version || '').replace(/^v/, ''); +function isNeutralLocale(value) { + return /^(?:c|posix)(?:\.[^@]+)?(?:@.*)?$/i.test(String(value || '')); +} + +function preferredLocale(env) { + var values = [env.LC_ALL, env.LC_MESSAGES, env.LANG]; + for (var i = 0; i < values.length; i += 1) { + var raw = values[i]; + var text = raw == null ? '' : String(raw).trim(); + if (!text || isNeutralLocale(text)) continue; + return text; + } + return undefined; +} + +function upgradeCommands() { return [ - 'Moss needs Node >= 22.16, but this is Node ' + shown + '.', - 'Upgrade Node, then run the install again:', - ' nvm install 22', - ' # NodeSource: https://github.com/nodesource/distributions', - ' # China mirror (npmmirror):', - ' NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node nvm install 22', + ' # After Node >= 22.16, install Moss with:', + ' npm install -g @rdk-moss/agent@latest', + '', + ' # macOS (Homebrew):', + ' brew install node@22', + ' # or nvm / fnm:', + ' nvm install 22 && nvm use 22', + ' fnm install 22 && fnm use 22', + '', + ' # Ubuntu / Debian (NodeSource):', + ' curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -', + ' sudo apt-get install -y nodejs', + ' # or nvm / fnm (same commands as above)', '', + ' # Windows (PowerShell):', + ' winget install OpenJS.NodeJS.LTS', + '', + ' # China mirror (nvm Node downloads):', + ' NVM_NODEJS_ORG_MIRROR=https://npmmirror.com/mirrors/node nvm install 22', ].join('\n'); } -if (require.main === module && !nodeIsSupported(process.versions.node)) { - process.stderr.write(upgradeMessage(process.versions.node)); +function upgradeMessage(version, env) { + var shown = String(version || '').replace(/^v/, ''); + var locale = preferredLocale(env || process.env); + var zh = locale && /^zh/i.test(locale); + var lead = zh + ? [ + '检测到 Node ' + shown + ',Moss 需要 >= 22.16。', + '请先升级 Node,再用下面的命令安装(可复制):', + ].join('\n') + : [ + 'Moss needs Node >= 22.16, but this is Node ' + shown + '.', + 'Upgrade Node, then install again with the copyable commands below:', + ].join('\n'); + return lead + '\n' + upgradeCommands() + '\n'; +} + +function versionUnderTest() { + var override = process.env.MOSS_TEST_FAKE_NODE_VERSION; + if (typeof override === 'string' && override.trim() !== '') return override.trim(); + return process.versions.node; +} + +if (require.main === module && !nodeIsSupported(versionUnderTest())) { + process.stderr.write(upgradeMessage(versionUnderTest(), process.env)); process.exit(1); } -module.exports = { nodeIsSupported, upgradeMessage }; +module.exports = { nodeIsSupported, upgradeMessage, upgradeCommands }; diff --git a/src/cli/update-command.ts b/src/cli/update-command.ts index 95ce1ca3..8a99e38f 100644 --- a/src/cli/update-command.ts +++ b/src/cli/update-command.ts @@ -63,7 +63,7 @@ export function gitCloneUrl(repository: MossPackageMeta['repository']): string { } /** - * First install while the package is private. `npm ci` runs `prepare` (`npm run build`). + * Source-clone install (developer path). `npm ci` runs `prepare` (Node check + build). * `npm install -g --install-links .` then copies that build into the prefix. * Verified on npm 10.9.2 and 11.21.0. */ @@ -81,7 +81,7 @@ export function sourceInstallCommands( /** * Registry installs upgrade with `@latest`. - * While `"private": true`, there is no published spec; use {@link sourceInstallCommands}. + * When `"private": true`, there is no published spec; use {@link sourceInstallCommands}. */ export function npmInstallSpec(pkg: MossPackageMeta): string | null { if (pkg.private === true) return null; @@ -280,7 +280,7 @@ export function renderUpdateAdvice(advice: MossUpdateAdvice, zh: boolean): strin return finishUpdateAdvice([head, '', choose, '', commandBlock], advice.commands, zh); } -export function renderUpdateHelp(zh: boolean, pkg: MossPackageMeta = { private: true }): string { +export function renderUpdateHelp(zh: boolean, pkg: MossPackageMeta = { private: false }): string { const published = pkg.private === false; const fresh = published ? globalInstallCommands({ ...pkg, private: false }) diff --git a/test/check-node-version-global-install.spec.mjs b/test/check-node-version-global-install.spec.mjs new file mode 100644 index 00000000..f0e6eb77 --- /dev/null +++ b/test/check-node-version-global-install.spec.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node +/** + * `npm install -g --install-links .` ignores the project `.npmrc`, so + * engine-strict does not stop it. npm packs the folder and runs `prepare` + * before it links the global bin. The prepare script runs + * scripts/check-node-version.cjs first. MOSS_TEST_FAKE_NODE_VERSION is read + * only by that script, so this stays on the real Node and does not download + * dependencies. + */ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); +const FAKE_VERSION = '20.19.2'; +const SHIM_NAMES = new Set(['moss', 'moss.cmd', 'moss.ps1']); + +assert.equal(pkg.scripts.preinstall, 'node scripts/check-node-version.cjs'); +assert.equal(pkg.scripts.prepare, 'node scripts/check-node-version.cjs && npm run build'); + +function makeTemp(name) { + return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), name))); +} + +function findShims(dir) { + const found = []; + let entries; + try { + entries = fs.readdirSync(dir, { withFileTypes: true }); + } catch (err) { + if (err && err.code === 'ENOENT') return found; + throw err; + } + for (const entry of entries) { + const full = path.join(dir, entry.name); + if (SHIM_NAMES.has(entry.name)) found.push(full); + if (entry.isDirectory()) found.push(...findShims(full)); + } + return found; +} + +function runNpm(args, cwd, env) { + const npmCli = process.env.npm_execpath; + const options = { cwd, env, encoding: 'utf8', timeout: 60_000 }; + if (npmCli && /npm-cli\.[cm]?js$/.test(npmCli)) { + return spawnSync(process.execPath, [npmCli, ...args], options); + } + return spawnSync('npm', args, { ...options, shell: process.platform === 'win32' }); +} + +const root = makeTemp('moss-node-gate-'); +const project = path.join(root, 'project'); +const prefix = path.join(root, 'prefix'); +const home = path.join(root, 'home'); +fs.mkdirSync(path.join(project, 'bin'), { recursive: true }); +fs.mkdirSync(path.join(project, 'scripts'), { recursive: true }); +fs.mkdirSync(prefix, { recursive: true }); +fs.mkdirSync(home, { recursive: true }); +fs.writeFileSync(path.join(home, '.npmrc'), ''); +fs.writeFileSync(path.join(home, 'etc-npmrc'), ''); +fs.writeFileSync(path.join(project, '.npmrc'), 'engine-strict=true\n'); +fs.copyFileSync( + path.join(repoRoot, 'scripts', 'check-node-version.cjs'), + path.join(project, 'scripts', 'check-node-version.cjs') +); +fs.writeFileSync( + path.join(project, 'bin', 'moss.cjs'), + '#!/usr/bin/env node\nconsole.log("fixture");\n' +); +fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ + name: 'moss-node-gate-fixture', + version: '0.0.0', + private: true, + bin: { moss: 'bin/moss.cjs' }, + files: ['bin', 'scripts/check-node-version.cjs'], + engines: { node: '>=22.16.0' }, + scripts: { + preinstall: pkg.scripts.preinstall, + prepare: pkg.scripts.prepare, + }, + }) +); + +const env = { + PATH: process.env.PATH ?? '', + HOME: home, + USERPROFILE: home, + LANG: 'C', + LC_ALL: 'C', + TMPDIR: os.tmpdir(), + TEMP: process.env.TEMP || os.tmpdir(), + TMP: process.env.TMP || os.tmpdir(), + npm_config_cache: path.join(root, 'cache'), + npm_config_prefix: prefix, + npm_config_userconfig: path.join(home, '.npmrc'), + npm_config_globalconfig: path.join(home, 'etc-npmrc'), + npm_config_update_notifier: 'false', + npm_config_fund: 'false', + npm_config_audit: 'false', + MOSS_TEST_FAKE_NODE_VERSION: FAKE_VERSION, +}; +if (process.env.Path) env.Path = process.env.Path; +if (process.env.SystemRoot) env.SystemRoot = process.env.SystemRoot; +if (process.env.PATHEXT) env.PATHEXT = process.env.PATHEXT; +if (process.env.COMSPEC) env.COMSPEC = process.env.COMSPEC; +if (process.env.npm_execpath) env.npm_execpath = process.env.npm_execpath; + +try { + const result = runNpm( + [ + 'install', + '-g', + '--install-links', + '.', + '--prefix', + prefix, + '--offline', + '--ignore-scripts=false', + '--no-audit', + '--no-fund', + ], + project, + env + ); + const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`; + if (result.error && result.error.code === 'ENOENT') { + console.log('[SKIP] check-node-version global install: npm is not on PATH'); + process.exit(0); + } + const offline = /ENOTCACHED|EAI_AGAIN|ENOTFOUND|getaddrinfo|registry\.npmjs\.org/i.test(output); + if (result.status !== 0 && !output.includes(FAKE_VERSION) && offline) { + console.log('[SKIP] check-node-version global install: npm needed the network while offline'); + process.exit(0); + } + assert.notEqual(result.status, 0, output); + assert.match(output, /Node 20\.19\.2/); + assert.match(output, /npm install -g @rdk-moss\/agent@latest/); + assert.match(output, /brew install node@22/); + assert.match(output, /nvm install 22 && nvm use 22/); + assert.match(output, /fnm install 22 && fnm use 22/); + assert.match(output, /https:\/\/deb\.nodesource\.com\/setup_22\.x/); + assert.match(output, /winget install OpenJS\.NodeJS\.LTS/); + assert.deepEqual(findShims(prefix), []); + console.log('[PASS] check-node-version global install'); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/test/check-node-version.spec.mjs b/test/check-node-version.spec.mjs index ea444e72..2afa3574 100644 --- a/test/check-node-version.spec.mjs +++ b/test/check-node-version.spec.mjs @@ -1,11 +1,14 @@ #!/usr/bin/env node /** - * preinstall refuses Node older than 22.16 and prints upgrade steps. + * preinstall / prepare refuse Node older than 22.16 and print upgrade steps + * for macOS / Ubuntu / Windows plus the npm i -g success path. * On a supported Node the script exits 0, so npm ci keeps going. + * MOSS_TEST_FAKE_NODE_VERSION exercises the failure path without an old Node. */ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -13,6 +16,7 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const script = path.join(root, 'scripts', 'check-node-version.cjs'); const require = createRequire(import.meta.url); const { nodeIsSupported, upgradeMessage } = require(script); +const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); assert.equal(nodeIsSupported('22.16.0'), true); assert.equal(nodeIsSupported('v22.16.0'), true); @@ -21,17 +25,50 @@ assert.equal(nodeIsSupported('22.15.9'), false); assert.equal(nodeIsSupported('18.20.0'), false); assert.equal(nodeIsSupported('20.18.0'), false); -const message = upgradeMessage('18.20.0'); +const message = upgradeMessage('18.20.0', { LC_ALL: 'C', LANG: 'en_US.UTF-8' }); assert.match(message, /Node >= 22\.16/); assert.match(message, /Node 18\.20\.0/); -assert.match(message, /nvm install 22/); +assert.match(message, /npm install -g @rdk-moss\/agent@latest/); +assert.match(message, /brew install node@22/); +assert.match(message, /nvm install 22 && nvm use 22/); +assert.match(message, /fnm install 22 && fnm use 22/); +assert.match(message, /https:\/\/deb\.nodesource\.com\/setup_22\.x/); +assert.match(message, /sudo apt-get install -y nodejs/); +assert.match(message, /winget install OpenJS\.NodeJS\.LTS/); assert.match( message, /NVM_NODEJS_ORG_MIRROR=https:\/\/npmmirror\.com\/mirrors\/node nvm install 22/ ); +const zh = upgradeMessage('20.19.2', { LC_ALL: 'C', LANG: 'zh_CN.UTF-8' }); +assert.match(zh, /检测到 Node 20\.19\.2/); +assert.match(zh, /npm install -g @rdk-moss\/agent@latest/); +assert.match(zh, /brew install node@22/); +assert.match(zh, /winget install OpenJS\.NodeJS\.LTS/); + +assert.equal(pkg.scripts.preinstall, 'node scripts/check-node-version.cjs'); +assert.equal(pkg.scripts.prepare, 'node scripts/check-node-version.cjs && npm run build'); +assert.equal(pkg.private, false); +assert.ok(pkg.files.includes('scripts/check-node-version.cjs')); + const ok = spawnSync(process.execPath, [script], { encoding: 'utf8', timeout: 10_000 }); assert.equal(ok.status, 0, ok.stderr); assert.equal(ok.stderr, ''); +const fake = spawnSync(process.execPath, [script], { + encoding: 'utf8', + timeout: 10_000, + env: { + ...process.env, + MOSS_TEST_FAKE_NODE_VERSION: '20.19.2', + LC_ALL: 'C', + LANG: 'en_US.UTF-8', + }, +}); +assert.notEqual(fake.status, 0, fake.stderr); +assert.match(fake.stderr, /Node 20\.19\.2/); +assert.match(fake.stderr, /npm install -g @rdk-moss\/agent@latest/); +assert.match(fake.stderr, /winget install OpenJS\.NodeJS\.LTS/); +assert.match(fake.stderr, /brew install node@22/); + console.log('[PASS] node version preinstall'); diff --git a/test/cli-update-command.spec.mjs b/test/cli-update-command.spec.mjs index e0624ef7..ccaf3a77 100644 --- a/test/cli-update-command.spec.mjs +++ b/test/cli-update-command.spec.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node /** * `moss update` prints the upgrade command for a git clone or an npm install. - * It does not run that command. The package stays private and publishable. + * It does not run that command. The package is published (`private: false`). */ + import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; @@ -29,22 +30,19 @@ const repo = { url: 'git+https://github.com/D-Robotics/moss.git' }; { assert.equal(pkg.name, '@rdk-moss/agent'); - assert.equal(pkg.private, true); + assert.equal(pkg.private, false); assert.equal(pkg.bin.moss, 'bin/moss.cjs'); assert.ok(fs.existsSync(path.join(repoRoot, pkg.bin.moss)), pkg.bin.moss); assert.ok(pkg.files.includes('bin'), 'files must include the bin directory'); assert.equal(pkg.engines.node, '>=22.16.0'); assert.equal(pkg.scripts.preinstall, 'node scripts/check-node-version.cjs'); assert.ok(pkg.files.includes('scripts/check-node-version.cjs')); - assert.equal(pkg.scripts.prepare, 'npm run build'); + assert.equal(pkg.scripts.prepare, 'node scripts/check-node-version.cjs && npm run build'); assert.equal(pkg.scripts.prepublishOnly, 'npm run build && npm run verify'); assert.equal(pkg.publishConfig.access, 'public'); assert.equal(githubInstallSpec(pkg.repository), 'github:D-Robotics/moss'); - assert.equal(npmInstallSpec(pkg), null); - assert.equal( - npmInstallSpec({ private: false, name: '@rdk-moss/agent' }), - '@rdk-moss/agent@latest' - ); + assert.equal(npmInstallSpec(pkg), '@rdk-moss/agent@latest'); + assert.equal(npmInstallSpec({ private: true, name: '@rdk-moss/agent' }), null); assert.equal(githubInstallSpec('git@github.com:D-Robotics/moss.git'), 'github:D-Robotics/moss'); assert.deepEqual(sourceInstallCommands(pkg.repository), [ 'git clone https://github.com/D-Robotics/moss.git', @@ -90,20 +88,20 @@ const repo = { url: 'git+https://github.com/D-Robotics/moss.git' }; const root = path.join('/usr', 'lib', 'node_modules', '@rdk-moss', 'agent'); const advice = adviseMossUpdate({ packageRoot: root, - pkg: { private: true, repository: repo }, + pkg: { private: false, name: '@rdk-moss/agent', repository: repo }, exists: () => false, }); assert.equal(advice.kind, 'npm-global'); - assert.deepEqual(advice.commands, sourceInstallCommands(repo)); + assert.deepEqual(advice.commands, ['npm install -g @rdk-moss/agent@latest']); const text = renderUpdateAdvice(advice, false); assert.match(text, /npm global install/); - assert.match(text, /npm install -g --install-links \./); + assert.match(text, /npm install -g @rdk-moss\/agent@latest/); assert.match(text, /EEXIST/); assert.match(text, /--force/); assert.match(text, /npm uninstall -g moss/); - assert.match(text, /No moss clone/); assert.match(text, /does not run it/); assert.doesNotMatch(text, /github:/); + assert.doesNotMatch(text, /No moss clone/); const zh = renderUpdateAdvice(advice, true); assert.match(zh, /EEXIST/); assert.match(zh, /--force/); @@ -111,6 +109,19 @@ const repo = { url: 'git+https://github.com/D-Robotics/moss.git' }; assert.match(zh, /不会执行/); } +{ + const root = path.join('/usr', 'lib', 'node_modules', '@rdk-moss', 'agent'); + const advice = adviseMossUpdate({ + packageRoot: root, + pkg: { private: true, repository: repo }, + exists: () => false, + }); + assert.equal(advice.kind, 'npm-global'); + assert.deepEqual(advice.commands, sourceInstallCommands(repo)); + assert.match(renderUpdateAdvice(advice, false), /No moss clone/); + assert.match(renderUpdateAdvice(advice, false), /npm install -g --install-links \./); +} + { const root = path.join('/work', 'app', 'node_modules', '@rdk-moss', 'agent'); const advice = adviseMossUpdate({ @@ -205,9 +216,12 @@ const repo = { url: 'git+https://github.com/D-Robotics/moss.git' }; assert.ok(readme.split(UPGRADE_IN_CLONE).length - 1 >= 2, 'upgrade line in en and zh'); assert.ok(readme.includes(LEGACY_PACKAGE_UNINSTALL)); assert.ok(readme.includes('npm uninstall -g @rdk-moss/agent')); - assert.match(readme, /coming soon/); - assert.match(readme, /即将发布/); + assert.match(readme, /npm install -g @rdk-moss\/agent@latest/); + assert.doesNotMatch(readme, /coming soon/); + assert.doesNotMatch(readme, /即将发布/); assert.match(readme, /npm config set registry https:\/\/registry\.npmmirror\.com/); + assert.match(readme, /brew install node@22/); + assert.match(readme, /winget install OpenJS\.NodeJS\.LTS/); assert.match(readme, /Set-ExecutionPolicy -Scope CurrentUser RemoteSigned/); assert.match(readme, /~\/\.npm-global/); assert.match(readme, /install-scripts/); @@ -225,7 +239,9 @@ const repo = { url: 'git+https://github.com/D-Robotics/moss.git' }; assert.match(readme, /可能会点名 `ssh2`/); assert.doesNotMatch(readme, /before dependencies are installed/); assert.doesNotMatch(readme, /不会把依赖装一半/); - assert.match(readme, /dependencies before the root `preinstall`/); + assert.match(readme, /exits non-zero immediately/); + assert.match(readme, /立刻非零退出/); + assert.doesNotMatch(readme, /dependencies before the root `preinstall`/); for (const [start, end] of [ ['## 升级', '## 卸载'], ['### Upgrade', '### Uninstall'], diff --git a/test/node-version-check.spec.mjs b/test/node-version-check.spec.mjs index e1f6176a..5d97bf02 100644 --- a/test/node-version-check.spec.mjs +++ b/test/node-version-check.spec.mjs @@ -21,8 +21,12 @@ const old = nodeVersionProblem('v20.19.2', en); assert.ok(old); assert.equal(old, fromBin('v20.19.2', en)); assert.match(old, /22\.16/); -assert.match(old, /nvm install 22/); +assert.match(old, /nvm install 22 && nvm use 22/); +assert.match(old, /brew install node@22/); +assert.match(old, /fnm install 22 && fnm use 22/); assert.match(old, /https:\/\/deb\.nodesource\.com\/setup_22\.x/); +assert.match(old, /winget install OpenJS\.NodeJS\.LTS/); +assert.match(old, /npm install -g @rdk-moss\/agent@latest/); assert.match(old, new RegExp(mirror.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.doesNotMatch(old, /--mirror/); @@ -38,8 +42,11 @@ assert.doesNotMatch(bin, /Moss needs Node/); assert.doesNotMatch(bin, /版本过低/); assert.match(bin, /import\('\.\.\/dist\/cli\.js'\)/); const message = fs.readFileSync(path.join(repoRoot, 'bin', 'node-version-message.cjs'), 'utf8'); -assert.match(message, /nvm install 22/); +assert.match(message, /nvm install 22 && nvm use 22/); +assert.match(message, /brew install node@22/); assert.match(message, /https:\/\/deb\.nodesource\.com\/setup_22\.x/); +assert.match(message, /winget install OpenJS\.NodeJS\.LTS/); +assert.match(message, /npm install -g @rdk-moss\/agent@latest/); assert.match( message, /NVM_NODEJS_ORG_MIRROR=https:\/\/npmmirror\.com\/mirrors\/node nvm install 22/