From e6975e5ce195a02ab7cdef3116ffba958f524a56 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 18 Oct 2026 09:18:59 -0400 Subject: [PATCH 1/5] feat(cli): canonical nself spellings, NSELF_JSON_LEGACY, NSELF_BIN and a cli compat check (P7-CANON-20) Keep admin working against cli 1.5.0 (lockstep release): - exec helpers and route spawns inherit NSELF_JSON_LEGACY=1 (bare cli 1.4 JSON) - NSELF_BIN picks the binary ahead of discovery - every argv naming a relocated command uses its canonical spelling (config services/secrets/env/plugins, status urls/health, add, remove); dynamic callers are rewritten in src/lib/nselfCanon.ts - scripts/cli-compat.sh checks the argv inventory against a cli build in v1.4 and v1.5 mode --- .github/wiki/CLI_INTEGRATION.md | 28 +++ next.config.mjs | 6 + scripts/cli-argv-inventory.txt | 204 ++++++++++++++++++ scripts/cli-compat.sh | 187 ++++++++++++++++ src/app/api/config/email/route.ts | 2 +- .../api/control-plane/__tests__/route.test.ts | 7 +- src/app/api/control-plane/migrate/route.ts | 12 +- src/app/api/control-plane/route.ts | 6 +- .../api/deployment/environments/route.test.ts | 14 +- src/app/api/deployment/environments/route.ts | 7 +- src/app/api/env/route.ts | 4 +- .../api/nself-plugins/[name]/install/route.ts | 2 +- .../api/nself-plugins/[name]/revoke/route.ts | 2 +- .../nself-plugins/[name]/uninstall/route.ts | 2 +- .../api/nself-plugins/[name]/update/route.ts | 2 +- src/app/api/nself/trust/route.ts | 2 +- src/app/api/nself/urls/route.ts | 2 +- src/app/api/plugins/[name]/install/route.ts | 4 +- src/app/api/plugins/[name]/logs/route.ts | 4 +- src/app/api/plugins/[name]/route.ts | 15 +- src/app/api/plugins/[name]/sync/route.ts | 2 +- src/app/api/plugins/[name]/update/route.ts | 14 +- src/app/api/plugins/marketplace/route.ts | 2 +- src/app/api/plugins/route.ts | 4 +- src/app/api/plugins/stripe/sync/route.ts | 2 +- src/app/api/plugins/updates/route.ts | 2 +- src/app/api/services/admin/route.ts | 2 +- src/app/api/services/cache/cli/route.ts | 2 +- src/app/api/services/cache/flush/route.ts | 2 +- src/app/api/services/cache/init/route.ts | 2 +- src/app/api/services/cache/stats/route.ts | 2 +- src/app/api/services/email/config/route.ts | 2 +- src/app/api/services/email/send/route.ts | 2 +- src/app/api/services/email/templates/route.ts | 2 +- src/app/api/services/email/test/route.ts | 2 +- src/app/api/services/functions/init/route.ts | 2 +- src/app/api/services/init/route.ts | 2 +- .../api/services/mlflow/experiments/route.ts | 2 +- src/app/api/services/mlflow/init/route.ts | 2 +- src/app/api/services/mlflow/models/route.ts | 2 +- src/app/api/services/mlflow/ui/route.ts | 2 +- src/app/api/services/nginx/route.ts | 2 +- .../api/services/realtime/channels/route.ts | 2 +- src/app/api/services/realtime/events/route.ts | 2 +- src/app/api/services/realtime/init/route.ts | 2 +- src/app/api/services/realtime/test/route.ts | 2 +- src/app/api/services/scaffold/route.ts | 5 +- .../services/search-engine/config/route.ts | 2 +- .../api/services/search-engine/index/route.ts | 2 +- .../api/services/search-engine/init/route.ts | 10 +- .../api/services/search-engine/query/route.ts | 7 +- src/app/api/services/search/route.ts | 6 +- src/app/api/services/storage/config/route.ts | 2 +- src/app/api/services/storage/init/route.ts | 2 +- src/app/api/services/storage/list/route.ts | 2 +- src/app/api/services/storage/status/route.ts | 2 +- src/app/api/services/storage/test/route.ts | 2 +- src/app/api/services/storage/upload/route.ts | 2 +- src/features/env-switcher/env-switcher.ts | 2 +- src/lib/__tests__/nselfCLI.test.ts | 141 +++++++++++- src/lib/health-checks.ts | 8 +- src/lib/nself-path.ts | 58 +++++ src/lib/nself-service.ts | 2 +- src/lib/nselfCLI.ts | 81 ++----- src/lib/nselfCanon.ts | 111 ++++++++++ 65 files changed, 862 insertions(+), 163 deletions(-) create mode 100644 scripts/cli-argv-inventory.txt create mode 100755 scripts/cli-compat.sh create mode 100644 src/lib/nselfCanon.ts diff --git a/.github/wiki/CLI_INTEGRATION.md b/.github/wiki/CLI_INTEGRATION.md index 7b0be014..acf1e86c 100644 --- a/.github/wiki/CLI_INTEGRATION.md +++ b/.github/wiki/CLI_INTEGRATION.md @@ -292,6 +292,34 @@ npm test 3. Make changes and test 4. Submit pull request +## Compatibility with cli v1.5 + +Admin ships in lockstep with the cli (release train P7-SHIP-06): **admin 1.5.x needs cli 1.5.x**. Admin 1.5 builds its argv with the canonical spellings of the v1.5 command canon (cli wiki: `Command-Canon.md`), which a cli 1.4 does not know. + +| What admin does | Where | +|---|---| +| Every child process gets `NSELF_JSON_LEGACY=1` (the caller can override it), so the JSON admin parses keeps the bare cli 1.4 shape instead of the v1 envelope. | `getNselfChildEnv` and `ensureLegacyJsonEnv` in `src/lib/nself-path.ts`; `next.config.mjs` sets the process default | +| `NSELF_BIN` names the binary to run, ahead of `NSELF_CLI_PATH` and discovery. A path that does not exist is ignored with a warning. | `getNselfBinOverride` in `src/lib/nself-path.ts` | +| Relocated commands use the canonical spelling: `service` is `config services`, `secrets` is `config secrets`, `env` is `config env`, `urls` is `status urls`, `health` is `status health`, `trust` is `config trust`, `plugin install\|remove` is `add\|remove`, other `plugin` commands are `config plugins`, `plugin test` is `plugin-dev test`. | call sites under `src/` | +| `executeNselfCommand` and `streamNselfCommand` rewrite an old root passed by a dynamic caller (a workflow step, a typed command) to the canonical argv before the exec. | `src/lib/nselfCanon.ts` | +| `functions` and `plugin-dev` are plugin commands; the cli installs them on first use (auto-add). | allowlist in `src/lib/nselfCanon.ts` | + +### The compat check + +```bash +NSELF_BIN=/path/to/nself NSELF_V15=1 bash scripts/cli-compat.sh # v1.5 mode +NSELF_BIN=/path/to/nself bash scripts/cli-compat.sh # v1.4 mode +``` + +`scripts/cli-compat.sh` needs bash, perl and jq. It writes the sorted argv prefixes admin builds to `scripts/cli-argv-inventory.txt` (commit the file; CI-style check: `git diff --exit-code scripts/cli-argv-inventory.txt` after a run). It then fails when + +- a prefix starts with an old spelling that cli v1.5 keeps only as a deprecated shim (read from `nself help --json`), or +- the longest prefix that is a cli command exits non-zero on `--help` or prints a `moved to` warning. + +A prefix whose full path is not a cli command is printed as `UNKNOWN`. That is admin calling something the cli does not have (`tenant`, `auth`, `perf`, `bench`, `ssl`, ...), or a v1.5 spelling when the binary runs in v1.4 mode. UNKNOWN is a report, not a failure. The pre-existing drift is tracked in `hq/quality/debt.yaml`. + +The check never installs a plugin and never reaches the network: it runs with `NSELF_NO_AUTO_ADD=1` and an empty `HOME`. + ## Support - GitHub Issues: https://github.com/nself-org/admin/issues diff --git a/next.config.mjs b/next.config.mjs index 35180244..843e7653 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,12 @@ import withBundleAnalyzer from '@next/bundle-analyzer' import createNextIntlPlugin from 'next-intl/plugin' +// cli 1.5 prints the v1 JSON envelope by default; admin parses the bare cli 1.4 +// shape. Set once here so every route that runs `nself` inherits it, including +// the ones that spawn a bare `nself` and never call findNselfPath (P7-CANON-20). +// An explicit NSELF_JSON_LEGACY in the environment wins. +process.env.NSELF_JSON_LEGACY ??= '1' + const bundleAnalyzer = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', }) diff --git a/scripts/cli-argv-inventory.txt b/scripts/cli-argv-inventory.txt new file mode 100644 index 00000000..d41b0cdd --- /dev/null +++ b/scripts/cli-argv-inventory.txt @@ -0,0 +1,204 @@ +add +apply +audit +auth devices list +auth devices register +auth devices revoke +auth devices trust +auth mfa backup-codes +auth mfa disable +auth mfa enable +auth mfa reset +auth mfa verify +auth oauth config +auth oauth disable +auth oauth enable +auth oauth install +auth oauth list +auth oauth status +auth oauth test +auth rate-limit config +auth rate-limit reset +auth rate-limit status +auth security audit +auth security report +auth security scan +auth webhooks create +auth webhooks delete +auth webhooks list +auth webhooks logs +auth webhooks show +auth webhooks test +backup +backup create +backup list +backup restore +backup schedule +bench baseline +bench compare +bench run +bench stress +build +claw import +cloud server create +cloud server destroy +cloud server status +config +config env +config env diff +config env list +config env target +config export +config import +config plugins +config plugins disable +config plugins list +config plugins logs +config plugins status +config plugins stripe +config plugins uninstall +config plugins update +config secrets +config secrets delete +config secrets get +config secrets list +config secrets rotate +config secrets set +config services +config services admin +config services cache +config services email +config services functions +config services init +config services mlflow +config services realtime +config services scaffold +config services search +config services storage +config sync +config trust status +config validate +config vault config +config vault init +config vault status +db analyze +db backup +db backup list +db list +db migrate +db query +db reset +db restore +db seed +db stats +db status +db sync +db table-data +db tables +deploy +deploy blue-green status +deploy canary status +deploy environments +deploy environments configure +deploy environments diff +deploy preview destroy +deploy preview list +deploy production +deploy staging +deploy status +doctor +export +frontend get +frontend list +frontend remove +functions delete +functions deploy +functions invoke +functions list +functions logs +helm install +helm repos list +help +init +k8s clusters +k8s logs +k8s namespaces +k8s namespaces delete +k8s namespaces get +k8s status +logs +monitor +monitor alerts rules +perf analyze +perf profile +perf report +perf slow-queries +perf suggest +plugin-dev test transactional-email +prod status +remove +reset +restart +restore +scale +scale auto status +scale status +ssl generate +ssl letsencrypt +ssl provision +ssl renew +ssl revoke +ssl status +ssl trust +staging +staging status +start +status +status health +status urls +stop +sync status +tenant activate +tenant branding colors +tenant branding logo +tenant branding preview +tenant branding reset +tenant branding show +tenant create +tenant delete +tenant domain add +tenant domain list +tenant domain primary +tenant domain remove +tenant domain show +tenant domain verify +tenant email edit +tenant email list +tenant email show +tenant list +tenant member add +tenant member list +tenant member remove +tenant member resend-invite +tenant member show +tenant member update +tenant org create +tenant org delete +tenant org list +tenant org member +tenant org permission +tenant org role +tenant org show +tenant org team +tenant org update +tenant settings list +tenant settings set +tenant show +tenant stats +tenant suspend +tenant theme apply +tenant theme customize +tenant theme list +tenant update +update +version diff --git a/scripts/cli-compat.sh b/scripts/cli-compat.sh new file mode 100755 index 00000000..edb70e5a --- /dev/null +++ b/scripts/cli-compat.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# cli-compat.sh (P7-CANON-20) +# +# Proves the shipped admin still works against a cli build, in v1.4 mode +# (NSELF_V15 unset) and in v1.5 mode (NSELF_V15=1): +# +# 1. Extracts every nself argv prefix admin builds from src/ (non-test files) +# into scripts/cli-argv-inventory.txt, sorted, at most three words each: +# executeNselfCommand('', ['a', 'b', ...]) and streamNselfCommand +# execFile/spawn(, ['a', 'b', ...]) +# `${nselfPath} a b ...` and exec('nself a b ...') strings +# runCLI(['a', 'b', ...]) +# A variable argument (execFile(bin, args)) is resolved to the literal +# array it was declared with, when there is one. +# 2. Fails when a prefix starts with an old spelling, i.e. a command that cli +# v1.5 keeps only as a deprecated shim until v1.6.0 (read from +# `NSELF_V15=1 nself help --json`, canon == deprecated-shim). +# 3. For each prefix, finds the longest word run that is a command in +# `nself help --json` of the current mode and runs ` --help`; fails +# on a non-zero exit or on a `moved to` warning. A prefix whose full path +# is not a cli command is printed as UNKNOWN (pre-existing admin/cli +# drift, not a failure). Nothing here installs a plugin or reaches the +# network: NSELF_NO_AUTO_ADD=1 and an empty HOME. +# +# Usage: NSELF_BIN=/path/to/nself [NSELF_V15=1] bash scripts/cli-compat.sh +# (NSELF_BIN defaults to `nself` on PATH.) +# Needs: bash, perl, jq. +# Exit: 0 ok, 1 a prefix fails a check, 2 usage or missing tool. + +set -euo pipefail +export LC_ALL=C + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +INVENTORY="${SCRIPT_DIR}/cli-argv-inventory.txt" +BIN="${NSELF_BIN:-$(command -v nself || true)}" +MODE="v1.4" +[ -n "${NSELF_V15:-}" ] && MODE="v1.5" + +for tool in perl jq; do + command -v "$tool" >/dev/null 2>&1 || { echo "cli-compat: $tool is required" >&2; exit 2; } +done +if [ -z "$BIN" ] || [ ! -x "$BIN" ]; then + echo "cli-compat: no nself binary (set NSELF_BIN to an executable)" >&2 + exit 2 +fi + +WORK="$(mktemp -d "${TMPDIR:-/tmp}/cli-compat.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT +mkdir -p "$WORK/home" "$WORK/cwd" + +# Run the cli in an empty HOME and cwd, with auto-add off. +nself_run() { + (cd "$WORK/cwd" && HOME="$WORK/home" NSELF_NO_AUTO_ADD=1 "$BIN" "$@" 2 ? 2 : $#w)])} = 1; } + for my $file (@ARGV) { + open(my $fh, "<", $file) or die "$file: $!"; + local $/; my $t = <$fh>; close $fh; + my @decl; # [offset, variable, literal words] of array initializers + while ($t =~ /(?:\b(?:const|let|var)\s+)?\b(\w+)\s*(?::\s*[\w\[\]<>| ]+)?(?])=(?![=>])\s*(\[)?/g) { + my ($v, $at, $arr) = ($1, pos($t), $2); + push @decl, [$at, $v, [$arr ? words($t) : ()]]; + pos($t) = $at; + } + my $lookup = sub { # literal words of the nearest declaration of $v before offset $p + my ($v, $p) = @_; my @w; + for my $d (@decl) { @w = @{ $d->[2] } if $d->[1] eq $v && $d->[0] < $p; } + return @w; + }; + # executeNselfCommand / streamNselfCommand + while ($t =~ /\b(?:executeNselfCommand|streamNselfCommand)\(\s*(?:\x27([a-z][a-z0-9-]*)\x27|"([a-z][a-z0-9-]*)")/g) { + my $cmd = defined $1 ? $1 : $2; my @w; + if ($t =~ /\G\s*,\s*\[/gc) { @w = words($t); } + elsif ($t =~ /\G\s*,\s*(\w+)\s*[,)]/gc) { @w = $lookup->($1, pos($t)); } + emit($cmd, @w); + } + # execFile / spawn of the nself binary + while ($t =~ /\b(?:execFileAsync|execFileSync|execFile|spawnSync|spawn)\(\s*(?:nselfPath|nselfCommand|nselfBin|nselfBinary|\x27nself\x27|"nself")\s*,\s*/g) { + my @w; + if ($t =~ /\G\[/gc) { @w = words($t); } + elsif ($t =~ /\G(\w+)\s*[,)]/gc) { @w = $lookup->($1, pos($t)); } + emit(@w); + } + # runCLI([...]) wrappers that prepend the nself binary + while ($t =~ /\brunCLI\(\s*\[/g) { emit(words($t)); } + # ${nselfPath} a b ... and exec("nself a b ...") + my @starts; + while ($t =~ /\$\{(?:nselfPath|nselfCommand|nselfBin|nselfBinary)\}\s+/g) { push @starts, pos($t); } + while ($t =~ /\bexec(?:Async|Sync)?\(\s*[`\x27"]nself\s+/g) { push @starts, pos($t); } + for my $p (@starts) { + pos($t) = $p; my @w; + while ($t =~ /\G([a-z][a-z0-9-]*)(?=[\s`\x27"]|\$|$)\s*/gc) { push @w, $1; } + emit(@w); + } + } + print "$_\n" for sort keys %seen; + ' "$@" +} + +FILES="$WORK/files.txt" +(cd "$ROOT" && find src -type f \( -name '*.ts' -o -name '*.tsx' \) \ + ! -path '*/__tests__/*' ! -name '*.test.*' ! -name '*.spec.*' | sort) >"$FILES" +(cd "$ROOT" && extract $(cat "$FILES")) | sort -u >"$INVENTORY" +FILE_COUNT="$(wc -l <"$FILES" | tr -d ' ')" +LINES="$(wc -l <"$INVENTORY" | tr -d ' ')" +echo "cli-compat [$MODE] $BIN: read $FILE_COUNT source files, $LINES argv prefixes -> scripts/cli-argv-inventory.txt" + +# ── 2. cli command tables ──────────────────────────────────────────────────── + +nself_run help --json >"$WORK/help.json" 2>/dev/null || { echo "cli-compat: nself help --json failed" >&2; exit 1; } +jq -r '.data.commands[].path | sub("^nself ";"")' "$WORK/help.json" | sort -u >"$WORK/paths.txt" +NSELF_V15=1 nself_run help --json 2>/dev/null \ + | jq -r '.data.commands[] | select(.canon == "deprecated-shim") | [(.path | sub("^nself ";"")), ((.target // "") | sub("^nself ";""))] | @tsv' \ + | sort -u >"$WORK/shims.tsv" || true +[ -s "$WORK/shims.tsv" ] || echo "cli-compat: note: this cli lists no deprecated shims (pre-canon build); old-spelling check is empty" + +# ── 3. checks ──────────────────────────────────────────────────────────────── + +FAIL=0 +CHECKED=0 +: >"$WORK/unknown.txt" +while IFS= read -r line; do + [ -n "$line" ] || continue + + # old spelling: the prefix starts with a path that is only a shim in v1.5 + while IFS="$(printf '\t')" read -r old target; do + case "$line" in + "$old"|"$old "*) + echo "FAIL old spelling: 'nself $line' starts with '$old' (use '${target:-the canonical spelling}')" + FAIL=1 + ;; + esac + done <"$WORK/shims.tsv" + + # longest word run that is a command in this mode + set -- $line + n=$# + found="" + k=$n + while [ "$k" -ge 1 ]; do + cand="$(echo "$line" | cut -d' ' -f1-"$k")" + if grep -Fxq -- "$cand" "$WORK/paths.txt"; then found="$cand"; break; fi + k=$((k - 1)) + done + if [ -z "$found" ]; then + echo "$line" >>"$WORK/unknown.txt" + continue + fi + [ "$found" = "$line" ] || echo "$line" >>"$WORK/unknown.txt" + + CHECKED=$((CHECKED + 1)) + # shellcheck disable=SC2086 + if ! out="$(nself_run $found --help 2>&1)"; then + echo "FAIL exit: 'nself $found --help' exited non-zero" + FAIL=1 + elif printf '%s' "$out" | grep -q 'moved to'; then + echo "FAIL shim: 'nself $found' prints a 'moved to' warning" + FAIL=1 + fi +done <"$INVENTORY" + +UNKNOWN_COUNT="$(sort -u "$WORK/unknown.txt" | wc -l | tr -d ' ')" +echo "cli-compat [$MODE]: $CHECKED commands run with --help, $UNKNOWN_COUNT prefixes not (fully) in this cli (UNKNOWN, pre-existing drift)" +sort -u "$WORK/unknown.txt" | sed 's/^/UNKNOWN nself /' + +if [ "$FAIL" -ne 0 ]; then + echo "cli-compat [$MODE]: FAIL" + exit 1 +fi +echo "cli-compat [$MODE]: PASS" diff --git a/src/app/api/config/email/route.ts b/src/app/api/config/email/route.ts index aa9ac66a..a3feb91e 100644 --- a/src/app/api/config/email/route.ts +++ b/src/app/api/config/email/route.ts @@ -141,7 +141,7 @@ export async function POST(request: NextRequest): Promise { // Test email action — delegates to CLI plugin test if (action === 'test') { - const result = await executeNselfCommand('plugin', ['test', 'transactional-email'], {}) + const result = await executeNselfCommand('plugin-dev', ['test', 'transactional-email'], {}) return NextResponse.json({ success: result.success, message: result.success ? 'Test email sent successfully' : 'Test email failed', diff --git a/src/app/api/control-plane/__tests__/route.test.ts b/src/app/api/control-plane/__tests__/route.test.ts index 292a6ce4..35883de8 100644 --- a/src/app/api/control-plane/__tests__/route.test.ts +++ b/src/app/api/control-plane/__tests__/route.test.ts @@ -162,7 +162,7 @@ describe('GET /api/control-plane', () => { // execFileCustom is what the route actually invokes (no callback arg). expect(mockExecFileCustom).toHaveBeenCalledWith( 'nself', - expect.arrayContaining(['env', 'target', 'probe', '--json']), + expect.arrayContaining(['config', 'env', 'target', 'probe', '--json']), expect.any(Object) ) }) @@ -244,7 +244,7 @@ describe('POST /api/control-plane — add', () => { // Verify CLI args via the promisified mock (execFileCustom — no callback arg). expect(mockExecFileCustom).toHaveBeenCalledWith( 'nself', - ['env', 'target', 'add', 'prod', '--host', '5.5.5.5', '--role', 'primary'], + ['config', 'env', 'target', 'add', 'prod', '--host', '5.5.5.5', '--role', 'primary'], expect.any(Object) ) }) @@ -266,6 +266,7 @@ describe('POST /api/control-plane — add', () => { expect(mockExecFileCustom).toHaveBeenCalledWith( 'nself', [ + 'config', 'env', 'target', 'add', @@ -342,7 +343,7 @@ describe('POST /api/control-plane — remove', () => { expect(json.action).toBe('remove') expect(mockExecFileCustom).toHaveBeenCalledWith( 'nself', - ['env', 'target', 'remove', 'prod'], + ['config', 'env', 'target', 'remove', 'prod'], expect.any(Object) ) }) diff --git a/src/app/api/control-plane/migrate/route.ts b/src/app/api/control-plane/migrate/route.ts index 5eaf6a28..e494c463 100644 --- a/src/app/api/control-plane/migrate/route.ts +++ b/src/app/api/control-plane/migrate/route.ts @@ -151,7 +151,17 @@ export async function POST(request: NextRequest): Promise { const role = conn.mode === 'production' ? 'production' : 'staging' - const execArgs = ['env', 'target', 'add', safeName, '--host', conn.host, '--role', role] + const execArgs = [ + 'config', + 'env', + 'target', + 'add', + safeName, + '--host', + conn.host, + '--role', + role, + ] if (conn.sshUser && validateSafeArg(conn.sshUser)) { execArgs.push('--ssh-user', conn.sshUser) } diff --git a/src/app/api/control-plane/route.ts b/src/app/api/control-plane/route.ts index 833601fc..38f7ca87 100644 --- a/src/app/api/control-plane/route.ts +++ b/src/app/api/control-plane/route.ts @@ -78,7 +78,7 @@ export async function GET(request: NextRequest): Promise { } // Build args: `nself env target list --json` or `nself env target probe --json` - const execArgs = ['env', 'target', action, '--json'] + const execArgs = ['config', 'env', 'target', action, '--json'] if (env) { execArgs.push('--env', env) } @@ -185,7 +185,7 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ success: false, error: 'Invalid sshUser' }, { status: 400 }) } - const execArgs = ['env', 'target', 'add', name, '--host', host, '--role', role] + const execArgs = ['config', 'env', 'target', 'add', name, '--host', host, '--role', role] if (sshUser) execArgs.push('--ssh-user', sshUser) if (sshKeyPath) execArgs.push('--ssh-key', sshKeyPath) @@ -211,7 +211,7 @@ export async function POST(request: NextRequest): Promise { ) } - const execArgs = ['env', 'target', 'remove', name] + const execArgs = ['config', 'env', 'target', 'remove', name] const { stdout, stderr } = await runNselfCommand(execArgs, 30000) return NextResponse.json({ diff --git a/src/app/api/deployment/environments/route.test.ts b/src/app/api/deployment/environments/route.test.ts index 7bb4bb9f..fa0e438f 100644 --- a/src/app/api/deployment/environments/route.test.ts +++ b/src/app/api/deployment/environments/route.test.ts @@ -179,10 +179,10 @@ describe('POST /api/deployment/environments — valid payloads', () => { const req = makeRequest({ action: 'list' }) const response = await POST(req) expect(response.status).toBe(200) - // execFile first arg = 'nself', second = ['env', 'list', ...] + // execFile first arg = 'nself', second = ['config', 'env', 'list', ...] expect(mockExecFile).toHaveBeenCalledWith( 'nself', - ['env', 'list'], + ['config', 'env', 'list'], expect.objectContaining({ cwd: '/tmp/test-project' }), expect.any(Function) ) @@ -193,7 +193,7 @@ describe('POST /api/deployment/environments — valid payloads', () => { await POST(req) expect(mockExecFile).toHaveBeenCalledWith( 'nself', - ['env', 'create', '--', 'staging'], + ['config', 'env', 'create', '--', 'staging'], expect.any(Object), expect.any(Function) ) @@ -204,7 +204,7 @@ describe('POST /api/deployment/environments — valid payloads', () => { await POST(req) expect(mockExecFile).toHaveBeenCalledWith( 'nself', - ['env', 'delete', '--', 'staging', '--force'], + ['config', 'env', 'delete', '--', 'staging', '--force'], expect.any(Object), expect.any(Function) ) @@ -219,7 +219,7 @@ describe('POST /api/deployment/environments — valid payloads', () => { await POST(req) expect(mockExecFile).toHaveBeenCalledWith( 'nself', - ['env', 'diff', '--', 'staging', 'prod'], + ['config', 'env', 'diff', '--', 'staging', 'prod'], expect.any(Object), expect.any(Function) ) @@ -235,7 +235,7 @@ describe('POST /api/deployment/environments — valid payloads', () => { // Correct POSIX order: option flags before '--', user-controlled positional after '--' expect(mockExecFile).toHaveBeenCalledWith( 'nself', - ['env', 'create', '--template', 'minimal', '--', 'myenv'], + ['config', 'env', 'create', '--template', 'minimal', '--', 'myenv'], expect.any(Object), expect.any(Function) ) @@ -249,6 +249,6 @@ describe('POST /api/deployment/environments — valid payloads', () => { }) await POST(req) const [, argv] = mockExecFile.mock.calls[0] as [string, string[]] - expect(argv).toEqual(['env', 'create', '--template', 'minimal', '--', 'myenv']) + expect(argv).toEqual(['config', 'env', 'create', '--template', 'minimal', '--', 'myenv']) }) }) diff --git a/src/app/api/deployment/environments/route.ts b/src/app/api/deployment/environments/route.ts index bb3c325f..a3d63f0e 100644 --- a/src/app/api/deployment/environments/route.ts +++ b/src/app/api/deployment/environments/route.ts @@ -95,13 +95,14 @@ export async function POST(request: NextRequest): Promise { let args: string[] switch (action) { case 'list': - args = ['env', 'list'] + args = ['config', 'env', 'list'] break case 'create': { // Flag arguments (--template) must precede the '--' end-of-options separator. // User-controlled positionals come after '--' so they cannot be misinterpreted as flags. args = [ + 'config', 'env', 'create', ...(options.template ? ['--template', options.template] : []), @@ -112,14 +113,14 @@ export async function POST(request: NextRequest): Promise { } case 'delete': - args = ['env', 'delete', '--', environment as string] + args = ['config', 'env', 'delete', '--', environment as string] if (options.force) { args.push('--force') } break case 'diff': - args = ['env', 'diff', '--', environment as string, options.compare as string] + args = ['config', 'env', 'diff', '--', environment as string, options.compare as string] break } diff --git a/src/app/api/env/route.ts b/src/app/api/env/route.ts index a1558354..3b558434 100644 --- a/src/app/api/env/route.ts +++ b/src/app/api/env/route.ts @@ -127,7 +127,7 @@ export async function GET(): Promise { // Also try nself env list command try { - const { stdout } = await execFileAsync('nself', ['env', 'list'], { + const { stdout } = await execFileAsync('nself', ['config', 'env', 'list'], { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 10000, @@ -182,7 +182,7 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ success: false, error: 'Invalid template name' }, { status: 400 }) } - const execArgs: string[] = ['env'] + const execArgs: string[] = ['config', 'env'] switch (action) { case 'create': { diff --git a/src/app/api/nself-plugins/[name]/install/route.ts b/src/app/api/nself-plugins/[name]/install/route.ts index 73cefe6c..091955f3 100644 --- a/src/app/api/nself-plugins/[name]/install/route.ts +++ b/src/app/api/nself-plugins/[name]/install/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest, context: { params: Promise<{ na return NextResponse.json({ error: 'Invalid plugin name.' }, { status: 400 }) } try { - const result = await executeNselfCommand('plugin', ['install', name]) + const result = await executeNselfCommand('add', [name]) if (!result.success) { return NextResponse.json( { diff --git a/src/app/api/nself-plugins/[name]/revoke/route.ts b/src/app/api/nself-plugins/[name]/revoke/route.ts index 5e4c69b2..757c12c5 100644 --- a/src/app/api/nself-plugins/[name]/revoke/route.ts +++ b/src/app/api/nself-plugins/[name]/revoke/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest, context: { params: Promise<{ na return NextResponse.json({ error: 'Invalid plugin name.' }, { status: 400 }) } try { - const result = await executeNselfCommand('plugin', ['disable', name]) + const result = await executeNselfCommand('config', ['plugins', 'disable', name]) if (!result.success) { return NextResponse.json( { diff --git a/src/app/api/nself-plugins/[name]/uninstall/route.ts b/src/app/api/nself-plugins/[name]/uninstall/route.ts index d3bd25f2..edc0b91e 100644 --- a/src/app/api/nself-plugins/[name]/uninstall/route.ts +++ b/src/app/api/nself-plugins/[name]/uninstall/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest, context: { params: Promise<{ na return NextResponse.json({ error: 'Invalid plugin name.' }, { status: 400 }) } try { - const result = await executeNselfCommand('plugin', ['uninstall', name]) + const result = await executeNselfCommand('config', ['plugins', 'uninstall', name]) if (!result.success) { return NextResponse.json( { diff --git a/src/app/api/nself-plugins/[name]/update/route.ts b/src/app/api/nself-plugins/[name]/update/route.ts index d9a1724f..5ae8c48d 100644 --- a/src/app/api/nself-plugins/[name]/update/route.ts +++ b/src/app/api/nself-plugins/[name]/update/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest, context: { params: Promise<{ na return NextResponse.json({ error: 'Invalid plugin name.' }, { status: 400 }) } try { - const result = await executeNselfCommand('plugin', ['update', name]) + const result = await executeNselfCommand('config', ['plugins', 'update', name]) if (!result.success) { return NextResponse.json( { diff --git a/src/app/api/nself/trust/route.ts b/src/app/api/nself/trust/route.ts index 6ad0e5e5..4a47855a 100644 --- a/src/app/api/nself/trust/route.ts +++ b/src/app/api/nself/trust/route.ts @@ -23,7 +23,7 @@ export async function GET(): Promise { // Run nself trust status — may not exist on all versions; fall back to nself doctor let raw = '' try { - const { stdout, stderr } = await execAsync(`${nselfCommand} trust status`, { + const { stdout, stderr } = await execAsync(`${nselfCommand} config trust status`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 10000, diff --git a/src/app/api/nself/urls/route.ts b/src/app/api/nself/urls/route.ts index ca1a4013..1f967aeb 100644 --- a/src/app/api/nself/urls/route.ts +++ b/src/app/api/nself/urls/route.ts @@ -45,7 +45,7 @@ export async function GET(): Promise { const nselfCommand = await findNselfPath() const projectPath = await getProjectPath() - const { stdout, stderr } = await execAsync(`${nselfCommand} urls --json`, { + const { stdout, stderr } = await execAsync(`${nselfCommand} status urls --json`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 10000, diff --git a/src/app/api/plugins/[name]/install/route.ts b/src/app/api/plugins/[name]/install/route.ts index 415bc448..b97ef894 100644 --- a/src/app/api/plugins/[name]/install/route.ts +++ b/src/app/api/plugins/[name]/install/route.ts @@ -118,7 +118,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise logger.info('Installing plugin', { name, pro: isPro }) - const { stdout, stderr } = await execFileAsync(nselfPath, ['plugin', 'install', name], { + const { stdout, stderr } = await execFileAsync(nselfPath, ['add', name], { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 120000, @@ -167,7 +167,7 @@ export async function DELETE(request: NextRequest, context: RouteContext): Promi logger.info('Removing plugin', { name }) - const { stdout, stderr } = await execFileAsync(nselfPath, ['plugin', 'remove', name], { + const { stdout, stderr } = await execFileAsync(nselfPath, ['remove', name], { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 60000, diff --git a/src/app/api/plugins/[name]/logs/route.ts b/src/app/api/plugins/[name]/logs/route.ts index 032c6768..dceb56a8 100644 --- a/src/app/api/plugins/[name]/logs/route.ts +++ b/src/app/api/plugins/[name]/logs/route.ts @@ -34,7 +34,7 @@ async function getLogsSnapshot( try { const { stdout, stderr } = await execAsync( - `${nselfPath} plugin logs ${name} --lines ${lines} --no-follow --no-color`, + `${nselfPath} config plugins logs ${name} --lines ${lines} --no-follow --no-color`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, @@ -98,7 +98,7 @@ async function streamLogs( start(controller) { const child = spawn( nselfPath, - ['plugin', 'logs', name, '--lines', String(lines), '--follow', '--no-color'], + ['config', 'plugins', 'logs', name, '--lines', String(lines), '--follow', '--no-color'], { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, diff --git a/src/app/api/plugins/[name]/route.ts b/src/app/api/plugins/[name]/route.ts index f8386df3..2c4e37a1 100644 --- a/src/app/api/plugins/[name]/route.ts +++ b/src/app/api/plugins/[name]/route.ts @@ -35,11 +35,14 @@ export async function GET(request: NextRequest, context: RouteContext): Promise< const projectPath = getProjectPath() const nselfPath = await findNselfPath() - const { stdout, stderr } = await execAsync(`${nselfPath} plugin status ${name} --json`, { - cwd: projectPath, - env: { ...process.env, PATH: getEnhancedPath() }, - timeout: 30000, - }) + const { stdout, stderr } = await execAsync( + `${nselfPath} config plugins status ${name} --json`, + { + cwd: projectPath, + env: { ...process.env, PATH: getEnhancedPath() }, + timeout: 30000, + } + ) if (stderr && !stdout) { logger.warn('Plugin status returned stderr', { name, stderr }) @@ -106,7 +109,7 @@ export async function DELETE(request: NextRequest, context: RouteContext): Promi logger.info('Removing plugin', { name }) - const { stdout, stderr } = await execAsync(`${nselfPath} plugin remove ${name}`, { + const { stdout, stderr } = await execAsync(`${nselfPath} remove ${name}`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 60000, diff --git a/src/app/api/plugins/[name]/sync/route.ts b/src/app/api/plugins/[name]/sync/route.ts index 4b731e93..7a8d2945 100644 --- a/src/app/api/plugins/[name]/sync/route.ts +++ b/src/app/api/plugins/[name]/sync/route.ts @@ -39,7 +39,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise logger.info('Triggering plugin sync', { name }) - const { stdout, stderr } = await execAsync(`${nselfPath} plugin ${name} sync`, { + const { stdout, stderr } = await execAsync(`${nselfPath} config plugins ${name} sync`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 300000, // 5 minute timeout for sync operations diff --git a/src/app/api/plugins/[name]/update/route.ts b/src/app/api/plugins/[name]/update/route.ts index 018de423..15a9ab6f 100644 --- a/src/app/api/plugins/[name]/update/route.ts +++ b/src/app/api/plugins/[name]/update/route.ts @@ -38,11 +38,15 @@ export async function POST(request: NextRequest, context: RouteContext): Promise logger.info('Updating plugin', { name }) - const { stdout, stderr } = await execFileAsync(nselfPath, ['plugin', 'update', name], { - cwd: projectPath, - env: { ...process.env, PATH: getEnhancedPath() }, - timeout: 120000, // 2 minute timeout for update operations - }) + const { stdout, stderr } = await execFileAsync( + nselfPath, + ['config', 'plugins', 'update', name], + { + cwd: projectPath, + env: { ...process.env, PATH: getEnhancedPath() }, + timeout: 120000, // 2 minute timeout for update operations + } + ) logger.cli(`plugin update ${name}`, true, Date.now() - startTime) logger.api('POST', `/api/plugins/${name}/update`, 200, Date.now() - startTime) diff --git a/src/app/api/plugins/marketplace/route.ts b/src/app/api/plugins/marketplace/route.ts index 6db6709c..454b2040 100644 --- a/src/app/api/plugins/marketplace/route.ts +++ b/src/app/api/plugins/marketplace/route.ts @@ -58,7 +58,7 @@ export async function GET(_request: NextRequest): Promise { const projectPath = getProjectPath() const nselfPath = await findNselfPath() - const { stdout } = await execAsync(`${nselfPath} plugin list --available --json`, { + const { stdout } = await execAsync(`${nselfPath} config plugins list --available --json`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 30000, diff --git a/src/app/api/plugins/route.ts b/src/app/api/plugins/route.ts index 073e7281..ed4d231a 100644 --- a/src/app/api/plugins/route.ts +++ b/src/app/api/plugins/route.ts @@ -25,7 +25,7 @@ export async function GET(request: NextRequest): Promise { const projectPath = getProjectPath() const nselfPath = await findNselfPath() - const { stdout, stderr } = await execAsync(`${nselfPath} plugin list --json`, { + const { stdout, stderr } = await execAsync(`${nselfPath} config plugins list --json`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 30000, @@ -104,7 +104,7 @@ export async function POST(request: NextRequest): Promise { // Build install command with optional version const versionSuffix = version ? `@${version}` : '' - const command = `${nselfPath} plugin install ${name}${versionSuffix}` + const command = `${nselfPath} add ${name}${versionSuffix}` logger.info('Installing plugin', { name, version }) diff --git a/src/app/api/plugins/stripe/sync/route.ts b/src/app/api/plugins/stripe/sync/route.ts index a1ec351b..37da04a2 100644 --- a/src/app/api/plugins/stripe/sync/route.ts +++ b/src/app/api/plugins/stripe/sync/route.ts @@ -29,7 +29,7 @@ export async function POST(request: NextRequest): Promise { } // Delegate sync to the nSelf CLI plugin system - const result = await executeNselfCommand('plugin', ['stripe', 'sync']) + const result = await executeNselfCommand('config', ['plugins', 'stripe', 'sync']) if (!result.success) { logger.error('Stripe sync failed', { error: result.error }) diff --git a/src/app/api/plugins/updates/route.ts b/src/app/api/plugins/updates/route.ts index 74036735..76c29f4b 100644 --- a/src/app/api/plugins/updates/route.ts +++ b/src/app/api/plugins/updates/route.ts @@ -96,7 +96,7 @@ async function fetchInstalled(): Promise { try { const projectPath = getProjectPath() const nselfPath = await findNselfPath() - const { stdout } = await execAsync(`${nselfPath} plugin list --installed --json`, { + const { stdout } = await execAsync(`${nselfPath} config plugins list --installed --json`, { cwd: projectPath, env: { ...process.env, PATH: getEnhancedPath() }, timeout: 30000, diff --git a/src/app/api/services/admin/route.ts b/src/app/api/services/admin/route.ts index 84004c28..d9534b75 100644 --- a/src/app/api/services/admin/route.ts +++ b/src/app/api/services/admin/route.ts @@ -7,7 +7,7 @@ import { NextResponse } from 'next/server' */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['admin']) + const result = await executeNselfCommand('config', ['services', 'admin']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/cache/cli/route.ts b/src/app/api/services/cache/cli/route.ts index 81ff9d44..5544e06c 100644 --- a/src/app/api/services/cache/cli/route.ts +++ b/src/app/api/services/cache/cli/route.ts @@ -21,7 +21,7 @@ export async function POST(request: NextRequest): Promise { ) } - const result = await executeNselfCommand('service', ['cache', 'cli', command.trim()]) + const result = await executeNselfCommand('config', ['services', 'cache', 'cli', command.trim()]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/cache/flush/route.ts b/src/app/api/services/cache/flush/route.ts index 4885208d..c44563d0 100644 --- a/src/app/api/services/cache/flush/route.ts +++ b/src/app/api/services/cache/flush/route.ts @@ -7,7 +7,7 @@ export async function POST(request: NextRequest): Promise { if (authError) return authError try { - const result = await executeNselfCommand('service', ['cache', 'flush']) + const result = await executeNselfCommand('config', ['services', 'cache', 'flush']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/cache/init/route.ts b/src/app/api/services/cache/init/route.ts index c10ed8bc..5fcb0a18 100644 --- a/src/app/api/services/cache/init/route.ts +++ b/src/app/api/services/cache/init/route.ts @@ -7,7 +7,7 @@ export async function POST(request: NextRequest): Promise { if (authError) return authError try { - const result = await executeNselfCommand('service', ['cache', 'init'], { + const result = await executeNselfCommand('config', ['services', 'cache', 'init'], { timeout: 120000, }) diff --git a/src/app/api/services/cache/stats/route.ts b/src/app/api/services/cache/stats/route.ts index 89cfec1d..29715c3b 100644 --- a/src/app/api/services/cache/stats/route.ts +++ b/src/app/api/services/cache/stats/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['cache', 'stats']) + const result = await executeNselfCommand('config', ['services', 'cache', 'stats']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/email/config/route.ts b/src/app/api/services/email/config/route.ts index 08d3540a..9ae5feda 100644 --- a/src/app/api/services/email/config/route.ts +++ b/src/app/api/services/email/config/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['email', 'config']) + const result = await executeNselfCommand('config', ['services', 'email', 'config']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/email/send/route.ts b/src/app/api/services/email/send/route.ts index fdf6ffc3..537b0117 100644 --- a/src/app/api/services/email/send/route.ts +++ b/src/app/api/services/email/send/route.ts @@ -55,7 +55,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--from=${from}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/email/templates/route.ts b/src/app/api/services/email/templates/route.ts index 53e7d480..e1cc145d 100644 --- a/src/app/api/services/email/templates/route.ts +++ b/src/app/api/services/email/templates/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['email', 'template']) + const result = await executeNselfCommand('config', ['services', 'email', 'template']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/email/test/route.ts b/src/app/api/services/email/test/route.ts index ce90dda4..acc08a81 100644 --- a/src/app/api/services/email/test/route.ts +++ b/src/app/api/services/email/test/route.ts @@ -21,7 +21,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--to=${to}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/functions/init/route.ts b/src/app/api/services/functions/init/route.ts index 196fba50..e467dbcc 100644 --- a/src/app/api/services/functions/init/route.ts +++ b/src/app/api/services/functions/init/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest): Promise { if (authError) return authError try { - const result = await executeNselfCommand('service', ['functions', 'init']) + const result = await executeNselfCommand('config', ['services', 'functions', 'init']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/init/route.ts b/src/app/api/services/init/route.ts index 9e6eb811..b344d4c0 100644 --- a/src/app/api/services/init/route.ts +++ b/src/app/api/services/init/route.ts @@ -35,7 +35,7 @@ export async function POST(request: NextRequest): Promise { ) } - const result = await executeNselfCommand('service', ['init', `--name=${name}`]) + const result = await executeNselfCommand('config', ['services', 'init', `--name=${name}`]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/mlflow/experiments/route.ts b/src/app/api/services/mlflow/experiments/route.ts index 21347a7c..020ce4fe 100644 --- a/src/app/api/services/mlflow/experiments/route.ts +++ b/src/app/api/services/mlflow/experiments/route.ts @@ -7,7 +7,7 @@ import { NextResponse } from 'next/server' */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['mlflow', 'experiments']) + const result = await executeNselfCommand('config', ['services', 'mlflow', 'experiments']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/mlflow/init/route.ts b/src/app/api/services/mlflow/init/route.ts index c39a3c5d..b64c5abc 100644 --- a/src/app/api/services/mlflow/init/route.ts +++ b/src/app/api/services/mlflow/init/route.ts @@ -11,7 +11,7 @@ export async function POST(request: NextRequest): Promise { if (authError) return authError try { - const result = await executeNselfCommand('service', ['mlflow', 'init']) + const result = await executeNselfCommand('config', ['services', 'mlflow', 'init']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/mlflow/models/route.ts b/src/app/api/services/mlflow/models/route.ts index a0c77e37..6188cf85 100644 --- a/src/app/api/services/mlflow/models/route.ts +++ b/src/app/api/services/mlflow/models/route.ts @@ -7,7 +7,7 @@ import { NextResponse } from 'next/server' */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['mlflow', 'models']) + const result = await executeNselfCommand('config', ['services', 'mlflow', 'models']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/mlflow/ui/route.ts b/src/app/api/services/mlflow/ui/route.ts index 9d1d9504..9a4554b6 100644 --- a/src/app/api/services/mlflow/ui/route.ts +++ b/src/app/api/services/mlflow/ui/route.ts @@ -7,7 +7,7 @@ import { NextResponse } from 'next/server' */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['mlflow', 'ui']) + const result = await executeNselfCommand('config', ['services', 'mlflow', 'ui']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/nginx/route.ts b/src/app/api/services/nginx/route.ts index e53f34f4..de8032a5 100644 --- a/src/app/api/services/nginx/route.ts +++ b/src/app/api/services/nginx/route.ts @@ -37,7 +37,7 @@ export async function GET(): Promise { try { // Parallel: fetch URLs (routes) + SSL status. const [urlsRaw, sslRaw] = await Promise.allSettled([ - runCLI(['urls', '--json']), + runCLI(['status', 'urls', '--json']), runCLI(['ssl', 'status', '--json']), ]) diff --git a/src/app/api/services/realtime/channels/route.ts b/src/app/api/services/realtime/channels/route.ts index 5a957755..e445855c 100644 --- a/src/app/api/services/realtime/channels/route.ts +++ b/src/app/api/services/realtime/channels/route.ts @@ -7,7 +7,7 @@ import { NextResponse } from 'next/server' */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['realtime', 'channels']) + const result = await executeNselfCommand('config', ['services', 'realtime', 'channels']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/realtime/events/route.ts b/src/app/api/services/realtime/events/route.ts index 17fd8bf7..85ab24bc 100644 --- a/src/app/api/services/realtime/events/route.ts +++ b/src/app/api/services/realtime/events/route.ts @@ -7,7 +7,7 @@ import { NextResponse } from 'next/server' */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['realtime', 'events']) + const result = await executeNselfCommand('config', ['services', 'realtime', 'events']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/realtime/init/route.ts b/src/app/api/services/realtime/init/route.ts index 8e2e5e38..e5949dad 100644 --- a/src/app/api/services/realtime/init/route.ts +++ b/src/app/api/services/realtime/init/route.ts @@ -44,7 +44,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--port=${port}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/realtime/test/route.ts b/src/app/api/services/realtime/test/route.ts index 4f711b5b..c263e6c5 100644 --- a/src/app/api/services/realtime/test/route.ts +++ b/src/app/api/services/realtime/test/route.ts @@ -67,7 +67,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--message=${message}`) - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/scaffold/route.ts b/src/app/api/services/scaffold/route.ts index f8057a8a..da2e1063 100644 --- a/src/app/api/services/scaffold/route.ts +++ b/src/app/api/services/scaffold/route.ts @@ -108,7 +108,8 @@ export async function POST(request: NextRequest): Promise { ) } - const result = await executeNselfCommand('service', [ + const result = await executeNselfCommand('config', [ + 'services', 'scaffold', `--template=${template.toLowerCase()}`, `--name=${name}`, @@ -151,7 +152,7 @@ export async function POST(request: NextRequest): Promise { */ export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['scaffold', '--list']) + const result = await executeNselfCommand('config', ['services', 'scaffold', '--list']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/search-engine/config/route.ts b/src/app/api/services/search-engine/config/route.ts index c1904aea..2864a2e3 100644 --- a/src/app/api/services/search-engine/config/route.ts +++ b/src/app/api/services/search-engine/config/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['search', 'config']) + const result = await executeNselfCommand('config', ['services', 'search', 'config']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/search-engine/index/route.ts b/src/app/api/services/search-engine/index/route.ts index a46ef015..5935d663 100644 --- a/src/app/api/services/search-engine/index/route.ts +++ b/src/app/api/services/search-engine/index/route.ts @@ -7,7 +7,7 @@ export async function POST(request: NextRequest): Promise { if (authError) return authError try { - const result = await executeNselfCommand('service', ['search', 'index'], { + const result = await executeNselfCommand('config', ['services', 'search', 'index'], { timeout: 120000, }) diff --git a/src/app/api/services/search-engine/init/route.ts b/src/app/api/services/search-engine/init/route.ts index 0cd885ff..2ace540a 100644 --- a/src/app/api/services/search-engine/init/route.ts +++ b/src/app/api/services/search-engine/init/route.ts @@ -23,9 +23,13 @@ export async function POST(request: NextRequest): Promise { ) } - const result = await executeNselfCommand('service', ['search', 'init', `--engine=${engine}`], { - timeout: 120000, - }) + const result = await executeNselfCommand( + 'config', + ['services', 'search', 'init', `--engine=${engine}`], + { + timeout: 120000, + } + ) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/search-engine/query/route.ts b/src/app/api/services/search-engine/query/route.ts index 21bf602a..270c62c8 100644 --- a/src/app/api/services/search-engine/query/route.ts +++ b/src/app/api/services/search-engine/query/route.ts @@ -21,7 +21,12 @@ export async function POST(request: NextRequest): Promise { ) } - const result = await executeNselfCommand('service', ['search', 'query', `--q=${query.trim()}`]) + const result = await executeNselfCommand('config', [ + 'services', + 'search', + 'query', + `--q=${query.trim()}`, + ]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/search/route.ts b/src/app/api/services/search/route.ts index 0c8d717e..f762de0b 100644 --- a/src/app/api/services/search/route.ts +++ b/src/app/api/services/search/route.ts @@ -23,7 +23,11 @@ export async function GET(request: NextRequest): Promise { return NextResponse.json({ success: false, error: 'Invalid search query' }, { status: 400 }) } - const result = await executeNselfCommand('service', ['search', `--query=${sanitized}`]) + const result = await executeNselfCommand('config', [ + 'services', + 'search', + `--query=${sanitized}`, + ]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/storage/config/route.ts b/src/app/api/services/storage/config/route.ts index 3b1898ce..e8cdefa0 100644 --- a/src/app/api/services/storage/config/route.ts +++ b/src/app/api/services/storage/config/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['storage', 'config']) + const result = await executeNselfCommand('config', ['services', 'storage', 'config']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/storage/init/route.ts b/src/app/api/services/storage/init/route.ts index 693a2d47..15d6ef6f 100644 --- a/src/app/api/services/storage/init/route.ts +++ b/src/app/api/services/storage/init/route.ts @@ -31,7 +31,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--provider=${provider}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/storage/list/route.ts b/src/app/api/services/storage/list/route.ts index ebbd115e..b5deeb7b 100644 --- a/src/app/api/services/storage/list/route.ts +++ b/src/app/api/services/storage/list/route.ts @@ -15,7 +15,7 @@ export async function GET(request: NextRequest): Promise { args.push(`--prefix=${prefix}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/storage/status/route.ts b/src/app/api/services/storage/status/route.ts index d5087ab2..74654dc9 100644 --- a/src/app/api/services/storage/status/route.ts +++ b/src/app/api/services/storage/status/route.ts @@ -3,7 +3,7 @@ import { NextResponse } from 'next/server' export async function GET(): Promise { try { - const result = await executeNselfCommand('service', ['storage', 'status']) + const result = await executeNselfCommand('config', ['services', 'storage', 'status']) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/storage/test/route.ts b/src/app/api/services/storage/test/route.ts index 1392b07f..ac3dfbd6 100644 --- a/src/app/api/services/storage/test/route.ts +++ b/src/app/api/services/storage/test/route.ts @@ -25,7 +25,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--bucket=${bucket}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/app/api/services/storage/upload/route.ts b/src/app/api/services/storage/upload/route.ts index ab6eb57f..77a7303c 100644 --- a/src/app/api/services/storage/upload/route.ts +++ b/src/app/api/services/storage/upload/route.ts @@ -42,7 +42,7 @@ export async function POST(request: NextRequest): Promise { args.push(`--destination=${destination}`) } - const result = await executeNselfCommand('service', args) + const result = await executeNselfCommand('config', ['services', ...args]) if (!result.success) { return NextResponse.json( diff --git a/src/features/env-switcher/env-switcher.ts b/src/features/env-switcher/env-switcher.ts index 4856f351..60bb487a 100644 --- a/src/features/env-switcher/env-switcher.ts +++ b/src/features/env-switcher/env-switcher.ts @@ -56,7 +56,7 @@ function buildExecEnv(): NodeJS.ProcessEnv { */ async function runEnvCommand(args: string[]): Promise { try { - const { stdout } = await execFileAsync('nself', ['env', ...args], { + const { stdout } = await execFileAsync('nself', ['config', 'env', ...args], { env: buildExecEnv(), timeout: CLI_TIMEOUT_MS, }) diff --git a/src/lib/__tests__/nselfCLI.test.ts b/src/lib/__tests__/nselfCLI.test.ts index d06d5d58..837ad9f2 100644 --- a/src/lib/__tests__/nselfCLI.test.ts +++ b/src/lib/__tests__/nselfCLI.test.ts @@ -27,10 +27,14 @@ jest.mock('util', () => ({ })) // Mock nself-path to return a known path -jest.mock('../nself-path', () => ({ - findNselfPathSync: jest.fn(() => '/usr/local/bin/nself'), - getEnhancedPath: jest.fn(() => '/usr/local/bin:/usr/bin'), -})) +jest.mock('../nself-path', () => { + const actual = jest.requireActual('../nself-path') + return { + ...actual, + findNselfPathSync: jest.fn(() => '/usr/local/bin/nself'), + getEnhancedPath: jest.fn(() => '/usr/local/bin:/usr/bin'), + } +}) // Mock paths to return a known project path jest.mock('../paths', () => ({ @@ -597,7 +601,7 @@ describe('nself CLI Module', () => { expect(result.success).toBe(true) expect(mockExecFile).toHaveBeenCalledWith( '/usr/local/bin/nself', - ['urls'], + ['status', 'urls'], expect.any(Object) ) }) @@ -607,7 +611,7 @@ describe('nself CLI Module', () => { expect(result.success).toBe(true) expect(mockExecFile).toHaveBeenCalledWith( '/usr/local/bin/nself', - ['urls', '--format', 'json'], + ['status', 'urls', '--format', 'json'], expect.any(Object) ) }) @@ -617,7 +621,7 @@ describe('nself CLI Module', () => { expect(result.success).toBe(true) expect(mockExecFile).toHaveBeenCalledWith( '/usr/local/bin/nself', - ['secrets', 'generate'], + ['config', 'secrets', 'generate'], expect.any(Object) ) }) @@ -627,7 +631,7 @@ describe('nself CLI Module', () => { expect(result.success).toBe(true) expect(mockExecFile).toHaveBeenCalledWith( '/usr/local/bin/nself', - ['secrets', 'rotate', 'postgres'], + ['config', 'secrets', 'rotate', 'postgres'], expect.any(Object) ) }) @@ -667,7 +671,7 @@ describe('nself CLI Module', () => { expect(result.success).toBe(true) expect(mockExecFile).toHaveBeenCalledWith( '/usr/local/bin/nself', - ['health', '--all'], + ['status', 'health', '--all'], expect.any(Object) ) }) @@ -677,7 +681,7 @@ describe('nself CLI Module', () => { expect(result.success).toBe(true) expect(mockExecFile).toHaveBeenCalledWith( '/usr/local/bin/nself', - ['health', 'postgres'], + ['status', 'health', 'postgres'], expect.any(Object) ) }) @@ -804,4 +808,121 @@ describe('nself CLI Module', () => { ) }) }) + + describe('cli v1.5 compatibility (P7-CANON-20)', () => { + const execArgs = () => mockExecFile.mock.calls[mockExecFile.mock.calls.length - 1] + + it('passes NSELF_JSON_LEGACY=1 in the child env', async () => { + delete process.env.NSELF_JSON_LEGACY + await nselfCLI.executeNselfCommand('status') + expect(execArgs()[2].env.NSELF_JSON_LEGACY).toBe('1') + }) + + it('lets the caller override NSELF_JSON_LEGACY and keeps custom env', async () => { + await nselfCLI.executeNselfCommand('status', [], { + env: { NSELF_JSON_LEGACY: '0', CUSTOM_VAR: 'value' }, + }) + expect(execArgs()[2].env).toEqual( + expect.objectContaining({ NSELF_JSON_LEGACY: '0', CUSTOM_VAR: 'value' }) + ) + }) + + it('passes NSELF_JSON_LEGACY=1 to streamed commands', () => { + const { spawn } = require('child_process') + spawn.mockClear() + nselfCLI.streamNselfCommand('logs', [], () => {}) + expect(spawn.mock.calls[0][2].env.NSELF_JSON_LEGACY).toBe('1') + }) + + it.each([ + ['service', ['ps', 'redis'], 'config', ['services', 'ps', 'redis']], + ['secrets', ['list'], 'config', ['secrets', 'list']], + ['urls', ['--json'], 'status', ['urls', '--json']], + ['health', ['api'], 'status', ['health', 'api']], + ['dev', [], 'start', ['dev']], + ['plugin', ['list', '--json'], 'config', ['plugins', 'list', '--json']], + ['plugin', ['status', 'stripe'], 'config', ['plugins', 'status', 'stripe']], + ['plugin', ['test', 'stripe'], 'plugin-dev', ['test', 'stripe']], + ['plugin', ['install', 'stripe'], 'add', ['stripe']], + ['plugin', ['remove', 'stripe'], 'remove', ['stripe']], + ])('rewrites old spelling %s %j to the canonical argv', async (cmd, args, want, wantArgs) => { + await nselfCLI.executeNselfCommand(cmd, args) + expect(mockExecFile).toHaveBeenCalledWith( + '/usr/local/bin/nself', + [want, ...wantArgs], + expect.any(Object) + ) + }) + + it('accepts the canonical plugin commands add, remove and functions', async () => { + for (const cmd of ['add', 'remove', 'functions']) { + const result = await nselfCLI.executeNselfCommand(cmd, ['x']) + expect(result.success).toBe(true) + } + }) + + it('rewrites streamed health to status health', () => { + const { spawn } = require('child_process') + spawn.mockClear() + nselfCLI.streamNselfCommand('health', ['--all'], () => {}) + expect(spawn.mock.calls[0][1]).toEqual(['status', 'health', '--all']) + }) + + it('names no relocated root in the allowlists', () => { + const { ALLOWED_COMMANDS, STREAM_COMMANDS } = require('../nselfCanon') + const moved = ['service', 'secrets', 'urls', 'health', 'plugin', 'dev'] + for (const root of moved) { + expect(ALLOWED_COMMANDS).not.toContain(root) + expect(STREAM_COMMANDS).not.toContain(root) + } + }) + }) +}) + +describe('nself-path NSELF_BIN seam (P7-CANON-20)', () => { + const actual = jest.requireActual('../nself-path') as typeof import('../nself-path') + const saved = process.env.NSELF_BIN + + // jest.setup.js stubs fs.existsSync to true; only this file exists here. + const existsSync = require('fs').existsSync as jest.Mock + + beforeEach(() => { + existsSync.mockImplementation((p: string) => p === __filename) + jest.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + afterEach(() => { + jest.restoreAllMocks() + existsSync.mockImplementation(() => true) + if (saved === undefined) delete process.env.NSELF_BIN + else process.env.NSELF_BIN = saved + }) + + it('honours NSELF_BIN before discovery, async and sync', async () => { + process.env.NSELF_BIN = __filename + expect(await actual.findNselfPath()).toBe(__filename) + expect(actual.findNselfPathSync()).toBe(__filename) + }) + + it('re-reads NSELF_BIN on every sync call (not cached)', () => { + process.env.NSELF_BIN = __filename + expect(actual.findNselfPathSync()).toBe(__filename) + process.env.NSELF_BIN = `${__filename}.missing` + expect(actual.findNselfPathSync()).not.toBe(`${__filename}.missing`) + }) + + it('ignores an NSELF_BIN that does not exist, with a warning', () => { + const warn = jest.spyOn(console, 'warn') + process.env.NSELF_BIN = `${__filename}.missing` + expect(actual.getNselfBinOverride()).toBeNull() + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('NSELF_BIN is set but does not exist') + ) + }) + + it('getNselfChildEnv sets NSELF_JSON_LEGACY=1 and lets extras win', () => { + delete process.env.NSELF_JSON_LEGACY + expect(actual.getNselfChildEnv().NSELF_JSON_LEGACY).toBe('1') + expect(actual.getNselfChildEnv({ NSELF_JSON_LEGACY: '0' }).NSELF_JSON_LEGACY).toBe('0') + }) }) diff --git a/src/lib/health-checks.ts b/src/lib/health-checks.ts index ccccab11..6dbb54c4 100644 --- a/src/lib/health-checks.ts +++ b/src/lib/health-checks.ts @@ -7,7 +7,7 @@ import { getProjectPath } from './paths' const execAsync = promisify(exec) /** - * A single installed plugin as reported by `nself plugin list --json`. + * A single installed plugin as reported by `nself config plugins list --json`. */ export interface PluginStatus { name: string @@ -103,7 +103,7 @@ export async function getActiveEnv(): Promise<'local' | 'staging' | 'prod'> { } /** - * Raw shape returned by `nself plugin list --json`. + * Raw shape returned by `nself config plugins list --json`. * We accept an array of objects with at least name/version and optionally * enabled/tier fields to guard against CLI version differences. */ @@ -122,14 +122,14 @@ function isRawPluginEntry(value: unknown): value is RawPluginEntry { } /** - * Run `nself plugin list --json` and return normalised plugin status array. + * Run `nself config plugins list --json` and return normalised plugin status array. * Returns an empty array if the CLI is unavailable or returns unparseable output. */ export async function getPluginStatus(): Promise { try { const projectPath = getProjectPath() - const { stdout } = await execAsync('nself plugin list --json', { + const { stdout } = await execAsync('nself config plugins list --json', { env: { ...process.env, PATH: getEnhancedPath() }, cwd: projectPath, timeout: 10_000, diff --git a/src/lib/nself-path.ts b/src/lib/nself-path.ts index 761526ae..2833ba46 100644 --- a/src/lib/nself-path.ts +++ b/src/lib/nself-path.ts @@ -5,6 +5,49 @@ import { promisify } from 'util' const execAsync = promisify(exec) +/** + * NSELF_BIN: absolute path of the nself binary to run, ahead of all discovery. + * The cli compat check (scripts/cli-compat.sh) and tests point it at a build of + * cli main. A value that does not exist on disk is ignored with a warning, so a + * typo never silently runs a different binary than the one named. + */ +export function getNselfBinOverride(): string | null { + const bin = process.env.NSELF_BIN + if (!bin) return null + if (fs.existsSync(bin)) return bin + console.warn(`NSELF_BIN is set but does not exist (${bin}); falling back to discovery`) + return null +} + +/** + * Admin parses the pre-envelope JSON of cli 1.4 everywhere. From cli 1.5 the + * default `--json` shape is the v1 envelope; NSELF_JSON_LEGACY=1 keeps the bare + * shape for one minor release. Setting it once on the process reaches the + * helpers in nselfCLI.ts and every route that spawns the binary found by + * findNselfPath*, because those inherit process.env. An explicit value (for + * example NSELF_JSON_LEGACY=0 in a test of the envelope) is never overwritten. + */ +export function ensureLegacyJsonEnv(): void { + if (process.env.NSELF_JSON_LEGACY === undefined) { + process.env.NSELF_JSON_LEGACY = '1' + } +} + +/** + * Child environment for an nself invocation: process env, the enhanced PATH, + * NSELF_JSON_LEGACY=1 unless set, then the caller's extras (which win). + */ +export function getNselfChildEnv( + extra: Record = {} +): NodeJS.ProcessEnv { + return { + ...process.env, + PATH: getEnhancedPath(), + NSELF_JSON_LEGACY: process.env.NSELF_JSON_LEGACY ?? '1', + ...extra, + } +} + /** * Get the nself CLI development path from environment or derive from HOME * Supports NSELF_CLI_PATH env var for explicit override @@ -49,12 +92,21 @@ function getCommonPaths(): string[] { /** * Find the nself CLI executable * Priority order: + * 0. NSELF_BIN environment variable (test and compat-check seam, see getNselfBinOverride) * 1. NSELF_CLI_PATH environment variable (explicit override) * 2. Development location ($HOME/Sites/nself/bin/nself) * 3. Common installation paths (container + system + user) * 4. nself in PATH via which — last resort for non-standard installs */ export async function findNselfPath(): Promise { + ensureLegacyJsonEnv() + + // 0. NSELF_BIN beats every discovery step + const binOverride = getNselfBinOverride() + if (binOverride) { + return binOverride + } + // 1. Check explicit environment variable first if (process.env.NSELF_CLI_PATH && fs.existsSync(process.env.NSELF_CLI_PATH)) { return process.env.NSELF_CLI_PATH @@ -106,6 +158,7 @@ let _nselfPathSyncCache: string | null = null /** * Synchronous version for use in API routes. * Priority order (fast static checks first, slow shell lookup last): + * 0. NSELF_BIN environment variable (checked before the cache, so it can change between calls) * 1. NSELF_CLI_PATH environment variable (explicit override) * 2. Development location ($HOME/Sites/nself/bin/nself) * 3. Common installation paths (container + system + user) @@ -115,6 +168,11 @@ let _nselfPathSyncCache: string | null = null * blocking execSync entirely, preventing event-loop stalls during E2E tests. */ export function findNselfPathSync(): string { + ensureLegacyJsonEnv() + + const binOverride = getNselfBinOverride() + if (binOverride) return binOverride + if (_nselfPathSyncCache !== null) return _nselfPathSyncCache // 1. Check explicit environment variable first diff --git a/src/lib/nself-service.ts b/src/lib/nself-service.ts index dbead108..cdfae0c2 100644 --- a/src/lib/nself-service.ts +++ b/src/lib/nself-service.ts @@ -20,7 +20,7 @@ export async function execNselfService(serviceCommand: string): Promise = {} ): Promise { try { + // Old spellings of moved commands become canonical before validation + const argv = canonicalizeArgv(command, args) + // Validate command - const allowedCommands = [ - // Core lifecycle - 'init', - 'build', - 'start', - 'stop', - 'restart', - 'reset', - 'status', - 'logs', - 'doctor', - // Data management - 'backup', - 'restore', - 'db', - // Configuration & environment - 'config', - 'secrets', - 'ssl', - 'apply', - 'export', - // Deployment & infrastructure - 'deploy', - 'scale', - 'infra', - // Monitoring & health - 'monitor', - 'health', - 'metrics', - 'perf', - // Service & container management - 'service', - 'exec', - // Multi-tenancy & auth - 'tenant', - 'auth', - // Developer tools - 'dev', - 'plugin', - 'bench', - 'completion', - // Audit & history - 'history', - 'audit', - // User management - 'user', - // Info & utility - 'update', - 'version', - 'help', - 'urls', - ] - - if (!allowedCommands.includes(command)) { + if (!ALLOWED_COMMANDS.includes(argv.command)) { throw new Error(`Invalid nself command: ${command}`) } // Build arguments safely - const cmdArgs = [command, ...args.filter((arg) => arg && typeof arg === 'string')] + const cmdArgs = [argv.command, ...argv.args.filter((arg) => arg && typeof arg === 'string')] // Execute with timeout using execFile for safety const timeout = options.timeout || 60000 // Increased default timeout @@ -102,7 +53,7 @@ export async function executeNselfCommand( timeout, maxBuffer: 10 * 1024 * 1024, // 10MB cwd, // Run in project directory - env: { ...process.env, PATH: getEnhancedPath(), ...options.env }, + env: getNselfChildEnv(options.env), // NSELF_JSON_LEGACY=1 unless the caller sets it }) return { @@ -138,18 +89,18 @@ export function streamNselfCommand( onError?: (error: string) => void, onClose?: (code: number) => void ): () => void { - // Validate command - const allowedCommands = ['logs', 'monitor', 'watch', 'tail', 'health', 'metrics'] + const argv = canonicalizeArgv(command, args) - if (!allowedCommands.includes(command)) { + // Validate command + if (!STREAM_COMMANDS.includes(argv.command)) { throw new Error(`Invalid streaming command: ${command}`) } const cwd = getWorkingDirectory() const nselfPath = getNselfCliPath() - const child = spawn(nselfPath, [command, ...args], { + const child = spawn(nselfPath, [argv.command, ...argv.args], { cwd, - env: { ...process.env, PATH: getEnhancedPath() }, + env: getNselfChildEnv(), }) child.stdout.on('data', (data) => { @@ -308,7 +259,7 @@ export async function nselfMonitor(action?: 'enable' | 'disable' | 'status'): Pr export async function nselfUrls(format?: 'json' | 'table'): Promise { const args = format ? ['--format', format] : [] - return executeNselfCommand('urls', args) + return executeNselfCommand('status', ['urls', ...args]) } export async function nselfApply(configPath?: string): Promise { @@ -322,7 +273,7 @@ export async function nselfSecrets( ): Promise { const args: string[] = [action] if (service) args.push(service) - return executeNselfCommand('secrets', args) + return executeNselfCommand('config', ['secrets', ...args]) } export async function nselfExport( @@ -340,7 +291,7 @@ export async function nselfScale(service: string, replicas: number): Promise { const args = service ? [service] : ['--all'] - return executeNselfCommand('health', args) + return executeNselfCommand('status', ['health', ...args]) } // Deployment commands diff --git a/src/lib/nselfCanon.ts b/src/lib/nselfCanon.ts new file mode 100644 index 00000000..60113661 --- /dev/null +++ b/src/lib/nselfCanon.ts @@ -0,0 +1,111 @@ +/** + * Canonical nself command spellings for the admin exec helpers. + * + * cli v1.5 moves several commands (ADR 0016, `Command-Canon.md` in the cli wiki) + * and keeps the old spelling as a shim that prints a deprecation warning until + * v1.6.0. Admin ships in lockstep with the cli (P7-SHIP-06), so every argv it + * builds uses the canonical spelling. Callers that still build an old root + * dynamically (workflow steps, `nself service ...` text) are rewritten here + * before the exec, so no shim warning reaches a response. + * + * Only roots that the helpers accepted before v1.5 appear in CANON_MOVES; + * scripts/cli-compat.sh proves each target exists and is not itself a shim. + * + * Reusable by: nselfCLI.ts (executeNselfCommand, streamNselfCommand). + */ + +export interface NselfArgv { + command: string + args: string[] +} + +/** Commands executeNselfCommand accepts, canonical spellings only. */ +export const ALLOWED_COMMANDS: readonly string[] = [ + // Core lifecycle + 'init', + 'build', + 'start', + 'stop', + 'restart', + 'reset', + 'status', + 'logs', + 'doctor', + // Data management + 'backup', + 'restore', + 'db', + // Configuration & environment (config also carries secrets, env, services, plugins) + 'config', + 'ssl', + 'apply', + 'export', + // Deployment & infrastructure + 'deploy', + 'scale', + 'infra', + // Monitoring & health + 'monitor', + 'metrics', + 'perf', + // Service & container management + 'exec', + // Plugins: add/remove are the canonical install/uninstall; functions and plugin-dev are plugins + 'add', + 'remove', + 'functions', + 'plugin-dev', + // Multi-tenancy & auth + 'tenant', + 'auth', + // Developer tools + 'bench', + 'completion', + // Audit & history + 'history', + 'audit', + // User management + 'user', + // Info & utility + 'update', + 'version', + 'help', +] + +/** Commands streamNselfCommand accepts (`status` carries `status health`). */ +export const STREAM_COMMANDS: readonly string[] = [ + 'logs', + 'monitor', + 'watch', + 'tail', + 'status', + 'metrics', +] + +const PLUGIN_DEV_SUBCOMMANDS = ['debug', 'dev', 'init', 'link', 'new', 'test', 'unlink'] + +/** Old root -> rewrite of that root's argv, from the cli canon table. */ +const CANON_MOVES: Record NselfArgv> = { + service: (args) => ({ command: 'config', args: ['services', ...args] }), + secrets: (args) => ({ command: 'config', args: ['secrets', ...args] }), + dev: (args) => ({ command: 'start', args: ['dev', ...args] }), + urls: (args) => ({ command: 'status', args: ['urls', ...args] }), + health: (args) => ({ command: 'status', args: ['health', ...args] }), + plugin: (args) => { + // `plugin test|debug|dev|init|link|new|unlink` are breakouts to the plugin-dev plugin. + if (args[0] !== undefined && PLUGIN_DEV_SUBCOMMANDS.includes(args[0])) + return { command: 'plugin-dev', args } + // `plugin install|remove` are shims for the top-level add and remove. + if (args[0] === 'install') return { command: 'add', args: args.slice(1) } + if (args[0] === 'remove') return { command: 'remove', args: args.slice(1) } + return { command: 'config', args: ['plugins', ...args] } + }, +} + +/** Rewrite an old-spelling argv to its canonical form; canonical argv pass through. */ +export function canonicalizeArgv(command: string, args: string[] = []): NselfArgv { + const move = Object.prototype.hasOwnProperty.call(CANON_MOVES, command) + ? CANON_MOVES[command] + : undefined + return move ? move(args) : { command, args } +} From 86f8d3f7b4487eb8a93d38c21a022e8bfebcc804 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 18 Oct 2026 09:28:30 -0400 Subject: [PATCH 2/5] fix(cli): canonicalize dynamic nself routes, set NSELF_JSON_LEGACY in the image, harden the compat check (P7-CANON-20) - cli/execute, commands/run, nself and ws/terminal rewrite old roots with canonicalizeArgs; env, trust, server and migrate join the rewrite table - Dockerfile sets NSELF_JSON_LEGACY=1 (standalone server.js skips next.config.mjs) - cli-compat.sh fails on no source files, no shim data, and unguarded dynamic routes; dynamic exec sites are listed in the inventory --- .github/wiki/CLI_INTEGRATION.md | 14 ++++---- Dockerfile | 4 +++ next.config.mjs | 6 ++-- scripts/cli-argv-inventory.txt | 55 ++++++++++++++++++++++++++++++ scripts/cli-compat.sh | 52 ++++++++++++++++++++++------ src/app/api/cli/execute/route.ts | 3 +- src/app/api/commands/run/route.ts | 3 +- src/app/api/nself/route.ts | 5 +-- src/app/api/ws/terminal/route.ts | 3 +- src/lib/__tests__/nselfCLI.test.ts | 37 +++++++++++++++++++- src/lib/nselfCanon.ts | 38 +++++++++++++++++++-- 11 files changed, 192 insertions(+), 28 deletions(-) diff --git a/.github/wiki/CLI_INTEGRATION.md b/.github/wiki/CLI_INTEGRATION.md index acf1e86c..dec54250 100644 --- a/.github/wiki/CLI_INTEGRATION.md +++ b/.github/wiki/CLI_INTEGRATION.md @@ -296,13 +296,13 @@ npm test Admin ships in lockstep with the cli (release train P7-SHIP-06): **admin 1.5.x needs cli 1.5.x**. Admin 1.5 builds its argv with the canonical spellings of the v1.5 command canon (cli wiki: `Command-Canon.md`), which a cli 1.4 does not know. -| What admin does | Where | -|---|---| -| Every child process gets `NSELF_JSON_LEGACY=1` (the caller can override it), so the JSON admin parses keeps the bare cli 1.4 shape instead of the v1 envelope. | `getNselfChildEnv` and `ensureLegacyJsonEnv` in `src/lib/nself-path.ts`; `next.config.mjs` sets the process default | -| `NSELF_BIN` names the binary to run, ahead of `NSELF_CLI_PATH` and discovery. A path that does not exist is ignored with a warning. | `getNselfBinOverride` in `src/lib/nself-path.ts` | -| Relocated commands use the canonical spelling: `service` is `config services`, `secrets` is `config secrets`, `env` is `config env`, `urls` is `status urls`, `health` is `status health`, `trust` is `config trust`, `plugin install\|remove` is `add\|remove`, other `plugin` commands are `config plugins`, `plugin test` is `plugin-dev test`. | call sites under `src/` | -| `executeNselfCommand` and `streamNselfCommand` rewrite an old root passed by a dynamic caller (a workflow step, a typed command) to the canonical argv before the exec. | `src/lib/nselfCanon.ts` | -| `functions` and `plugin-dev` are plugin commands; the cli installs them on first use (auto-add). | allowlist in `src/lib/nselfCanon.ts` | +| What admin does | Where | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Every child process gets `NSELF_JSON_LEGACY=1` (the caller can override it), so the JSON admin parses keeps the bare cli 1.4 shape instead of the v1 envelope. | `getNselfChildEnv` and `ensureLegacyJsonEnv` in `src/lib/nself-path.ts`; the process default comes from `next.config.mjs` (`next dev`, `next start`) and `ENV NSELF_JSON_LEGACY=1` in the Dockerfile (the standalone `server.js` does not evaluate `next.config.mjs`) | +| `NSELF_BIN` names the binary to run, ahead of `NSELF_CLI_PATH` and discovery, for code that resolves the binary through `src/lib/nself-path.ts`. A path that does not exist is ignored with a warning. Routes that spawn a bare `nself` use `PATH` (tracked as debt). | `getNselfBinOverride` in `src/lib/nself-path.ts` | +| Relocated commands use the canonical spelling: `service` is `config services`, `secrets` is `config secrets`, `env` is `config env`, `urls` is `status urls`, `health` is `status health`, `trust` is `config trust`, `plugin install\|remove` is `add\|remove`, other `plugin` commands are `config plugins`, `plugin test` is `plugin-dev test`. | call sites under `src/` | +| `executeNselfCommand`, `streamNselfCommand` and the routes that run a user-chosen command (`cli/execute`, `commands/run`, `nself`, `ws/terminal`) rewrite an old root to the canonical argv before the exec (`canonicalizeArgv`, `canonicalizeArgs`). The check script requires the four routes to call it. | `src/lib/nselfCanon.ts` | +| `functions` and `plugin-dev` are plugin commands; the cli installs them on first use (auto-add). | allowlist in `src/lib/nselfCanon.ts` | ### The compat check diff --git a/Dockerfile b/Dockerfile index d36927b3..ffa4b1c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -194,6 +194,10 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Expose port 3021 (reserved for nself-admin, distinct from Loki on 3100) EXPOSE 3021 +# cli 1.5 prints the v1 JSON envelope by default; admin parses the bare cli 1.4 shape. +# The standalone server.js does not evaluate next.config.mjs, so set it here (P7-CANON-20). +ENV NSELF_JSON_LEGACY=1 + # Use STOPSIGNAL for proper graceful shutdown STOPSIGNAL SIGTERM diff --git a/next.config.mjs b/next.config.mjs index 843e7653..bdb71046 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -2,8 +2,10 @@ import withBundleAnalyzer from '@next/bundle-analyzer' import createNextIntlPlugin from 'next-intl/plugin' // cli 1.5 prints the v1 JSON envelope by default; admin parses the bare cli 1.4 -// shape. Set once here so every route that runs `nself` inherits it, including -// the ones that spawn a bare `nself` and never call findNselfPath (P7-CANON-20). +// shape. Set once here so `next dev` and `next start` give every route that runs +// `nself` the legacy shape, including the ones that spawn a bare `nself` and never +// call findNselfPath. The Docker image sets the same variable in the Dockerfile, +// because its standalone server.js does not evaluate this file (P7-CANON-20). // An explicit NSELF_JSON_LEGACY in the environment wins. process.env.NSELF_JSON_LEGACY ??= '1' diff --git a/scripts/cli-argv-inventory.txt b/scripts/cli-argv-inventory.txt index d41b0cdd..494bb5ff 100644 --- a/scripts/cli-argv-inventory.txt +++ b/scripts/cli-argv-inventory.txt @@ -1,3 +1,58 @@ +@dynamic src/app/api/auth/roles/route.ts +@dynamic src/app/api/commands/registry/route.ts +@dynamic src/app/api/commands/run/route.ts +@dynamic src/app/api/control-plane/route.ts +@dynamic src/app/api/deploy/blue-green/route.ts +@dynamic src/app/api/deploy/canary/route.ts +@dynamic src/app/api/deploy/preview/route.ts +@dynamic src/app/api/deploy/production/route.ts +@dynamic src/app/api/docker/exec/route.ts +@dynamic src/app/api/frontend/[name]/deploy/route.ts +@dynamic src/app/api/frontend/[name]/route.ts +@dynamic src/app/api/frontend/route.ts +@dynamic src/app/api/health/route.ts +@dynamic src/app/api/helm/generate/route.ts +@dynamic src/app/api/helm/init/route.ts +@dynamic src/app/api/helm/repos/route.ts +@dynamic src/app/api/helm/rollback/route.ts +@dynamic src/app/api/helm/route.ts +@dynamic src/app/api/helm/status/route.ts +@dynamic src/app/api/helm/uninstall/route.ts +@dynamic src/app/api/helm/upgrade/route.ts +@dynamic src/app/api/helm/values/route.ts +@dynamic src/app/api/history/deployments/route.ts +@dynamic src/app/api/history/migrations/route.ts +@dynamic src/app/api/history/route.ts +@dynamic src/app/api/k8s/apply/route.ts +@dynamic src/app/api/k8s/clusters/connect/route.ts +@dynamic src/app/api/k8s/convert/route.ts +@dynamic src/app/api/k8s/deploy/route.ts +@dynamic src/app/api/k8s/deployments/route.ts +@dynamic src/app/api/k8s/ingresses/route.ts +@dynamic src/app/api/k8s/init/route.ts +@dynamic src/app/api/k8s/namespaces/route.ts +@dynamic src/app/api/k8s/pods/route.ts +@dynamic src/app/api/k8s/rollback/route.ts +@dynamic src/app/api/k8s/scale/route.ts +@dynamic src/app/api/k8s/services/route.ts +@dynamic src/app/api/monitor/alerts/route.ts +@dynamic src/app/api/monitor/alerts/rules/route.ts +@dynamic src/app/api/monitor/alerts/silence/route.ts +@dynamic src/app/api/monitor/metrics/route.ts +@dynamic src/app/api/nself/help/route.ts +@dynamic src/app/api/nself/route.ts +@dynamic src/app/api/nself/updates/route.ts +@dynamic src/app/api/nself/version/route.ts +@dynamic src/app/api/scale/auto/route.ts +@dynamic src/app/api/sync/config/route.ts +@dynamic src/app/api/sync/db/route.ts +@dynamic src/app/api/sync/files/route.ts +@dynamic src/app/api/sync/history/route.ts +@dynamic src/app/api/sync/route.ts +@dynamic src/app/api/ws/terminal/route.ts +@dynamic src/features/license/license.ts +@dynamic src/lib/nselfCLI.ts +@dynamic src/lib/workflows/index.ts add apply audit diff --git a/scripts/cli-compat.sh b/scripts/cli-compat.sh index edb70e5a..5e353ff7 100755 --- a/scripts/cli-compat.sh +++ b/scripts/cli-compat.sh @@ -12,6 +12,11 @@ # runCLI(['a', 'b', ...]) # A variable argument (execFile(bin, args)) is resolved to the literal # array it was declared with, when there is one. +# A call whose argv cannot be read statically (a variable that is not a +# literal array, a dynamic command) is listed as `@dynamic ` in the +# inventory, so a new dynamic exec site shows up in the committed diff. +# The routes that run a user-chosen command must call canonicalizeArgs +# (checked below). # 2. Fails when a prefix starts with an old spelling, i.e. a command that cli # v1.5 keeps only as a deprecated shim until v1.6.0 (read from # `NSELF_V15=1 nself help --json`, canon == deprecated-shim). @@ -56,8 +61,8 @@ nself_run() { # ── 1. inventory ───────────────────────────────────────────────────────────── -extract() { - perl -0777 -e ' +read -r -d '' PERL_PROG <<'PERL_EOF' || true + use strict; use warnings; my %seen; sub words { # consume string-literal words from an array body at pos() @@ -85,6 +90,7 @@ extract() { return @w; }; # executeNselfCommand / streamNselfCommand + while ($t =~ /(?($1, pos($t)); } + $seen{"\@dynamic $file"} = 1 unless @w; emit(@w); } # runCLI([...]) wrappers that prepend the nself binary @@ -107,29 +114,40 @@ extract() { for my $p (@starts) { pos($t) = $p; my @w; while ($t =~ /\G([a-z][a-z0-9-]*)(?=[\s`\x27"]|\$|$)\s*/gc) { push @w, $1; } + $seen{"\@dynamic $file"} = 1 unless @w; emit(@w); } } print "$_\n" for sort keys %seen; - ' "$@" -} + +PERL_EOF FILES="$WORK/files.txt" -(cd "$ROOT" && find src -type f \( -name '*.ts' -o -name '*.tsx' \) \ +(cd "$ROOT" && find -L src -type f \( -name '*.ts' -o -name '*.tsx' \) \ ! -path '*/__tests__/*' ! -name '*.test.*' ! -name '*.spec.*' | sort) >"$FILES" -(cd "$ROOT" && extract $(cat "$FILES")) | sort -u >"$INVENTORY" +tr '\n' '\0' <"$FILES" | (cd "$ROOT" && xargs -0 perl -0777 -e "$PERL_PROG") | sort -u >"$INVENTORY" FILE_COUNT="$(wc -l <"$FILES" | tr -d ' ')" -LINES="$(wc -l <"$INVENTORY" | tr -d ' ')" +if [ "$FILE_COUNT" -eq 0 ] || [ ! -s "$INVENTORY" ]; then + echo "cli-compat: FAIL: no source files or no argv prefixes found (is $ROOT/src missing or empty?)" >&2 + exit 1 +fi +LINES="$(grep -vc '^@dynamic ' "$INVENTORY" || true)" echo "cli-compat [$MODE] $BIN: read $FILE_COUNT source files, $LINES argv prefixes -> scripts/cli-argv-inventory.txt" # ── 2. cli command tables ──────────────────────────────────────────────────── nself_run help --json >"$WORK/help.json" 2>/dev/null || { echo "cli-compat: nself help --json failed" >&2; exit 1; } jq -r '.data.commands[].path | sub("^nself ";"")' "$WORK/help.json" | sort -u >"$WORK/paths.txt" -NSELF_V15=1 nself_run help --json 2>/dev/null \ - | jq -r '.data.commands[] | select(.canon == "deprecated-shim") | [(.path | sub("^nself ";"")), ((.target // "") | sub("^nself ";""))] | @tsv' \ - | sort -u >"$WORK/shims.tsv" || true -[ -s "$WORK/shims.tsv" ] || echo "cli-compat: note: this cli lists no deprecated shims (pre-canon build); old-spelling check is empty" +if ! NSELF_V15=1 nself_run help --json >"$WORK/help15.json" 2>/dev/null; then + echo "cli-compat: FAIL: NSELF_V15=1 nself help --json failed, cannot read the v1.5 shims" >&2 + exit 1 +fi +jq -r '.data.commands[] | select(.canon == "deprecated-shim") | [(.path | sub("^nself ";"")), ((.target // "") | sub("^nself ";""))] | @tsv' "$WORK/help15.json" \ + | sort -u >"$WORK/shims.tsv" +if [ ! -s "$WORK/shims.tsv" ]; then + echo "cli-compat: FAIL: this cli lists no deprecated shims in v1.5 mode (no canon data), the old-spelling check would prove nothing" >&2 + exit 1 +fi # ── 3. checks ──────────────────────────────────────────────────────────────── @@ -138,6 +156,7 @@ CHECKED=0 : >"$WORK/unknown.txt" while IFS= read -r line; do [ -n "$line" ] || continue + case "$line" in "@dynamic "*) continue ;; esac # old spelling: the prefix starts with a path that is only a shim in v1.5 while IFS="$(printf '\t')" read -r old target; do @@ -176,7 +195,18 @@ while IFS= read -r line; do fi done <"$INVENTORY" +# Routes that run a user-chosen command must make its root canonical. +DYNAMIC_COUNT="$(grep -c '^@dynamic ' "$INVENTORY" || true)" +for guarded in src/app/api/cli/execute/route.ts src/app/api/commands/run/route.ts \ + src/app/api/nself/route.ts src/app/api/ws/terminal/route.ts; do + if ! grep -qE 'canonicalizeArgs|canonicalizeArgv' "$ROOT/$guarded"; then + echo "FAIL dynamic argv: $guarded runs a user-chosen command but does not call canonicalizeArgs" + FAIL=1 + fi +done + UNKNOWN_COUNT="$(sort -u "$WORK/unknown.txt" | wc -l | tr -d ' ')" +echo "cli-compat [$MODE]: $DYNAMIC_COUNT files exec the cli with an argv not readable statically (@dynamic in the inventory)" echo "cli-compat [$MODE]: $CHECKED commands run with --help, $UNKNOWN_COUNT prefixes not (fully) in this cli (UNKNOWN, pre-existing drift)" sort -u "$WORK/unknown.txt" | sed 's/^/UNKNOWN nself /' diff --git a/src/app/api/cli/execute/route.ts b/src/app/api/cli/execute/route.ts index c0a916f2..bfc63b91 100644 --- a/src/app/api/cli/execute/route.ts +++ b/src/app/api/cli/execute/route.ts @@ -1,4 +1,5 @@ import { getEnhancedPath } from '@/lib/nself-path' +import { canonicalizeArgs } from '@/lib/nselfCanon' import { getProjectPath } from '@/lib/paths' import { requireAuth } from '@/lib/require-auth' import { execFile } from 'child_process' @@ -228,7 +229,7 @@ export async function POST(request: NextRequest): Promise { // Pass command and args separately, use cwd option instead of cd const { stdout, stderr } = await execFileAsync( cmdArgs[0] ?? 'nself', // 'nself' - cmdArgs.slice(1), // remaining args + canonicalizeArgs(cmdArgs.slice(1)), // remaining args, old roots made canonical { cwd: backendPath, env: { diff --git a/src/app/api/commands/run/route.ts b/src/app/api/commands/run/route.ts index 0ec4c9d1..ca5e6cb9 100644 --- a/src/app/api/commands/run/route.ts +++ b/src/app/api/commands/run/route.ts @@ -1,4 +1,5 @@ import { findNselfPathSync, getEnhancedPath } from '@/lib/nself-path' +import { canonicalizeArgs } from '@/lib/nselfCanon' import { getProjectPath } from '@/lib/paths' import { requireAuth } from '@/lib/require-auth' import { execFile } from 'child_process' @@ -185,7 +186,7 @@ export async function POST(request: NextRequest): Promise { let result: RunCommandResult try { - const { stdout, stderr } = await execFileAsync(nselfBin, args, { + const { stdout, stderr } = await execFileAsync(nselfBin, canonicalizeArgs(args), { cwd, env, timeout: timeoutMs, diff --git a/src/app/api/nself/route.ts b/src/app/api/nself/route.ts index db798a8d..93f8516f 100644 --- a/src/app/api/nself/route.ts +++ b/src/app/api/nself/route.ts @@ -1,4 +1,5 @@ import { getEnhancedPath } from '@/lib/nself-path' +import { canonicalizeArgs } from '@/lib/nselfCanon' import { getProjectPath } from '@/lib/paths' import { requireAuth } from '@/lib/require-auth' import { execFile } from 'child_process' @@ -109,7 +110,7 @@ export async function POST(request: NextRequest): Promise { const projectPath = getProjectPath() const execArgs = [command, ...validatedArgs] - const { stdout, stderr } = await execFileAsync('nself', execArgs, { + const { stdout, stderr } = await execFileAsync('nself', canonicalizeArgs(execArgs), { cwd: projectPath, env: { ...process.env, @@ -165,7 +166,7 @@ export async function GET(request: NextRequest): Promise { try { const projectPath = getProjectPath() - const { stdout, stderr } = await execFileAsync('nself', [action], { + const { stdout, stderr } = await execFileAsync('nself', canonicalizeArgs([action]), { cwd: projectPath, env: { ...process.env, diff --git a/src/app/api/ws/terminal/route.ts b/src/app/api/ws/terminal/route.ts index b202c7a8..2ad6a8ca 100644 --- a/src/app/api/ws/terminal/route.ts +++ b/src/app/api/ws/terminal/route.ts @@ -6,6 +6,7 @@ */ import { findNselfPath, getEnhancedPath } from '@/lib/nself-path' +import { canonicalizeArgs } from '@/lib/nselfCanon' import { getProjectPath } from '@/lib/paths' import { requireAuth } from '@/lib/require-auth' import { spawn } from 'child_process' @@ -137,7 +138,7 @@ export async function POST(request: NextRequest): Promise { ['plugin', ['list', '--json'], 'config', ['plugins', 'list', '--json']], ['plugin', ['status', 'stripe'], 'config', ['plugins', 'status', 'stripe']], ['plugin', ['test', 'stripe'], 'plugin-dev', ['test', 'stripe']], + ['env', ['list'], 'config', ['env', 'list']], + ['trust', ['status'], 'config', ['trust', 'status']], + ['server', ['list'], 'infra', ['server', 'list']], + ['migrate', [], 'db', []], + ['migrate', ['run'], 'update', ['project', 'run']], + ['migrate', ['firebase', '--dry-run'], 'db', ['import', 'firebase', '--dry-run']], + ['migrate', ['watch'], 'db', ['migrate', 'watch']], ['plugin', ['install', 'stripe'], 'add', ['stripe']], ['plugin', ['remove', 'stripe'], 'remove', ['stripe']], ])('rewrites old spelling %s %j to the canonical argv', async (cmd, args, want, wantArgs) => { @@ -868,9 +875,37 @@ describe('nself CLI Module', () => { expect(spawn.mock.calls[0][1]).toEqual(['status', 'health', '--all']) }) + it('canonicalizeArgs rewrites a whole argv and leaves canonical argv alone', () => { + const { canonicalizeArgs } = require('../nselfCanon') + expect(canonicalizeArgs(['urls', '--json'])).toEqual(['status', 'urls', '--json']) + expect(canonicalizeArgs(['config', 'env', 'list'])).toEqual(['config', 'env', 'list']) + expect(canonicalizeArgs(['deploy', 'staging'])).toEqual(['deploy', 'staging']) + expect(canonicalizeArgs([])).toEqual([]) + }) + + it('routes that run a user-chosen command canonicalize its root', () => { + const fs = jest.requireActual('fs') as typeof import('fs') + const path = jest.requireActual('path') as typeof import('path') + for (const route of ['cli/execute', 'commands/run', 'nself', 'ws/terminal']) { + const file = path.join(__dirname, '../../app/api', route, 'route.ts') + expect(fs.readFileSync(file, 'utf8')).toContain('canonicalizeArgs(') + } + }) + it('names no relocated root in the allowlists', () => { const { ALLOWED_COMMANDS, STREAM_COMMANDS } = require('../nselfCanon') - const moved = ['service', 'secrets', 'urls', 'health', 'plugin', 'dev'] + const moved = [ + 'service', + 'secrets', + 'urls', + 'health', + 'plugin', + 'dev', + 'env', + 'trust', + 'server', + 'migrate', + ] for (const root of moved) { expect(ALLOWED_COMMANDS).not.toContain(root) expect(STREAM_COMMANDS).not.toContain(root) diff --git a/src/lib/nselfCanon.ts b/src/lib/nselfCanon.ts index 60113661..0ba4b828 100644 --- a/src/lib/nselfCanon.ts +++ b/src/lib/nselfCanon.ts @@ -8,8 +8,10 @@ * dynamically (workflow steps, `nself service ...` text) are rewritten here * before the exec, so no shim warning reaches a response. * - * Only roots that the helpers accepted before v1.5 appear in CANON_MOVES; - * scripts/cli-compat.sh proves each target exists and is not itself a shim. + * CANON_MOVES holds the roots admin builds or passes through (the helpers and + * the routes that run a user-chosen command: cli/execute, commands/run, + * nself, ws/terminal). scripts/cli-compat.sh proves the canonical targets exist + * and are not shims, and that those routes call canonicalizeArgs. * * Reusable by: nselfCLI.ts (executeNselfCommand, streamNselfCommand). */ @@ -82,6 +84,18 @@ export const STREAM_COMMANDS: readonly string[] = [ 'metrics', ] +/** `migrate ` was retired into db and update project (Command-Canon.md). */ +const MIGRATE_UPDATE_PROJECT = ['detect', 'from-bash', 'from-v099', 'rollback', 'run'] +const MIGRATE_DB_IMPORT = [ + 'appwrite', + 'firebase', + 'hasura-cloud', + 'nhost', + 'nself', + 'postgres', + 'supabase', +] + const PLUGIN_DEV_SUBCOMMANDS = ['debug', 'dev', 'init', 'link', 'new', 'test', 'unlink'] /** Old root -> rewrite of that root's argv, from the cli canon table. */ @@ -89,6 +103,18 @@ const CANON_MOVES: Record NselfArgv> = { service: (args) => ({ command: 'config', args: ['services', ...args] }), secrets: (args) => ({ command: 'config', args: ['secrets', ...args] }), dev: (args) => ({ command: 'start', args: ['dev', ...args] }), + env: (args) => ({ command: 'config', args: ['env', ...args] }), + trust: (args) => ({ command: 'config', args: ['trust', ...args] }), + server: (args) => ({ command: 'infra', args: ['server', ...args] }), + migrate: (args) => { + const sub = args[0] + if (sub !== undefined && MIGRATE_UPDATE_PROJECT.includes(sub)) + return { command: 'update', args: ['project', ...args] } + if (sub !== undefined && MIGRATE_DB_IMPORT.includes(sub)) + return { command: 'db', args: ['import', ...args] } + if (sub === 'generate' || sub === 'watch') return { command: 'db', args: ['migrate', ...args] } + return { command: 'db', args } + }, urls: (args) => ({ command: 'status', args: ['urls', ...args] }), health: (args) => ({ command: 'status', args: ['health', ...args] }), plugin: (args) => { @@ -109,3 +135,11 @@ export function canonicalizeArgv(command: string, args: string[] = []): NselfArg : undefined return move ? move(args) : { command, args } } + +/** canonicalizeArgv for a whole argv ([command, ...args]); an empty argv is returned as is. */ +export function canonicalizeArgs(argv: string[]): string[] { + const [command, ...args] = argv + if (command === undefined) return argv + const out = canonicalizeArgv(command, args) + return [out.command, ...out.args] +} From ecbf6ff83e2009f6d9fb8d27dafca037072fc931 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 18 Oct 2026 09:34:09 -0400 Subject: [PATCH 3/5] fix(cli): compat check reads execFile() sites (P7-CANON-20) --- scripts/cli-argv-inventory.txt | 1 + scripts/cli-compat.sh | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/cli-argv-inventory.txt b/scripts/cli-argv-inventory.txt index 494bb5ff..5943f2a4 100644 --- a/scripts/cli-argv-inventory.txt +++ b/scripts/cli-argv-inventory.txt @@ -1,4 +1,5 @@ @dynamic src/app/api/auth/roles/route.ts +@dynamic src/app/api/cli/execute/route.ts @dynamic src/app/api/commands/registry/route.ts @dynamic src/app/api/commands/run/route.ts @dynamic src/app/api/control-plane/route.ts diff --git a/scripts/cli-compat.sh b/scripts/cli-compat.sh index 5e353ff7..d1f8212c 100755 --- a/scripts/cli-compat.sh +++ b/scripts/cli-compat.sh @@ -7,7 +7,7 @@ # 1. Extracts every nself argv prefix admin builds from src/ (non-test files) # into scripts/cli-argv-inventory.txt, sorted, at most three words each: # executeNselfCommand('', ['a', 'b', ...]) and streamNselfCommand -# execFile/spawn(, ['a', 'b', ...]) +# execFile/spawn(, ['a', 'b', ...]) # `${nselfPath} a b ...` and exec('nself a b ...') strings # runCLI(['a', 'b', ...]) # A variable argument (execFile(bin, args)) is resolved to the literal @@ -98,7 +98,7 @@ read -r -d '' PERL_PROG <<'PERL_EOF' || true emit($cmd, @w); } # execFile / spawn of the nself binary - while ($t =~ /\b(?:execFileAsync|execFileSync|execFile|spawnSync|spawn)\(\s*(?:nselfPath|nselfCommand|nselfBin|nselfBinary|\x27nself\x27|"nself")\s*,\s*/g) { + while ($t =~ /\b(?:execFileAsync|execFileSync|execFile|spawnSync|spawn)\(\s*[^,()]*nself[^,()]*,\s*/g) { my @w; if ($t =~ /\G\[/gc) { @w = words($t); } elsif ($t =~ /\G(\w+)\s*[,)]/gc) { @w = $lookup->($1, pos($t)); } From 815cb8727a679f10124cf01e56792a37b70da267 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 18 Oct 2026 09:47:47 -0400 Subject: [PATCH 4/5] fix(cli): migrate rewrite only for canon rows, plugin-command and v1.5-only reports in the compat check (P7-CANON-20) --- scripts/cli-compat.sh | 29 ++++++++++++++++++++++++++++- src/lib/__tests__/nselfCLI.test.ts | 9 +++++++-- src/lib/nselfCanon.ts | 11 +++++------ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/scripts/cli-compat.sh b/scripts/cli-compat.sh index d1f8212c..c4a9ee7b 100755 --- a/scripts/cli-compat.sh +++ b/scripts/cli-compat.sh @@ -195,11 +195,38 @@ while IFS= read -r line; do fi done <"$INVENTORY" +# Canonical spellings are a v1.5 feature: in v1.4 mode they are not commands, so +# admin 1.5.x needs cli 1.5.x. Report them (never a failure here); in v1.5 mode +# every one of them was checked above. +: >"$WORK/v15only.txt" +while IFS="$(printf '\t')" read -r _old target; do + [ -n "$target" ] || continue + if ! grep -Fxq -- "$target" "$WORK/paths.txt"; then + grep -E "^$(printf '%s' "$target" | sed 's/[][\.*^$/]/\\&/g')( |$)" "$INVENTORY" >>"$WORK/v15only.txt" || true + fi +done <"$WORK/shims.tsv" +V15ONLY_COUNT="$(sort -u "$WORK/v15only.txt" | wc -l | tr -d ' ')" +[ "$V15ONLY_COUNT" -eq 0 ] || echo "cli-compat [$MODE]: $V15ONLY_COUNT admin prefixes use v1.5 canonical spellings this cli does not have (admin 1.5.x needs cli 1.5.x)" + +# Commands that moved into plugins (functions, plugin-dev, infra): in v1.5 mode with +# auto-add off the cli must answer E404 naming `nself add ` (or run, when the +# plugin is installed), never "unknown command". +if [ -n "${NSELF_V15:-}" ]; then + for root in functions plugin-dev infra; do + grep -Eq "^$root( |$)" "$INVENTORY" || continue + out="$(nself_run $root --help 2>&1)" && rc=0 || rc=$? + if [ "$rc" -ne 0 ] && ! printf '%s' "$out" | grep -q "nself add $root"; then + echo "FAIL plugin command: 'nself $root' is neither routed to the $root plugin nor runnable" + FAIL=1 + fi + done +fi + # Routes that run a user-chosen command must make its root canonical. DYNAMIC_COUNT="$(grep -c '^@dynamic ' "$INVENTORY" || true)" for guarded in src/app/api/cli/execute/route.ts src/app/api/commands/run/route.ts \ src/app/api/nself/route.ts src/app/api/ws/terminal/route.ts; do - if ! grep -qE 'canonicalizeArgs|canonicalizeArgv' "$ROOT/$guarded"; then + if ! grep -qE 'canonicalizeArg[sv]\(' "$ROOT/$guarded"; then echo "FAIL dynamic argv: $guarded runs a user-chosen command but does not call canonicalizeArgs" FAIL=1 fi diff --git a/src/lib/__tests__/nselfCLI.test.ts b/src/lib/__tests__/nselfCLI.test.ts index 1457946e..f2cfa853 100644 --- a/src/lib/__tests__/nselfCLI.test.ts +++ b/src/lib/__tests__/nselfCLI.test.ts @@ -850,6 +850,8 @@ describe('nself CLI Module', () => { ['migrate', ['run'], 'update', ['project', 'run']], ['migrate', ['firebase', '--dry-run'], 'db', ['import', 'firebase', '--dry-run']], ['migrate', ['watch'], 'db', ['migrate', 'watch']], + ['migrate', ['drop'], 'migrate', ['drop']], + ['migrate', ['restore', 'backups/x.sql'], 'migrate', ['restore', 'backups/x.sql']], ['plugin', ['install', 'stripe'], 'add', ['stripe']], ['plugin', ['remove', 'stripe'], 'remove', ['stripe']], ])('rewrites old spelling %s %j to the canonical argv', async (cmd, args, want, wantArgs) => { @@ -940,10 +942,13 @@ describe('nself-path NSELF_BIN seam (P7-CANON-20)', () => { }) it('re-reads NSELF_BIN on every sync call (not cached)', () => { + // Prime the module cache with no override, then set one: the override must still win. + delete process.env.NSELF_BIN + const discovered = actual.findNselfPathSync() process.env.NSELF_BIN = __filename expect(actual.findNselfPathSync()).toBe(__filename) - process.env.NSELF_BIN = `${__filename}.missing` - expect(actual.findNselfPathSync()).not.toBe(`${__filename}.missing`) + delete process.env.NSELF_BIN + expect(actual.findNselfPathSync()).toBe(discovered) }) it('ignores an NSELF_BIN that does not exist, with a warning', () => { diff --git a/src/lib/nselfCanon.ts b/src/lib/nselfCanon.ts index 0ba4b828..904d9cc4 100644 --- a/src/lib/nselfCanon.ts +++ b/src/lib/nselfCanon.ts @@ -108,15 +108,14 @@ const CANON_MOVES: Record NselfArgv> = { server: (args) => ({ command: 'infra', args: ['server', ...args] }), migrate: (args) => { const sub = args[0] - if (sub !== undefined && MIGRATE_UPDATE_PROJECT.includes(sub)) + if (sub === undefined) return { command: 'db', args } + if (MIGRATE_UPDATE_PROJECT.includes(sub)) return { command: 'update', args: ['project', ...args] } - if (sub !== undefined && MIGRATE_DB_IMPORT.includes(sub)) - return { command: 'db', args: ['import', ...args] } + if (MIGRATE_DB_IMPORT.includes(sub)) return { command: 'db', args: ['import', ...args] } if (sub === 'generate' || sub === 'watch') return { command: 'db', args: ['migrate', ...args] } - return { command: 'db', args } + // Not a row of the canon table: leave it for the cli to refuse. Never widen it to `db `. + return { command: 'migrate', args } }, - urls: (args) => ({ command: 'status', args: ['urls', ...args] }), - health: (args) => ({ command: 'status', args: ['health', ...args] }), plugin: (args) => { // `plugin test|debug|dev|init|link|new|unlink` are breakouts to the plugin-dev plugin. if (args[0] !== undefined && PLUGIN_DEV_SUBCOMMANDS.includes(args[0])) From ba93f15d26310439712fbbcfab04294e82cb536d Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Sun, 18 Oct 2026 09:49:32 -0400 Subject: [PATCH 5/5] fix(cli): restore urls/health rewrites, migrate test (P7-CANON-20) --- src/lib/__tests__/nselfCLI.test.ts | 15 +++++++++++++-- src/lib/nselfCanon.ts | 2 ++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/lib/__tests__/nselfCLI.test.ts b/src/lib/__tests__/nselfCLI.test.ts index f2cfa853..060bbf92 100644 --- a/src/lib/__tests__/nselfCLI.test.ts +++ b/src/lib/__tests__/nselfCLI.test.ts @@ -850,8 +850,6 @@ describe('nself CLI Module', () => { ['migrate', ['run'], 'update', ['project', 'run']], ['migrate', ['firebase', '--dry-run'], 'db', ['import', 'firebase', '--dry-run']], ['migrate', ['watch'], 'db', ['migrate', 'watch']], - ['migrate', ['drop'], 'migrate', ['drop']], - ['migrate', ['restore', 'backups/x.sql'], 'migrate', ['restore', 'backups/x.sql']], ['plugin', ['install', 'stripe'], 'add', ['stripe']], ['plugin', ['remove', 'stripe'], 'remove', ['stripe']], ])('rewrites old spelling %s %j to the canonical argv', async (cmd, args, want, wantArgs) => { @@ -877,6 +875,19 @@ describe('nself CLI Module', () => { expect(spawn.mock.calls[0][1]).toEqual(['status', 'health', '--all']) }) + it('does not widen an unlisted migrate subcommand to db', async () => { + const { canonicalizeArgv } = require('../nselfCanon') + expect(canonicalizeArgv('migrate', ['drop'])).toEqual({ command: 'migrate', args: ['drop'] }) + expect(canonicalizeArgv('migrate', ['restore', 'x.sql'])).toEqual({ + command: 'migrate', + args: ['restore', 'x.sql'], + }) + mockExecFile.mockClear() + const result = await nselfCLI.executeNselfCommand('migrate', ['drop']) + expect(result.success).toBe(false) + expect(mockExecFile).not.toHaveBeenCalled() + }) + it('canonicalizeArgs rewrites a whole argv and leaves canonical argv alone', () => { const { canonicalizeArgs } = require('../nselfCanon') expect(canonicalizeArgs(['urls', '--json'])).toEqual(['status', 'urls', '--json']) diff --git a/src/lib/nselfCanon.ts b/src/lib/nselfCanon.ts index 904d9cc4..65a729f1 100644 --- a/src/lib/nselfCanon.ts +++ b/src/lib/nselfCanon.ts @@ -116,6 +116,8 @@ const CANON_MOVES: Record NselfArgv> = { // Not a row of the canon table: leave it for the cli to refuse. Never widen it to `db `. return { command: 'migrate', args } }, + urls: (args) => ({ command: 'status', args: ['urls', ...args] }), + health: (args) => ({ command: 'status', args: ['health', ...args] }), plugin: (args) => { // `plugin test|debug|dev|init|link|new|unlink` are breakouts to the plugin-dev plugin. if (args[0] !== undefined && PLUGIN_DEV_SUBCOMMANDS.includes(args[0]))