Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
116 changes: 80 additions & 36 deletions README.md

Large diffs are not rendered by default.

35 changes: 26 additions & 9 deletions bin/node-version-message.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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)
Expand All @@ -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,
};
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down Expand Up @@ -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)\"",
Expand Down
86 changes: 71 additions & 15 deletions scripts/check-node-version.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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 };
6 changes: 3 additions & 3 deletions src/cli/update-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -81,7 +81,7 @@ export function sourceInstallCommands(

/**
* Registry installs upgrade with `<name>@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;
Expand Down Expand Up @@ -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 })
Expand Down
153 changes: 153 additions & 0 deletions test/check-node-version-global-install.spec.mjs
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading