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/