From 0a2818407ffe35c4ff812e44946426f8c4a4ad0d Mon Sep 17 00:00:00 2001 From: d-robotics Date: Sun, 11 Oct 2026 02:03:56 +0800 Subject: [PATCH 1/2] feat(doctor): show running moss version and binary path, warn on multiple moss on PATH (O59) --- src/cli/doctor.ts | 79 ++++++++- src/cli/moss-binary-path.ts | 287 +++++++++++++++++++++++++++++++++ test/cli-doctor.spec.mjs | 62 +++++++ test/moss-binary-path.spec.mjs | 207 ++++++++++++++++++++++++ 4 files changed, 634 insertions(+), 1 deletion(-) create mode 100644 src/cli/moss-binary-path.ts create mode 100644 test/moss-binary-path.spec.mjs diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index bf2df8b14..257814f07 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -20,6 +20,9 @@ import { import { isZhLocale, setupCopy } from './cli-locale.js'; import { glossConfigValue, localizeConfigSource } from './config-snapshot.js'; import { detectEnvCredentials } from './env-credentials.js'; +import { collectMossRuntimeFacts, displayMossPath } from './moss-binary-path.js'; +import { formatVersionLine, getPackageJsonPath, readBuildStamp } from './package-info.js'; +import type { BuildStamp } from './package-info.js'; import { rdkDocsPinNote } from './rdk-docs-pin-check.js'; import type { RdkDocsPinNote } from './rdk-docs-pin-check.js'; import { renderBoardProxyDoctor } from './doctor-board-proxy.js'; @@ -142,6 +145,80 @@ export function cliDoctorHasFailure(report: string): boolean { return report.split('\n').some((line) => line.startsWith(' fail ')); } +/** + * Version label for doctor. Same stamp as `moss --version` when a commit was + * recorded. Chinese avoids the English word `dirty` and keeps the commit in + * the same token as the version digits. + */ +export function formatDoctorVersion(version: string, stamp: BuildStamp | null): string { + if (!version || version === 'unknown') return uiText('unknown', '未知'); + const formatted = formatVersionLine(version, stamp); + const marked = `moss v${version}`; + const en = + formatted.startsWith(marked) && formatted.length > marked.length + ? `${version}${formatted.slice(marked.length)}` + : version; + const commit = stamp?.commit; + if (!commit) return uiText(en, version); + const dirtyNote = stamp?.dirty ? ',有未提交改动' : ''; + const dateNote = stamp?.date ? `,${stamp.date}` : ''; + return uiText(en, `${version}(${commit}${dirtyNote}${dateNote})`); +} + +/** Version (with build stamp), running entry, package root, and a PATH collision warning. */ +export function renderMossRuntimeDoctorLines(input: { + version: string; + stamp: BuildStamp | null; + entryPath: string; + packageRoot: string; + warning: string | null; +}): string[] { + const entry = displayMossPath(input.entryPath); + const root = displayMossPath(input.packageRoot); + const lines = [ + ok(uiText('version', '版本'), formatDoctorVersion(input.version, input.stamp)), + ok( + uiText('binary', '程序'), + uiText(`${entry} (package ${root})`, `${entry}(安装根目录 ${root})`) + ), + ]; + if (input.warning) lines.push(warn(uiText('binaries', '可执行文件'), input.warning)); + return lines; +} + +/** + * Facts for the running install. Stat and realpath only. On any failure this + * still returns a version line, and never a `fail` line. + */ +function mossRuntimeDoctorLines(version: string): string[] { + try { + const facts = collectMossRuntimeFacts({ + pathEnv: process.env.PATH, + pathExt: process.env.PATHEXT, + platform: process.platform, + fs: { + statSync(filePath: string) { + return fs.statSync(filePath); + }, + realpathSync(filePath: string) { + return fs.realpathSync(filePath); + }, + }, + argv1: process.argv[1], + packageJsonPath: getPackageJsonPath(), + }); + return renderMossRuntimeDoctorLines({ + version, + stamp: readBuildStamp(), + entryPath: facts.entryPath, + packageRoot: facts.packageRoot, + warning: facts.scan.warning, + }); + } catch { + return [ok(uiText('version', '版本'), version || uiText('unknown', '未知'))]; + } +} + /** Informational. A newer npm release is a warning, never a `fail` line. */ export function renderRdkDocsPinDoctorLine(note: RdkDocsPinNote): string { if (note.kind === 'latest-newer') { @@ -474,7 +551,7 @@ export function renderAuthDoctorLine( export async function renderCliDoctor(options: DoctorOptions): Promise { const lines = [uiText('[doctor] Moss', '[诊断] Moss')]; lines.push(renderNodeDoctorLine()); - lines.push(ok(uiText('version', '版本'), options.currentVersion)); + lines.push(...mossRuntimeDoctorLines(options.currentVersion)); const pinNote = await rdkDocsPinNote(process.env); if (pinNote) lines.push(renderRdkDocsPinDoctorLine(pinNote)); lines.push(renderAuthDoctorLine(options.config)); diff --git a/src/cli/moss-binary-path.ts b/src/cli/moss-binary-path.ts new file mode 100644 index 000000000..b4d6b5699 --- /dev/null +++ b/src/cli/moss-binary-path.ts @@ -0,0 +1,287 @@ +/** + * Which `moss` is on PATH, and which file this process actually is. + * Stat and realpath only — never spawn another moss. A scan error is an + * empty result so `moss doctor` still prints. + */ +import path from 'node:path'; +import { uiText } from '../utils/ui-language.js'; + +/** Enough of `node:fs` for the PATH walk. Tests pass a fake. */ +export interface MossBinaryPathFs { + statSync(filePath: string): { isFile(): boolean; mode: number }; + realpathSync(filePath: string): string; +} + +export interface MossBinaryHit { + /** File joined onto a PATH directory, in PATH order. */ + listed: string; + /** Distinct identity: realpath of `listed`. */ + realPath: string; +} + +export interface MossBinaryScan { + /** First occurrence of each real path, in PATH order. */ + binaries: MossBinaryHit[]; + /** First PATH entry, or null when nothing named moss is executable. */ + winner: MossBinaryHit | null; + /** True when `winner` resolves to the same file as `runningPath`. */ + winnerIsRunning: boolean; + /** + * Set only when more than one distinct real path is on PATH. + * Names every hit in PATH order, says which one wins, and whether that + * winner is the process that is running. + */ + warning: string | null; +} + +export interface MossBinaryScanInput { + pathEnv: string | undefined; + /** Windows `PATHEXT`. Ignored elsewhere. Unset includes `.exe`, `.cmd`, and `.ps1`. */ + pathExt?: string | undefined; + platform: NodeJS.Platform; + fs: MossBinaryPathFs; + /** realpath of this process's entry (`process.argv[1]`), when known. */ + runningPath?: string; +} + +export interface MossRuntimeFacts { + /** realpath of the running entry, or a localized "unknown". */ + entryPath: string; + /** Directory of the running package.json, realpath'd when the file exists. */ + packageRoot: string; + scan: MossBinaryScan; +} + +const DEFAULT_WINDOWS_PATHEXT = '.COM;.EXE;.BAT;.CMD;.PS1'; + +function pathApi(platform: NodeJS.Platform): path.PlatformPath { + return platform === 'win32' ? path.win32 : path.posix; +} + +/** One separator in the report. Windows realpath uses `\`; `/` still opens the file. */ +export function displayMossPath(filePath: string): string { + return filePath.replaceAll('\\', '/'); +} + +function sameFile(platform: NodeJS.Platform, left: string, right: string): boolean { + const a = displayMossPath(left); + const b = displayMossPath(right); + return platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +function fileKey(platform: NodeJS.Platform, filePath: string): string { + const slashed = displayMossPath(filePath); + return platform === 'win32' ? slashed.toLowerCase() : slashed; +} + +function splitPath(pathEnv: string | undefined, platform: NodeJS.Platform): string[] { + if (!pathEnv) return []; + const delimiter = platform === 'win32' ? ';' : ':'; + const dirs: string[] = []; + for (const part of pathEnv.split(delimiter)) { + const trimmed = part.trim().replace(/^"(.*)"$/, '$1'); + if (!trimmed) continue; + dirs.push(trimmed); + } + return dirs; +} + +/** + * Extensions PATHEXT contributes, lowercased, in listed order. + * An unset variable uses the Windows default plus `.PS1` (moss.ps1). + * An empty variable contributes none — only the bare `moss` name is probed. + */ +function windowsExtensions(pathExt: string | undefined): string[] { + const raw = pathExt === undefined ? DEFAULT_WINDOWS_PATHEXT : pathExt; + const seen = new Set(); + const exts: string[] = []; + for (const part of raw.split(';')) { + const trimmed = part.trim(); + if (!trimmed) continue; + const withDot = (trimmed.startsWith('.') ? trimmed : `.${trimmed}`).toLowerCase(); + if (seen.has(withDot)) continue; + seen.add(withDot); + exts.push(withDot); + } + return exts; +} + +/** Unix: `moss`. Windows: bare `moss`, then `moss` + each PATHEXT extension (`.exe`, `.cmd`, `.ps1`, …). */ +function candidateNames(platform: NodeJS.Platform, pathExt: string | undefined): string[] { + if (platform !== 'win32') return ['moss']; + const names = ['moss']; + for (const ext of windowsExtensions(pathExt)) names.push(`moss${ext}`); + return names; +} + +function isExecutable( + platform: NodeJS.Platform, + stat: { isFile(): boolean; mode: number } +): boolean { + if (!stat.isFile()) return false; + if (platform === 'win32') return true; + return (stat.mode & 0o111) !== 0; +} + +function displayHit(platform: NodeJS.Platform, hit: MossBinaryHit): string { + const listed = displayMossPath(hit.listed); + const real = displayMossPath(hit.realPath); + if (sameFile(platform, listed, real)) return real; + return `${listed} -> ${real}`; +} + +function warningText( + platform: NodeJS.Platform, + binaries: MossBinaryHit[], + runningPath: string | undefined +): string | null { + if (binaries.length < 2) return null; + const winner = binaries[0]; + if (!winner) return null; + const winnerLabel = displayHit(platform, winner); + const shown = binaries.map((hit) => displayHit(platform, hit)); + const list = shown.join('; '); + const zhList = shown.join('、'); + const winnerIsRunning = + runningPath !== undefined && + runningPath !== '' && + (sameFile(platform, winner.realPath, runningPath) || + sameFile(platform, winner.listed, runningPath)); + if (runningPath === undefined || runningPath === '') { + return uiText( + `${binaries.length} moss executables on PATH: ${list}. ${winnerLabel} wins (first on PATH).`, + `PATH 上有 ${binaries.length} 个不同的 moss:${zhList}。先出现的会生效:${winnerLabel}。` + ); + } + if (winnerIsRunning) { + return uiText( + `${binaries.length} moss executables on PATH: ${list}. ${winnerLabel} wins (first on PATH) and is the one currently running.`, + `PATH 上有 ${binaries.length} 个不同的 moss:${zhList}。先出现的会生效:${winnerLabel}。它就是当前正在运行的这个。` + ); + } + const running = displayMossPath(runningPath); + return uiText( + `${binaries.length} moss executables on PATH: ${list}. ${winnerLabel} wins (first on PATH) and is not the one currently running (${running}).`, + `PATH 上有 ${binaries.length} 个不同的 moss:${zhList}。先出现的会生效:${winnerLabel}。它不是当前正在运行的(${running})。` + ); +} + +function emptyScan(): MossBinaryScan { + return { binaries: [], winner: null, winnerIsRunning: false, warning: null }; +} + +/** + * Every executable `moss` on PATH. Symlinks that share a realpath count once + * (the earlier PATH entry). Does not spawn. Returns an empty scan if the + * walk itself throws. + */ +export function scanMossBinariesOnPath(input: MossBinaryScanInput): MossBinaryScan { + try { + const platform = input.platform; + const api = pathApi(platform); + const names = candidateNames(platform, input.pathExt); + const binaries: MossBinaryHit[] = []; + const seen = new Set(); + for (const dir of splitPath(input.pathEnv, platform)) { + for (const name of names) { + const listed = api.join(dir, name); + let stat: { isFile(): boolean; mode: number }; + try { + stat = input.fs.statSync(listed); + } catch { + continue; + } + if (!isExecutable(platform, stat)) continue; + let realPath: string; + try { + realPath = input.fs.realpathSync(listed); + } catch { + realPath = api.resolve(listed); + } + const key = fileKey(platform, realPath); + if (seen.has(key)) continue; + seen.add(key); + binaries.push({ listed, realPath }); + } + } + const winner = binaries[0] ?? null; + const running = input.runningPath; + const winnerIsRunning = + winner !== null && + running !== undefined && + running !== '' && + (sameFile(platform, winner.realPath, running) || sameFile(platform, winner.listed, running)); + return { + binaries, + winner, + winnerIsRunning, + warning: warningText(platform, binaries, running), + }; + } catch { + return emptyScan(); + } +} + +function resolveEntry( + argv1: string | undefined, + fs: MossBinaryPathFs, + platform: NodeJS.Platform +): string { + if (!argv1 || !argv1.trim()) return uiText('unknown', '未知'); + const api = pathApi(platform); + try { + return fs.realpathSync(argv1); + } catch { + try { + return api.resolve(argv1); + } catch { + return argv1; + } + } +} + +function resolvePackageRoot( + packageJsonPath: string, + fs: MossBinaryPathFs, + platform: NodeJS.Platform +): string { + const api = pathApi(platform); + try { + return api.dirname(fs.realpathSync(packageJsonPath)); + } catch { + try { + return fs.realpathSync(api.dirname(packageJsonPath)); + } catch { + return api.dirname(packageJsonPath); + } + } +} + +/** Entry path, package root, and the PATH scan. Never throws. */ +export function collectMossRuntimeFacts(input: { + pathEnv: string | undefined; + pathExt?: string | undefined; + platform: NodeJS.Platform; + fs: MossBinaryPathFs; + argv1: string | undefined; + packageJsonPath: string; +}): MossRuntimeFacts { + try { + const entryPath = resolveEntry(input.argv1, input.fs, input.platform); + const packageRoot = resolvePackageRoot(input.packageJsonPath, input.fs, input.platform); + const scan = scanMossBinariesOnPath({ + pathEnv: input.pathEnv, + pathExt: input.pathExt, + platform: input.platform, + fs: input.fs, + runningPath: entryPath, + }); + return { entryPath, packageRoot, scan }; + } catch { + return { + entryPath: uiText('unknown', '未知'), + packageRoot: uiText('unknown', '未知'), + scan: emptyScan(), + }; + } +} diff --git a/test/cli-doctor.spec.mjs b/test/cli-doctor.spec.mjs index fb101d5d1..e1ebc7559 100644 --- a/test/cli-doctor.spec.mjs +++ b/test/cli-doctor.spec.mjs @@ -7,9 +7,11 @@ import assert from 'node:assert/strict'; import { cliDoctorHasFailure, + renderMossRuntimeDoctorLines, renderNodeDoctorLine, renderSearchDoctor, } from '../dist/cli/doctor.js'; +import { clearUiLanguage, installUiLanguage } from '../dist/utils/ui-language.js'; // ─── cliDoctorHasFailure — detects failures in the report ──────────────────── @@ -99,4 +101,64 @@ import { assert.equal(cliDoctorHasFailure(line), false, 'warn is not a failure'); } +// ─── running moss: version stamp, entry path, package root ────────────────── + +{ + const entry = '/usr/local/lib/node_modules/@rdk-moss/agent/bin/moss.cjs'; + const root = '/usr/local/lib/node_modules/@rdk-moss/agent'; + const lines = renderMossRuntimeDoctorLines({ + version: '0.26.0', + stamp: { commit: 'abc1234', date: '2026-10-10', dirty: true }, + entryPath: entry, + packageRoot: root, + warning: null, + }); + const text = lines.join('\n'); + assert.match(text, /ok\s+version:/); + assert.match(text, /0\.26\.0 \(abc1234\+dirty, 2026-10-10\)/); + assert.match(text, /ok\s+binary:/); + assert.match(text, /moss\.cjs \(package \/usr\/local\/lib\/node_modules\/@rdk-moss\/agent\)/); + assert.equal(cliDoctorHasFailure(text), false, 'version and binary lines are not failures'); +} + +{ + const lines = renderMossRuntimeDoctorLines({ + version: '0.26.0', + stamp: null, + entryPath: '/opt/a/bin/moss', + packageRoot: '/opt/a', + warning: + '2 moss executables on PATH: /opt/a/bin/moss; /opt/b/bin/moss. /opt/a/bin/moss wins (first on PATH) and is the one currently running.', + }); + const text = lines.join('\n'); + assert.match(text, /version: 0\.26\.0$/m, 'no stamp means the version number alone'); + assert.match(text, /binary: \/opt\/a\/bin\/moss \(package \/opt\/a\)/); + assert.match(text, /warn\s+binaries:/); + assert.match(text, /\/opt\/a\/bin\/moss wins/); + assert.doesNotMatch(text, /^\s+fail\s/m); + assert.equal(cliDoctorHasFailure(text), false, 'a PATH collision is a warning, not a failure'); +} + +{ + installUiLanguage({ language: 'zh', source: 'flag', setting: 'zh' }); + try { + const lines = renderMossRuntimeDoctorLines({ + version: '0.26.0', + stamp: { commit: 'beeface', date: '2026-10-10', dirty: true }, + entryPath: 'D:\\a\\moss\\moss\\dist\\cli.js', + packageRoot: 'D:\\a\\moss\\moss', + warning: null, + }); + const text = lines.join('\n'); + assert.match(text, /版本: 0\.26\.0(beeface,有未提交改动,2026-10-10)/); + assert.match(text, /程序: D:\/a\/moss\/moss\/dist\/cli\.js(安装根目录 D:\/a\/moss\/moss)/); + assert.equal(text.includes('dirty'), false); + assert.equal(text.includes('package'), false); + assert.equal(text.includes('binary'), false); + assert.equal(cliDoctorHasFailure(text), false); + } finally { + clearUiLanguage(); + } +} + console.log('[PASS] Doctor diagnostics'); diff --git a/test/moss-binary-path.spec.mjs b/test/moss-binary-path.spec.mjs new file mode 100644 index 000000000..c9de060c5 --- /dev/null +++ b/test/moss-binary-path.spec.mjs @@ -0,0 +1,207 @@ +#!/usr/bin/env node +/** + * PATH scan for `moss` executables. The filesystem is injected — no spawn, + * no real install. English warnings assume the test runner's C locale. + */ +import assert from 'node:assert/strict'; +import path from 'node:path'; + +import { clearUiLanguage, installUiLanguage } from '../dist/utils/ui-language.js'; +import { collectMossRuntimeFacts, scanMossBinariesOnPath } from '../dist/cli/moss-binary-path.js'; + +/** @param {Record} files */ +function fakeFs(files) { + return { + statSync(filePath) { + const hit = files[filePath]; + if (!hit) throw new Error(`ENOENT: ${filePath}`); + return { + isFile: () => hit.isFile !== false, + mode: hit.mode ?? 0o755, + }; + }, + realpathSync(filePath) { + const hit = files[filePath]; + if (!hit) throw new Error(`ENOENT: ${filePath}`); + return hit.realPath ?? filePath; + }, + }; +} + +// ─── one executable: no warning, even if a non-executable moss sits later ── + +{ + const scan = scanMossBinariesOnPath({ + pathEnv: '/usr/local/bin:/opt/data/bin', + platform: 'linux', + runningPath: '/usr/local/bin/moss', + fs: fakeFs({ + '/usr/local/bin/moss': { realPath: '/usr/local/bin/moss', mode: 0o755 }, + '/opt/data/bin/moss': { realPath: '/opt/data/bin/moss', mode: 0o644 }, + }), + }); + assert.equal(scan.warning, null, 'a single executable moss does not warn'); + assert.equal(scan.binaries.length, 1); + assert.equal(scan.winner?.listed, '/usr/local/bin/moss'); + assert.equal(scan.winnerIsRunning, true); +} + +// ─── two different binaries: name both, the PATH-order winner, and running ── + +{ + const fs = fakeFs({ + '/usr/local/bin/moss': { realPath: '/usr/local/bin/moss' }, + '/opt/homebrew/bin/moss': { realPath: '/opt/homebrew/bin/moss' }, + }); + const running = scanMossBinariesOnPath({ + pathEnv: '/usr/local/bin:/opt/homebrew/bin', + platform: 'darwin', + runningPath: '/usr/local/bin/moss', + fs, + }); + const warning = running.warning ?? ''; + assert.equal(running.binaries.length, 2); + assert.equal(running.winner?.realPath, '/usr/local/bin/moss'); + assert.equal(running.winnerIsRunning, true); + assert.match(warning, /\/usr\/local\/bin\/moss/); + assert.match(warning, /\/opt\/homebrew\/bin\/moss/); + assert.ok( + warning.indexOf('/usr/local/bin/moss') < warning.indexOf('/opt/homebrew/bin/moss'), + 'PATH order, not sorted order' + ); + assert.match(warning, /\/usr\/local\/bin\/moss wins \(first on PATH\)/); + assert.match(warning, /is the one currently running/); + assert.doesNotMatch(warning, /not the one currently running/); + + const other = scanMossBinariesOnPath({ + pathEnv: '/usr/local/bin:/opt/homebrew/bin', + platform: 'darwin', + runningPath: '/opt/homebrew/bin/moss', + fs, + }); + assert.equal(other.winnerIsRunning, false); + assert.match(other.warning ?? '', /\/usr\/local\/bin\/moss wins \(first on PATH\)/); + assert.match(other.warning ?? '', /not the one currently running \(\/opt\/homebrew\/bin\/moss\)/); +} + +// ─── two PATH entries, one real file: count once, no warning ─────────────── + +{ + const scan = scanMossBinariesOnPath({ + pathEnv: '/usr/local/bin:/opt/homebrew/bin', + platform: 'linux', + runningPath: '/opt/moss/bin/moss.cjs', + fs: fakeFs({ + '/usr/local/bin/moss': { realPath: '/opt/moss/bin/moss.cjs' }, + '/opt/homebrew/bin/moss': { realPath: '/opt/moss/bin/moss.cjs' }, + }), + }); + assert.equal(scan.binaries.length, 1, 'symlinks to the same target count once'); + assert.equal(scan.warning, null); + assert.equal(scan.binaries[0]?.listed, '/usr/local/bin/moss'); + assert.equal(scan.binaries[0]?.realPath, '/opt/moss/bin/moss.cjs'); + assert.equal(scan.winnerIsRunning, true); +} + +// ─── Windows PATHEXT: only listed extensions, plus the bare name ─────────── + +{ + const dir = 'C:\\Tools'; + const bare = path.win32.join(dir, 'moss'); + const exe = path.win32.join(dir, 'moss.exe'); + const cmd = path.win32.join(dir, 'moss.cmd'); + const ps1 = path.win32.join(dir, 'moss.ps1'); + const bat = path.win32.join(dir, 'moss.bat'); + const txt = path.win32.join(dir, 'moss.txt'); + const shared = 'C:\\Real\\moss.exe'; + const fs = fakeFs({ + [bare]: { realPath: shared }, + [exe]: { realPath: shared }, + [cmd]: { realPath: 'C:\\Real\\moss.cmd' }, + [ps1]: { realPath: 'C:\\Real\\moss.ps1' }, + [bat]: { realPath: 'C:\\Real\\moss.bat' }, + [txt]: { realPath: 'C:\\Real\\moss.txt' }, + }); + + const exeOnly = scanMossBinariesOnPath({ + pathEnv: dir, + pathExt: '.EXE', + platform: 'win32', + runningPath: 'c:\\real\\moss.exe', + fs, + }); + assert.equal(exeOnly.warning, null, '.EXE does not pick up .cmd/.ps1/.bat/.txt'); + assert.equal(exeOnly.binaries.length, 1); + assert.equal(exeOnly.binaries[0]?.listed, bare, 'bare moss is probed and shares the exe target'); + assert.equal(exeOnly.binaries[0]?.realPath, shared); + assert.equal(exeOnly.winnerIsRunning, true, 'Windows path compare is case-insensitive'); + + const many = scanMossBinariesOnPath({ + pathEnv: dir, + pathExt: '.EXE;.CMD;.PS1', + platform: 'win32', + runningPath: 'C:\\Tools\\moss.exe', + fs, + }); + const names = many.binaries.map((hit) => hit.listed); + assert.deepEqual(names, [bare, cmd, ps1]); + assert.ok(many.warning); + const warning = many.warning ?? ''; + assert.match(warning, /moss\.cmd/); + assert.match(warning, /moss\.ps1/); + assert.match(warning, /wins \(first on PATH\)/); + assert.doesNotMatch(warning, /moss\.txt/); + assert.doesNotMatch(warning, /moss\.bat/); + assert.match(warning, /not the one currently running/); + assert.equal(many.winner?.listed, bare); + assert.equal(many.winnerIsRunning, false); +} + +// ─── Chinese warning names both installs without the English verb ────────── + +{ + installUiLanguage({ language: 'zh', source: 'flag', setting: 'zh' }); + try { + const scan = scanMossBinariesOnPath({ + pathEnv: '/usr/local/bin:/opt/homebrew/bin', + platform: 'linux', + runningPath: '/opt/homebrew/bin/moss', + fs: fakeFs({ + '/usr/local/bin/moss': { realPath: '/usr/local/bin/moss' }, + '/opt/homebrew/bin/moss': { realPath: '/opt/homebrew/bin/moss' }, + }), + }); + const warning = scan.warning ?? ''; + assert.match(warning, /\/usr\/local\/bin\/moss/); + assert.match(warning, /\/opt\/homebrew\/bin\/moss/); + assert.match(warning, /先出现的会生效:\/usr\/local\/bin\/moss/); + assert.match(warning, /它不是当前正在运行的(\/opt\/homebrew\/bin\/moss)/); + assert.equal(warning.includes('wins'), false); + assert.equal(warning.includes('currently'), false); + } finally { + clearUiLanguage(); + } +} + +// ─── entry realpath and package root, without spawning ───────────────────── + +{ + const facts = collectMossRuntimeFacts({ + pathEnv: '/opt/moss/bin', + platform: 'linux', + argv1: '/opt/moss/bin/moss.cjs', + packageJsonPath: '/opt/moss/package.json', + fs: fakeFs({ + '/opt/moss/bin/moss': { realPath: '/real/moss/bin/moss.cjs' }, + '/opt/moss/bin/moss.cjs': { realPath: '/real/moss/bin/moss.cjs' }, + '/opt/moss/package.json': { realPath: '/real/moss/package.json' }, + }), + }); + assert.equal(facts.entryPath, '/real/moss/bin/moss.cjs'); + assert.equal(facts.packageRoot, '/real/moss'); + assert.equal(facts.scan.binaries.length, 1); + assert.equal(facts.scan.winnerIsRunning, true, 'PATH moss and argv[1] share a real path'); + assert.equal(facts.scan.warning, null); +} + +console.log('[PASS] moss binary path'); From 7d1013446ab0aff99d8125f41ec381771ec4baab Mon Sep 17 00:00:00 2001 From: QiaolongLi1201 Date: Sun, 11 Oct 2026 21:56:15 +0800 Subject: [PATCH 2/2] docs: document custom OpenAI-compatible gateways; /v1 optional (O60) - README (zh + en): custom openai-compatible gateway section; baseUrl /v1 optional - config --help / baseUrl hint: /v1 is optional instead of 'not /v1' - test/api-v1-url.spec.mjs: buildApiV1Url with/without /v1 and trailing slash, never /v1/v1 O58 Node-gate install work is already on main via #85; not reintroduced here. --- CHANGELOG.md | 15 +++++++++++ README.md | 30 +++++++++++++++++++++ src/cli/config-commands.ts | 8 ++++-- test/api-v1-url.spec.mjs | 45 ++++++++++++++++++++++++++++++++ test/cli-setup-commands.spec.mjs | 7 +++++ 5 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 test/api-v1-url.spec.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index d59699f27..756edfebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,21 @@ 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. +### O60 — custom OpenAI-compatible gateway docs + +README (zh + en) documents `provider openai-compatible` with `model` / `baseUrl` / +`apiKeyEnv`. A trailing `/v1` on `baseUrl` is optional; Moss appends +`/v1/chat/completions` and never produces `/v1/v1`. `moss config --help` and the +invalid-`baseUrl` hint say `/v1` is optional. `test/api-v1-url.spec.mjs` locks +`buildApiV1Url` for bare hosts, trailing slash, `/v1`, and the d-robotics preset. + +### O59 — doctor binary path and PATH duplicates + +`moss doctor` prints `version` (with build stamp) and `binary` (realpath of the +running entry + package root), and warns when several distinct `moss` executables +are on PATH (PATH order, which one wins, whether it is the running one; PATHEXT on +Windows, symlinks deduped). Scanner lives in `src/cli/moss-binary-path.ts`. + ### Install follow-ups `npm ci` runs `scripts/check-node-version.cjs` as `preinstall`. The script is diff --git a/README.md b/README.md index 0acb0bf0f..2c1eb0c32 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,21 @@ npm 11 可能打印 `npm warn install-scripts`。`npm ci` 可能会点名 `ssh2` 看一下这个目录里有什么 ``` +### 自定义 OpenAI 兼容网关 + +内置预设之外的网关用 `openai-compatible`。四条命令分别写入服务商、模型、地址和密钥变量名(`apiKeyEnv` 的值是变量名,不是密钥本身): + +```bash +moss config set provider openai-compatible +moss config set model your-model-name +moss config set baseUrl https://gw.example +moss config set apiKeyEnv MY_GATEWAY_API_KEY +export MY_GATEWAY_API_KEY=your-key +moss doctor +``` + +`baseUrl` 末尾的 `/v1` 可写可不写。`https://gw.example` 和 `https://gw.example/v1` 都可以。Moss 自己拼接 `/v1/chat/completions`,不会变成 `/v1/v1`。不必写 `/chat/completions`,写了也会被去掉。`moss doctor` 核对服务商、模型、地址,以及密钥变量是否已设置。 + 进到交互界面后: - `moss "整理这个项目的 README"` 直接派活;`@` 引用文件,`!` 执行 shell @@ -490,6 +505,21 @@ With no usable config, `moss` sets itself up in that screen (press a number to p look around this folder and tell me what it is ``` +### Custom OpenAI-compatible gateway + +For a gateway that is not a built-in preset, use `openai-compatible`. These four commands set the provider, model, address, and the key variable name (`apiKeyEnv` stores the name, not the secret): + +```bash +moss config set provider openai-compatible +moss config set model your-model-name +moss config set baseUrl https://gw.example +moss config set apiKeyEnv MY_GATEWAY_API_KEY +export MY_GATEWAY_API_KEY=your-key +moss doctor +``` + +A trailing `/v1` on `baseUrl` is optional. Both `https://gw.example` and `https://gw.example/v1` work. Moss appends `/v1/chat/completions` itself and never produces `/v1/v1`. Do not include `/chat/completions`; it is not needed, and Moss strips it if it is present. `moss doctor` checks the provider, model, address, and whether the key variable is set. + Inside Moss: give it a job (`@` to reference files, `!` for shell), `Shift+Tab` to cycle modes (`plan` = read-only planning), `Ctrl+V` to attach a clipboard image / Finder file / local path (macOS; Linux: wl-paste/xclip; Windows: PowerShell), `/help` for keys and commands. diff --git a/src/cli/config-commands.ts b/src/cli/config-commands.ts index 687f0f5f4..7f3f99938 100644 --- a/src/cli/config-commands.ts +++ b/src/cli/config-commands.ts @@ -586,7 +586,7 @@ export function renderConfigHelp(zh = false): string { ' moss config set profile autonomous', ' moss config set provider openai-compatible', ' moss config set model ', - ' moss config set baseUrl https://your-gateway.example # API root, not /v1 or /chat/completions', + ' moss config set baseUrl https://your-gateway.example # /v1 is optional (https://host/v1 also works); /chat/completions is not needed', ' moss setup # stores the API key (hidden prompt, safer than command line)', ' moss config set --project safetyMode workspace-write', ` # ${WORKSPACE_WRITE_LIMIT_EN}`, @@ -646,6 +646,7 @@ function renderConfigHelpZh(): string { ` # ${WORKSPACE_WRITE_LIMIT_ZH}`, ' `moss config set provider openai-compatible`', ' `moss config set model <模型>`', + ' `moss config set baseUrl https://your-gateway.example` # /v1 可写可不写(https://主机/v1 也可以);不必带 /chat/completions', ' `moss config set rdkDocs false`', ' `moss config set rdkDocs.package ../rdk-docs-mcp`', ' `moss config set language auto|en|zh` # 只写用户配置(不能 --project,项目 .env 也不能设置)', @@ -1061,7 +1062,10 @@ function applyConfigSetPair( ok: false, messages: [ `Invalid baseUrl: ${value.trim()}`, - 'baseUrl must be a full http(s) URL, e.g. https://your-gateway.example (API root, no /v1)', + uiText( + 'baseUrl must be a full http(s) URL, e.g. https://your-gateway.example or https://your-gateway.example/v1 (/v1 is optional)', + 'baseUrl 必须是完整的 http(s) URL,例如 https://your-gateway.example 或 https://your-gateway.example/v1(/v1 可写可不写)' + ), ], }; } diff --git a/test/api-v1-url.spec.mjs b/test/api-v1-url.spec.mjs new file mode 100644 index 000000000..32a920f73 --- /dev/null +++ b/test/api-v1-url.spec.mjs @@ -0,0 +1,45 @@ +#!/usr/bin/env node +/** + * buildApiV1Url accepts an API root with or without a trailing /v1 + * and never emits /v1/v1. The d-robotics preset base URL is one of the inputs. + */ +import assert from 'node:assert/strict'; + +import { buildApiV1Url } from '../dist/provider/api-v1-url.js'; +import { PROVIDER_PRESETS } from '../dist/provider/provider-presets.js'; + +const preset = PROVIDER_PRESETS['d-robotics'].defaultBaseUrl; +assert.equal(preset, 'https://ai-api.d-robotics.cc/v1'); + +const cases = [ + ['https://gw.example', 'https://gw.example'], + ['https://gw.example/', 'https://gw.example'], + ['https://gw.example/v1', 'https://gw.example'], + ['https://gw.example/v1/', 'https://gw.example'], + [preset, 'https://ai-api.d-robotics.cc'], + ['https://gw.example/v1/chat/completions', 'https://gw.example'], +]; + +for (const [base, root] of cases) { + const chat = buildApiV1Url(base, 'chat/completions'); + const models = buildApiV1Url(base, 'models'); + assert.equal(chat, `${root}/v1/chat/completions`, base); + assert.equal(models, `${root}/v1/models`, base); + assert.equal(chat.includes('/v1/v1'), false, chat); + assert.equal(models.includes('/v1/v1'), false, models); +} + +assert.equal( + buildApiV1Url('https://gw.example', '/chat/completions'), + 'https://gw.example/v1/chat/completions' +); +assert.equal( + buildApiV1Url('https://gw.example/v1/chat/completions', '/models'), + 'https://gw.example/v1/models' +); +assert.equal( + buildApiV1Url('https://gw.example/v1/', '/chat/completions').includes('/v1/v1'), + false +); + +console.log('[PASS] buildApiV1Url'); diff --git a/test/cli-setup-commands.spec.mjs b/test/cli-setup-commands.spec.mjs index c2969707f..9e939ee43 100644 --- a/test/cli-setup-commands.spec.mjs +++ b/test/cli-setup-commands.spec.mjs @@ -104,6 +104,13 @@ test('short usage stays short; the full reference lives on config --help alone', 'full help keeps the smoke-parity example' ); assert.ok(full.includes('Examples:'), 'full help keeps the examples section'); + assert.match(full, /\/v1 is optional/); + assert.doesNotMatch(full, /not \/v1/); + assert.doesNotMatch(full, /no \/v1/); + const zhHelp = setup.renderConfigHelp(true); + assert.match(zhHelp, /\/v1 可写可不写/); + assert.doesNotMatch(zhHelp, /不是 \/v1/); + assert.doesNotMatch(zhHelp, /不要写 \/v1/); assert.match(full, /--config-file and MOSS_CONFIG_FILE load only that file/); assert.match(full, /\.moss\/config\.json layer is not loaded/); assert.ok(