diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 780ef09..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,82 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What this repo is - -`nsolid-plugin` distributes NodeSource N|Solid AI skills and MCP server configs into five agent harnesses: Claude Code, Codex CLI, OpenCode, Antigravity CLI, and Pi Agent. It ships 17 skills (memory leak/spike analysis, CPU spikes, tracing, vulnerability/dependency audits, Node/package upgrades, benchmarking, SBOM, org switching) backed by three MCP servers (`nsolid-console`, `ns-benchmark`, `ncm`). - -## Commands - -```bash -pnpm build # Build all packages (pnpm -r build) -pnpm test # All tests (unit + integration), cross-platform runner -pnpm test:unit # Unit tests only -pnpm test:integration # Integration tests only -pnpm lint # Lint all packages (neostandard, eslint.config.js) -pnpm test:marketplace # Validates marketplace/plugin install artifacts (also run in CI) -``` - -Run a single test file directly (the package-level `pnpm test` wraps this same runner): - -```bash -node --experimental-test-module-mocks --import tsx/esm --test packages/core/test/unit/skills/skill-copier.test.ts -``` - -Scope `pnpm test` to one package: `node scripts/run-tests.mjs core`. - -**Do not use `node --test 'packages/*/test/**/*.test.ts'` with a shell glob** — `scripts/run-tests.mjs` exists specifically because Node's internal glob matcher silently matches zero files on Windows (exits 0 with "0 tests"). It discovers `*.test.ts` files with `node:fs` and passes explicit paths to `node --test` instead. Always go through `pnpm test` / `run-tests.mjs`, never a raw glob. - -### Asset/manifest sync checks (run before committing generated-file changes) - -```bash -pnpm --filter nsolid-plugin bundle:check # core's bundle.json copy is in sync with root -pnpm --filter nsolid-plugin bundle:sync # sync it -pnpm plugin:check # generated manifests/configs in sync + no committed package skill copies -pnpm plugin:sync # regenerate manifests/configs, remove materialized package skill copies -pnpm plugin:materialize # copy root skills into packages/pi-plugin for pack/release -pnpm plugin:root # refresh root marketplace/plugin manifests from bundle.json -pnpm plugin:root:check # fail if committed root manifests drift from bundle.json -``` - -CI (`.github/workflows/test.yml`, matrix: ubuntu/macos/windows) runs `pnpm lint`, `pnpm build`, `pnpm test`, `node scripts/test-marketplace-install.js`. The pre-commit hook (`.husky/pre-commit`) runs `pnpm lint && pnpm test`. Run `pnpm plugin:check` yourself before release — it is not in the git hook. - -## Architecture - -### Single source of truth, five distribution paths - -Skills are canonical **only** under root `skills//` (SKILL.md + helper scripts). `bundle.json` at the repo root is the canonical descriptor: it lists every skill (name, path, description, `requiresMcp`) and the three `mcpServers` (with `${MCP_URL}`, `${AUTH_TOKEN}`, `${AUTH_ORG_ID}` placeholders) plus the OAuth `auth` block. Everything else — `.claude-plugin/`, `.codex-plugin/`, `plugin.json`, `packages/pi-plugin`, and `packages/core`'s own bundled copy — is generated or materialized *from* `bundle.json` and root `skills/`. Never hand-edit generated manifests; edit `bundle.json` and/or `skills/` and regenerate with the `plugin:*` scripts above. - -Per-harness install model (see `README.md` "Supported harnesses" table and `openspec/changes/archive/2026-07-21-cross-harness-plugin-installer/` for the original design rationale): - -- **Claude / Codex / Antigravity**: install the repo root as a native harness plugin (marketplace manifests under `.claude-plugin/`, `.codex-plugin/`, `plugin.json`). `nsolid-plugin install --harness ` (from `packages/core`) is only a fallback/repair path — never the primary install. -- **OpenCode**: no native plugin model. `packages/core`'s CLI is the *only* install path: `setup` then `install --harness opencode`, which copies skills to `~/.config/opencode/skills/` and writes MCP config to `~/.config/opencode/opencode.jsonc`. -- **Pi Agent**: `packages/pi-plugin` is a real npm package (`nsolid-pi-plugin`) that owns its skills via `pi.skills` in its manifest; skills are materialized into it only at `prepack` time (`pnpm plugin:materialize`) and cleaned afterward (`pnpm plugin:clean` / `plugin:sync`) — a materialized `packages/pi-plugin/skills/` should never be committed. `packages/core`'s CLI writes only `~/.pi/agent/mcp.json` for Pi; a separate `pi-mcp-adapter` package is required at runtime since Pi has no native MCP support. - -No harness relies on npm `postinstall` hooks — install/setup is always an explicit command. - -### `packages/core` - -The shared TypeScript library + CLI (published as npm package `nsolid-plugin`), organized by concern: - -- `src/auth/` — NodeSource OAuth flow (`auth-manager.ts` orchestrates; `oauth-server.ts` is the local callback listener on port 8765, fallback 8766–8770; `token-storage.ts`/`token-validator.ts` manage `~/.agents/.nodesource-auth.json`, mode `0600`, shared across all harnesses). -- `src/harnesses/` — one adapter per harness (`claude-adapter.ts`, `codex-adapter.ts`, `opencode-adapter.ts`, `antigravity-adapter.ts`, `pi-adapter.ts`) implementing the `HarnessAdapter` interface (`harness-adapter.ts`): `getMcpConfigPath`, `getSkillsPath`, `readMcpConfig`/`writeMcpConfig`, and optional `detectNativePlugin()` for harnesses with a native plugin model (doctor treats it as N/A where absent, e.g. OpenCode). -- `src/mcp/` — MCP config merging (`mcp-config-merger.ts`, never clobbers non-NodeSource servers already in a harness config) and dedup tracking (`mcp-tracker.ts`). -- `src/skills/` — `skill-copier.ts` / `skill-linker.ts` install skills into a harness's skills dir; both validate the destination name and resolved source path stay within their base directories (path-traversal guards — see `*-security.test.ts` siblings) before touching disk. -- `src/cli.ts` → the `nsolid-plugin` bin, dispatching `setup | install | uninstall | doctor | restore`. - -Install/setup are intentionally split: `setup()` is the only thing that authenticates/opens a browser; `install()` is a pure asset installer that never touches auth. Runtime MCP wrapper scripts fail fast with an actionable `Run: nsolid-plugin setup --harness ` message when credentials are missing/expired rather than trying to trigger auth themselves. - -Config mutations are backed up first to `~/.agents/.config-backup//.` (with a `.meta.json` sidecar) before any harness MCP config is overwritten, restorable via `restore()`. - -### `scripts/` - -- `run-tests.mjs` — the cross-platform test discovery/runner described above. -- `sync-plugin-assets.mjs` — implements `plugin:sync` / `plugin:check` / `plugin:materialize`. -- `materialize-github-marketplace.mjs` — implements `plugin:root` / `plugin:root:check`. -- `mcp-wrapper.js` — the runtime shim referenced by generated MCP configs; injects auth headers from stored credentials and produces the actionable re-auth error mentioned above. -- `test-marketplace-install.js` — end-to-end check that the generated marketplace/plugin artifacts actually install correctly. - -### Skill anatomy - -Each `skills//` has a `SKILL.md` (frontmatter + instructions the agent follows) plus optional helper scripts (e.g. `fetch-asset.cjs`, `workspace-delta.cjs`) that skills shell out to. Skills declare `requiresMcp` in `bundle.json` — skills without it (e.g. `ns-optimize-function`) work purely from local workspace source, no MCP call required. diff --git a/README.md b/README.md index 0f0f43a..4f5c8b1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ N|Solid Plugin installs NodeSource AI skills and MCP servers into Claude Code, C |---|---|---| | **Claude Code** | Root GitHub marketplace/plugin + `.claude-plugin/plugin.json` | Native plugin install, then explicit setup | | **Codex CLI** | Root GitHub marketplace/plugin + `.codex-plugin/plugin.json` | Native plugin install, then explicit setup | -| **OpenCode** | CLI direct install (user-level skills + MCP config) | `nsolid-plugin setup --harness opencode`, then `nsolid-plugin install --harness opencode` | +| **OpenCode** | CLI direct install (user-level skills + MCP config) | `nsolid-plugin setup --harness opencode` (auth + writes config); `nsolid-plugin install --harness opencode` refreshes it | | **Antigravity CLI** | Root GitHub plugin + `plugin.json` | `agy plugin install `, then explicit setup | | **Pi Agent** | npm package + `pi.skills` | `pi install npm:nsolid-pi-plugin`, `nsolid-plugin setup --harness pi`, then `pi install npm:pi-mcp-adapter` | @@ -47,7 +47,7 @@ Skills are canonical in the repository-root `skills/` directory. The repo root i | **Codex** | Root plugin | Native marketplace/plugin install; `setup` for auth | | **Antigravity** | Root plugin | `agy plugin install `; `setup` for auth | | **Pi** | Pi npm package (`pi.skills`) | Pi package owns skills; `setup` writes auth/MCP config | -| **OpenCode** | CLI direct install | `setup` authenticates; `install` copies skills and writes MCP config | +| **OpenCode** | CLI direct install | `setup` authenticates AND writes MCP config/skills; `install` refreshes the direct config | ## Authentication @@ -63,14 +63,16 @@ On setup: 1. Your browser opens `accounts.nodesource.com/sign-in` for login. 2. A local HTTP server starts on port **8765** (fallback: 8766–8770) to receive the callback. 3. The OAuth callback provides a `serviceToken`, `consoleId`, `saasToken`, and `consoleUrl`. -4. An `mcpUrl` is derived from the callback's `consoleId` (or via string transform from `consoleUrl`). +4. An `mcpUrl` is derived by combining the callback's `consoleId` (the org UUID) with the trusted environment suffix of `consoleUrl` (e.g. `saas.nodesource.io`, `staging.saas.nodesource.io`), giving `https://.mcp./`. 5. Credentials are stored at `~/.agents/.nodesource-auth.json` with mode `0600`. +If a browser does not open automatically (headless CI, devcontainer, agent host, etc.), the CLI prints the sign-in URL to stderr — open it manually in any browser to complete the flow. Nothing sensitive (no tokens) is printed there. + **What is stored:** `serviceToken`, `organizationId`, `saasToken`, `consoleUrl`, `mcpUrl`, `expiresAt`, `permissions`, and the `accountsUrl` auth origin used to mint/validate the token. **Token lifecycle:** Expired credentials trigger re-authentication during explicit setup/login. Runtime MCP wrappers fail with an actionable `Run: nsolid-plugin setup --harness ` message if credentials are missing or expired. Credentials are shared across harnesses — which also means there is only ever one authenticated NodeSource org at a time. If you belong to more than one org, use `nsolid-plugin switch-org --harness ` to force a fresh sign-in and pick a different one; see [Switching organizations](#switching-organizations) below. -**`mcpUrl` derivation:** Always built from the org's UUID (`consoleId`/`organizationId`), never from `consoleUrl`'s hostname label — a console may be reachable at a friendly display alias (e.g. `homedepot-nucleus-stage-1.saas.nodesource.io`), but the underlying MCP ingress route is only ever provisioned under the org's UUID, so using the alias verbatim produces a dead endpoint. `consoleUrl` is only consulted for its environment suffix (`saas.nodesource.io`, `staging.saas.nodesource.io`, etc.), giving `https://.mcp./`. Computed and stored on every fresh OAuth completion (`setup`, `switch-org`); a stored/explicit `credentials.mcpUrl` — including a legitimate custom operator override — still always takes priority over re-deriving. If `consoleUrl` doesn't match a recognized NodeSource pattern and no `mcpUrl` is stored, installation fails with an actionable error. +**`mcpUrl` derivation:** Always built from the org's UUID (`consoleId`/`organizationId`), never from `consoleUrl`'s hostname label — a console may be reachable at a friendly display alias (e.g. `homedepot-nucleus-stage-1.saas.nodesource.io`), but the underlying MCP ingress route is only ever provisioned under the org's UUID, so using the alias verbatim produces a dead endpoint. `consoleUrl` is only consulted for its environment suffix (`saas.nodesource.io`, `staging.saas.nodesource.io`, etc.), which must be the exact suffix or a dot-delimited deeper suffix — a hostname where `saas` is merely a substring of a larger label (e.g. `foo-saas.nodesource.io`) is rejected. This gives `https://.mcp./`, always over `https`. Computed and stored on every fresh OAuth completion (`setup`, `switch-org`); a stored/explicit `credentials.mcpUrl` — including a legitimate custom operator override — still always takes priority over re-deriving. If `consoleUrl` doesn't match a recognized NodeSource pattern, fresh OAuth fails with an actionable error and never silently persists a guessed production URL, and any previously stored credentials are left unchanged. ## Per-harness install @@ -131,7 +133,7 @@ nsolid-plugin setup --harness opencode nsolid-plugin install --harness opencode ``` -OpenCode does not use this repository as a native plugin. `setup` authenticates with NodeSource. `install` copies skills directly to `~/.config/opencode/skills/` and writes MCP servers to `~/.config/opencode/opencode.jsonc` under the top-level `mcp` key. It does not use shared `~/.agents/skills/`, avoiding cross-harness skill leakage and Pi package-owned skill collisions. +OpenCode does not use this repository as a native plugin. `nsolid-plugin setup --harness opencode` authenticates AND writes the direct config in one step: it copies skills to `~/.config/opencode/skills/` and writes MCP servers to `~/.config/opencode/opencode.jsonc` under the top-level `mcp` key. It does not use shared `~/.agents/skills/`, avoiding cross-harness skill leakage and Pi package-owned skill collisions. `nsolid-plugin install --harness opencode` re-runs that same direct config — including after a `switch-org`, where the harness you pass to `--harness` is refreshed on the spot. ### Antigravity CLI @@ -272,7 +274,7 @@ nsolid-plugin restore --harness --backup ~/.agents/.config-backup/` authenticates with NodeSource and may open a browser. `nsolid-plugin install --harness ` never opens a browser; it directly writes N|Solid skills and MCP config for a harness. Claude, Codex, and Antigravity should normally use native GitHub plugin install from the repository root. OpenCode uses the explicit two-step CLI path: `setup`, then `install`. Pi is package-owned: `pi install npm:nsolid-pi-plugin` installs skills, while `nsolid-plugin install/setup --harness pi` only writes Pi MCP config. +`nsolid-plugin setup --harness ` authenticates with NodeSource and may open a browser; for direct-config harnesses (OpenCode, Pi) it also writes that harness's MCP config in the same step. `nsolid-plugin install --harness ` never opens a browser; it directly writes N|Solid skills and MCP config for a harness and is used to (re)run a direct config — for example after `switch-org`. Claude, Codex, and Antigravity should normally use native GitHub plugin install from the repository root. OpenCode uses the single-step `setup`, and `install` to refresh its config. Pi is package-owned: `pi install npm:nsolid-pi-plugin` installs skills, while `nsolid-plugin install/setup --harness pi` writes Pi MCP config. ### Switching organizations @@ -280,7 +282,7 @@ nsolid-plugin restore --harness --backup ~/.agents/.config-backup/ ``` -Credentials are one shared file (`~/.agents/.nodesource-auth.json`), not per-harness, so only one NodeSource org is authenticated at a time. `switch-org` forces a fresh OAuth round-trip even when current credentials are still valid, so NodeSource's sign-in flow can show its org picker again (it only appears when your account belongs to more than one org). The new org applies globally — to every installed harness, not just the one passed to `--harness` — and other harnesses pick it up on their own next MCP reconnect (native plugin installs) or next `setup`/`install` run (OpenCode, Pi, and fallback-installed Claude/Codex/Antigravity). The command's own output tells you which follow-up applies to the harness you ran it for. An `ns-switch-org` skill is also installed alongside the others, so this can be triggered from inside a harness instead of a separate terminal. +Credentials are one shared file (`~/.agents/.nodesource-auth.json`), not per-harness, so only one NodeSource org is authenticated at a time. `switch-org` forces a fresh OAuth round-trip even when current credentials are still valid, so NodeSource's sign-in flow can show its org picker again (it only appears when your account belongs to more than one org). The new org applies globally — to every installed harness, not just the one passed to `--harness`. The harness you run it for is refreshed immediately: its direct MCP config (OpenCode/Pi) or its reconnect-ready native plugin picks up the new org. Other direct-config harnesses (OpenCode, Pi, and fallback-installed Claude/Codex/Antigravity) pick up the new org on their own next `setup`/`install` run; native-plugin harnesses get it on their next MCP reconnect. If the org switch itself succeeds but the selected harness's config refresh fails, the CLI reports a partial success (org already changed, credentials kept) with a nonzero exit and the retry command, rather than claiming the switch failed. The command's own output tells you which follow-up applies to the harness you ran it for. An `ns-switch-org` skill is also installed alongside the others, so this can be triggered from inside a harness instead of a separate terminal. ### Verbose logging diff --git a/packages/core/src/auth/auth-manager.ts b/packages/core/src/auth/auth-manager.ts index f61d976..ec4aaa3 100644 --- a/packages/core/src/auth/auth-manager.ts +++ b/packages/core/src/auth/auth-manager.ts @@ -162,6 +162,15 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg signInUrl.searchParams.set('state', state) logger?.info('auth.oauth.start', { accountsUrl: authConfig.accountsUrl }) + // Headless-safe manual fallback: surface the sign-in URL on stderr so a + // failed `open`/`xdg-open` (devcontainer, CI, agent host) does not leave the + // user waiting out the full timeout with no path to authenticate. The URL + // carries only the loopback port + CSRF state — never tokens — and is always + // printed regardless of whether the browser launch succeeds. + process.stderr.write('\nNodeSource authentication started.\n') + process.stderr.write('If a browser did not open automatically, open this sign-in URL manually:\n') + process.stderr.write(`${signInUrl.toString()}\n\n`) + openBrowser(signInUrl.toString(), logger) const callback = await server.waitForCallback() @@ -188,7 +197,22 @@ export async function ensureAuthenticated (authConfig: AuthConfig, logger?: Logg throw new Error(formatPluginError(pluginErr), { cause: pluginErr }) } - const mcpUrl = deriveMcpUrlFromConsoleUrl(callback.consoleUrl, callback.consoleId) ?? `https://${callback.consoleId}.mcp.saas.nodesource.io` + // Never guess a production MCP host when the console URL is not a recognized + // NodeSource SaaS origin. Silently persisting a wrong endpoint here would + // make it sticky (install and the runtime wrapper both prefer stored + // `mcpUrl` over re-deriving). Fail with an actionable error instead, leaving + // the previous credentials untouched on disk. + const mcpUrl = deriveMcpUrlFromConsoleUrl(callback.consoleUrl, callback.consoleId) + if (mcpUrl === null) { + const pluginErr = toPluginError( + new Error(`Could not determine the N|Solid MCP endpoint from the console URL for org "${callback.consoleId}" (unrecognized NodeSource console host).`), + 'AUTH_FAILED', + { + action: 'Re-run setup after confirming the console URL, or contact NodeSource support for your organization\'s MCP endpoint. Existing credentials were left unchanged.', + } + ) + throw new Error(formatPluginError(pluginErr), { cause: pluginErr }) + } try { const result = await validateToken(callback.token, callback.consoleId, authConfig.accountsUrl, logger) diff --git a/packages/core/src/auth/mcp-url.ts b/packages/core/src/auth/mcp-url.ts index f7782d9..985e6ad 100644 --- a/packages/core/src/auth/mcp-url.ts +++ b/packages/core/src/auth/mcp-url.ts @@ -12,6 +12,8 @@ * etc.) is trusted from consoleUrl; the label itself is always rebuilt from * the trusted organizationId. */ +const SUFFIX = 'saas.nodesource.io' + export function deriveMcpUrlFromConsoleUrl (consoleUrl: string, organizationId: string): string | null { let parsed: URL try { @@ -24,7 +26,12 @@ export function deriveMcpUrlFromConsoleUrl (consoleUrl: string, organizationId: if (labels.length < 2) return null const suffix = labels.slice(1).join('.') - if (!suffix.endsWith('saas.nodesource.io')) return null + // Trust only an exact `saas.nodesource.io` suffix or a deeper one reached + // through a DNS label boundary (dot-delimited). A plain `endsWith` would + // also accept a label like `foo-saas.nodesource.io` — where `saas` is a + // substring of a larger label — and rebuild the endpoint onto a hostname + // that accounts-api never provisions. + if (suffix !== SUFFIX && !suffix.endsWith(`.${SUFFIX}`)) return null return `https://${organizationId}.mcp.${suffix}/` } diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 057b4fb..a2e0a33 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -73,16 +73,17 @@ Options: --verbose Enable detailed logging to stderr --json Output doctor report as JSON (machine-readable) --no-color Disable colored output - --quiet Suppress step-by-step progress output (install only) + --quiet Suppress step-by-step progress output (setup/install/switch-org) --yes Skip interactive confirmation prompts - --accounts-url Explicit origin-only accounts URL override for setup + --accounts-url Explicit origin-only accounts URL override for setup/switch-org --help Show this help message Distribution notes: Claude/Codex/Antigravity: install from the GitHub plugin root; setup is auth-only. Pi: use pi install for package-owned skills; CLI install/setup only writes MCP config. - OpenCode: run setup --harness opencode for auth, then install --harness opencode for skills/MCP config. - Auth: only setup/login may open a browser.`) + OpenCode: setup --harness opencode authenticates AND writes its skills/MCP config; install --harness opencode re-runs that direct config. + After switch-org, the harness you pass to --harness has its direct MCP config refreshed on the spot; other direct-config harnesses (OpenCode, Pi, fallback CLI installs) need a later setup/install to re-bake the new org's token. + Auth: only setup/switch-org may open a browser.`) } function isInteractive (): boolean { @@ -278,6 +279,7 @@ async function main (): Promise { dim: (s: string) => color ? C.dim(s) : s, green: (s: string) => color ? C.green(s) : s, yellow: (s: string) => color ? C.yellow(s) : s, + red: (s: string) => color ? C.red(s) : s, } switch (command) { @@ -465,18 +467,43 @@ async function main (): Promise { force: true, }) - if (!result.success) { - console.error(`Switch organization failed for ${switchHarness}:`) - for (const err of result.errors) { - console.error(` - ${err}`) - } - process.exit(1) - } - const current = (() => { try { return loadCredentials() } catch { return null } })() - console.log(`${paint.green('✓')} Now signed in to org: ${current?.organizationId ?? '(unknown)'}`) + + // Pure, unit-tested orchestration of the switch-org output + exit code. + const { buildSwitchOrgOutcome } = await import('./utils/format.js') + const outcome = buildSwitchOrgOutcome({ + success: result.success, + authSucceeded: result.authSucceeded, + errors: result.errors, + previousOrg: previous?.organizationId, + currentOrg: current?.organizationId, + harness: switchHarness, + harnessLabel: HARNESS_LABELS[switchHarness], + isPluginOwned: PLUGIN_OWNED_HARNESSES.has(switchHarness), + }) + + if (outcome.kind === 'auth-failed') { + console.error(paint.red(outcome.errorHeader ?? `✗ Switch organization failed for ${switchHarness}:`)) + for (const line of outcome.detail) console.error(line) + process.exit(1) + } + + console.log(paint.green(outcome.stateLine)) + + if (outcome.kind === 'partial') { + // Org switched (credentials live on disk) but the selected harness's + // on-disk MCP config could not be refreshed. This is a partial success: + // do NOT report the switch as failed, and do NOT roll back the + // globally-switched credentials. Exit nonzero so CI/scripts know the + // refresh is incomplete, and point at the retry command. + console.error(paint.yellow(outcome.warning!)) + for (const line of outcome.detail) console.error(line) + console.error(' The new org is already saved globally; refresh this harness with:') + for (const line of outcome.commands) console.error(paint.dim(` ${line}`)) + process.exit(1) + } const { formatSwitchOrgGuidance } = await import('./utils/format.js') let nativeInstalled = false @@ -497,7 +524,7 @@ async function main (): Promise { console.log(guidanceLine) } - console.log(paint.dim(' Other harnesses sharing this login pick up the new org on their next MCP reconnect (native plugins) or next setup/install run (direct-config harnesses).')) + console.log(paint.dim(' Other direct-config harnesses sharing this login (OpenCode, Pi, fallback CLI installs) pick up the new org on their next setup/install run.')) break } case 'restore': { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 97e4896..dbf62e4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,6 +129,7 @@ export async function setup (options: SetupOptions): Promise { skillsInstalled: 0, mcpServersConfigured: [], hadToAuthenticate: false, + authSucceeded: false, errors: [], } @@ -174,6 +175,10 @@ export async function setup (options: SetupOptions): Promise { try { await ensureAuthenticated(authConfig, logger, { harness: options.harness, confirmAuth: options.confirmAuth, force: options.force }) + // Credentials are authenticated now — the active org is set (freshly + // stored, or already valid). This is the "org switch succeeded" signal, + // independent of the harness install/config refresh that follows. + result.authSucceeded = true } catch (err) { const pluginErr = toPluginError(err, 'AUTH_FAILED', { harness: options.harness }) result.errors.push(`Authentication failed: ${pluginErr.message}`) @@ -213,6 +218,7 @@ export async function install (options: InstallOptions): Promise skillsInstalled: 0, mcpServersConfigured: [], hadToAuthenticate: false, + authSucceeded: false, errors: [], } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 66215f2..c6caab9 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -110,6 +110,16 @@ export interface InstallResult { mcpServersConfigured: string[]; /** True if credentials were needed and re-authentication was performed (whether it succeeded or failed). */ hadToAuthenticate: boolean; + /** + * True when the shared NodeSource credentials are authenticated and the + * active org is set (freshly stored, or already valid). For `switch-org` + * this is the "org switch succeeded" signal: it is independently true of + * `success`, because a later harness install/config refresh can still fail + * after the org has already been switched. Never set by `install()` (which + * does not authenticate). Do NOT roll back switched credentials when this + * is true but `success` is false — the org change is real and global. + */ + authSucceeded: boolean; /** Non-empty when any step failed; fatal failures short-circuit, non-fatal ones leave partial state. */ errors: string[]; } diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index a60be70..8601459 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -152,3 +152,102 @@ export function formatSwitchOrgGuidance (input: SwitchOrgGuidanceInput, color: b return lines } + +export interface SwitchOrgOutcomeInput { + /** result.success — false when any step failed. */ + success: boolean + /** result.authSucceeded — the org-switch signal, independent of `success`. */ + authSucceeded: boolean + /** result.errors — non-empty when a step failed. */ + errors: string[] + /** Org id signed in BEFORE the switch (undefined when none). */ + previousOrg?: string | null + /** Org id signed in AFTER the switch (undefined when unknown). */ + currentOrg?: string | null + harness: string + harnessLabel: string + isPluginOwned: boolean +} + +export type SwitchOrgOutcomeKind = 'auth-failed' | 'partial' | 'success' + +/** + * Structured result of the `switch-org` orchestration, so the CLI handler and + * its exit-code/output semantics are unit-testable without spawning a browser + * or the CLI process. Deliberately separates an auth failure (the switch did + * not happen) from a partial success (the org DID switch, but the selected + * harness's direct config refresh failed afterward — credentials are live and + * MUST NOT be rolled back). + */ +export interface SwitchOrgOutcome { + kind: SwitchOrgOutcomeKind + /** 1 for both auth-failure and partial (incomplete refresh); 0 only on full success. */ + exitCode: 0 | 1 + currentOrg: string + orgChanged: boolean + /** "Now signed in to org: X" / "Still signed in to org: X" (colorized green by caller). */ + stateLine: string + /** Red header shown only when auth itself failed. */ + errorHeader: string | null + /** Yellow warning shown only on partial success (config refresh incomplete). */ + warning: string | null + /** Plain error detail lines (from result.errors). */ + detail: string[] + /** Dim retry commands to print verbatim. */ + commands: string[] +} + +export function buildSwitchOrgOutcome (input: SwitchOrgOutcomeInput): SwitchOrgOutcome { + const { success, authSucceeded, errors, previousOrg, currentOrg, harness, harnessLabel, isPluginOwned } = input + const org = currentOrg ?? '(unknown)' + const orgChanged = currentOrg !== previousOrg + const stateLine = `${orgChanged ? '✓ Now signed in to org' : '✓ Still signed in to org'}: ${org}` + + if (!success && !authSucceeded) { + // Auth itself failed — the org was not switched. + return { + kind: 'auth-failed', + exitCode: 1, + currentOrg: org, + orgChanged, + stateLine, + errorHeader: `✗ Switch organization failed for ${harness}:`, + warning: null, + detail: errors.map((e) => ` - ${e}`), + commands: [], + } + } + + if (!success) { + // Org switched (credentials live on disk) but the harness's direct MCP + // config could not be refreshed. Partial success: report it accurately, + // show the active org + retry command, and still exit nonzero. + const commands = [`nsolid-plugin install --harness ${harness}`] + if (!isPluginOwned) commands.push(`(or re-run: nsolid-plugin setup --harness ${harness})`) + return { + kind: 'partial', + exitCode: 1, + currentOrg: org, + orgChanged, + stateLine, + errorHeader: null, + warning: `! Organization switched to ${org}, but ${harnessLabel} MCP config could not be fully refreshed.`, + detail: errors.map((e) => ` - ${e}`), + commands, + } + } + + // Full success. Caller still appends formatSwitchOrgGuidance / the + // "other direct-config harnesses" note. + return { + kind: 'success', + exitCode: 0, + currentOrg: org, + orgChanged, + stateLine, + errorHeader: null, + warning: null, + detail: [], + commands: [], + } +} diff --git a/packages/core/test/integration/auth/auth-manager.test.ts b/packages/core/test/integration/auth/auth-manager.test.ts index 0062ab7..c52ad32 100644 --- a/packages/core/test/integration/auth/auth-manager.test.ts +++ b/packages/core/test/integration/auth/auth-manager.test.ts @@ -719,3 +719,88 @@ describe('ensureAuthenticated - accountsUrl override', () => { await promise }) }) + +describe('ensureAuthenticated - manual sign-in URL fallback', () => { + it('prints the sign-in URL to stderr without exposing tokens', { timeout: 10000 }, async () => { + const { saveCredentials } = await import('../../../src/auth/token-storage.js') + const expiredCreds: Credentials = { + serviceToken: 'expired-token', + organizationId: 'org-123', + saasToken: 'expired-saas', + consoleUrl: 'https://expired.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() - 1000).toISOString(), + } + saveCredentials(expiredCreds) + + globalThis.fetch = mock.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), + })) as unknown as typeof fetch + + const originalWrite = process.stderr.write + const stderrChunks: string[] = [] + process.stderr.write = ((chunk: unknown) => { + stderrChunks.push(String(chunk)) + return true + }) as typeof process.stderr.write + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const promise = ensureAuthenticated(authConfig) + try { + const state = await pollForState(getStateFromExecFileCall) + await sendCallback(8767, state) + await promise + } finally { + process.stderr.write = originalWrite + } + + const stderr = stderrChunks.join('') + const urlLine = stderr.split('\n').find((line) => line.includes('/sign-in')) + assert.ok(urlLine, 'sign-in URL must be surfaced on stderr as a manual fallback') + const url = new URL(urlLine.trim()) + assert.strictEqual(url.pathname, '/sign-in') + assert.strictEqual(url.searchParams.get('port'), '8767') + assert.ok(url.searchParams.get('state'), 'CSRF state must be present') + // The manual URL must never leak credential material. + assert.ok(!stderr.includes('expired-token')) + assert.ok(!stderr.includes('expired-saas')) + }) +}) + +describe('ensureAuthenticated - unrecognized console URL', () => { + it('rejects fresh OAuth instead of persisting a guessed MCP URL, leaving old credentials intact', { timeout: 10000 }, async () => { + const { saveCredentials, loadCredentials } = await import('../../../src/auth/token-storage.js') + const creds: Credentials = { + serviceToken: 'existing-token', + organizationId: 'org-123', + saasToken: 'test-saas-token', + consoleUrl: 'https://one.saas.nodesource.io', + mcpUrl: 'https://org-123.mcp.saas.nodesource.io', + expiresAt: new Date(Date.now() + 86400000).toISOString(), + } + saveCredentials(creds) + + globalThis.fetch = mock.fn(async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), + })) as unknown as typeof fetch + + const { ensureAuthenticated } = await import('../../../src/auth/auth-manager.js') + const promise = ensureAuthenticated(authConfig, undefined, { force: true }) + const rejection = assert.rejects( + promise, + /Could not determine the N\|Solid MCP endpoint from the console URL/ + ) + + const state = await pollForState(getStateFromExecFileCall) + await sendCallback(8767, state, { consoleId: 'org-456', url: 'https://console.example.com' }) + await rejection + + assert.strictEqual(loadCredentials()?.organizationId, 'org-123', 'old credentials must survive a rejected fresh OAuth') + }) +}) diff --git a/packages/core/test/integration/cli-help.test.ts b/packages/core/test/integration/cli-help.test.ts index 27d54f3..e05beb1 100644 --- a/packages/core/test/integration/cli-help.test.ts +++ b/packages/core/test/integration/cli-help.test.ts @@ -17,7 +17,7 @@ describe('CLI help', () => { const output = result.stdout assert.match(output, /Claude\/Codex\/Antigravity: install from the GitHub plugin root/, 'help must group Codex with root native plugin harnesses') assert.match(output, /setup is auth-only/, 'help must identify setup as auth-only for native plugin harnesses') - assert.match(output, /OpenCode: run setup --harness opencode for auth, then install --harness opencode for skills\/MCP config\./, 'help must describe OpenCode setup then install') + assert.match(output, /OpenCode: setup --harness opencode authenticates AND writes its skills\/MCP config/, 'help must describe OpenCode setup as one-step direct config') assert.doesNotMatch(output, /OpenCode\/Codex/, 'help must not list Codex as a user-level skill harness') }) @@ -29,4 +29,24 @@ describe('CLI help', () => { assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) assert.match(result.stdout, /switch-org\s+Force re-authentication to switch NodeSource organizations/, 'help must list switch-org command') }) + + it('documents that switch-org refreshes the selected direct-config harness and notes other direct configs need setup/install', () => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', CLI_PATH, '--help'], { + encoding: 'utf-8', + }) + + assert.strictEqual(result.status, 0, `CLI --help failed: ${result.stderr}`) + assert.match( + result.stdout, + /After switch-org, the harness you pass to --harness has its direct MCP config refreshed on the spot/, + 'help must state the selected direct-config harness is refreshed immediately' + ) + assert.match( + result.stdout, + /other direct-config harnesses \(OpenCode, Pi, fallback CLI installs\) need a later setup\/install/, + 'help must state other direct configs need a later setup/install' + ) + assert.match(result.stdout, /--accounts-url \s+Explicit origin-only accounts URL override for setup\/switch-org/, 'help must scope --accounts-url to setup/switch-org') + assert.match(result.stdout, /--quiet\s+Suppress step-by-step progress output \(setup\/install\/switch-org\)/, 'help must scope --quiet to setup/install/switch-org') + }) }) diff --git a/packages/core/test/integration/installer.test.ts b/packages/core/test/integration/installer.test.ts index 4c6f584..543482b 100644 --- a/packages/core/test/integration/installer.test.ts +++ b/packages/core/test/integration/installer.test.ts @@ -246,6 +246,130 @@ describe('install()', () => { assert.strictEqual(loadCredentials()?.organizationId, 'org-456') }) + it('setup with force rewrites the OpenCode on-disk MCP config with the new org url/token', { timeout: 10000 }, async () => { + const { setup, loadCredentials } = await import('../../src/index.js') + const { readJsonFile } = await import('../../src/utils/config.js') + const bundle = createBundle({ + mcpServers: [ + { name: 'nsolid-console', url: '$' + '{MCP_URL}', headers: { 'X-Nsolid-Service-Token': '$' + '{AUTH_TOKEN}' } }, + ], + auth: { + type: 'oauth', + provider: 'nodesource', + accountsUrl: 'https://accounts.nodesource.com', + callbackPort: 8769, + }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials({ organizationId: 'org-original' }) + globalThis.fetch = (async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), + })) as unknown as typeof fetch + const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } + + const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true }) + + const { state, port } = await pollForState() + await sendCallback(port, state, { consoleId: 'org-456' }) + const result = await promise + + assert.strictEqual(result.authSucceeded, true) + assert.strictEqual(result.success, true) + assert.strictEqual(loadCredentials()?.organizationId, 'org-456', 'shared credentials must be switched') + const cfg = readJsonFile>(join(tmpDir, '.config', 'opencode', 'opencode.jsonc')) + const server = (cfg?.mcp as Record }>)?.['nsolid-console'] + assert.ok(server, 'openocode.jsonc must contain an nsolid-console server') + assert.strictEqual(server.url, 'https://org-456.mcp.saas.nodesource.io/') + assert.strictEqual(server.headers?.['X-Nsolid-Service-Token'], 'oauth-token') + }) + + it('setup with force rewrites the Pi on-disk MCP config with the new org url/token', { timeout: 10000 }, async () => { + const { setup, loadCredentials } = await import('../../src/index.js') + const { readJsonFile } = await import('../../src/utils/config.js') + const bundle = createBundle({ + mcpServers: [ + { name: 'nsolid-console', url: '$' + '{MCP_URL}', headers: { 'X-Nsolid-Service-Token': '$' + '{AUTH_TOKEN}' } }, + ], + auth: { + type: 'oauth', + provider: 'nodesource', + accountsUrl: 'https://accounts.nodesource.com', + callbackPort: 8769, + }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials({ organizationId: 'org-original' }) + globalThis.fetch = (async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), + })) as unknown as typeof fetch + const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } + + const promise = setup({ harness: 'pi', bundlePath, skillsSource, progress, force: true, packageOwnedSkills: true }) + + const { state, port } = await pollForState() + await sendCallback(port, state, { consoleId: 'org-456' }) + const result = await promise + + assert.strictEqual(result.authSucceeded, true) + assert.strictEqual(result.success, true) + assert.strictEqual(loadCredentials()?.organizationId, 'org-456', 'shared credentials must be switched') + const cfg = readJsonFile>(join(tmpDir, '.pi', 'agent', 'mcp.json')) + const server = (cfg?.mcpServers as Record }>)?.['nsolid-console'] + assert.ok(server, 'Pi mcp.json must contain an nsolid-console server') + assert.strictEqual(server.url, 'https://org-456.mcp.saas.nodesource.io/') + assert.strictEqual(server.headers?.['X-Nsolid-Service-Token'], 'oauth-token') + }) + + it('reports partial success when the post-auth MCP config refresh fails but the org already switched', { timeout: 10000 }, async () => { + const { setup, loadCredentials } = await import('../../src/index.js') + const bundle = createBundle({ + mcpServers: [ + { name: 'nsolid-console', url: '$' + '{MCP_URL}', headers: { 'X-Nsolid-Service-Token': '$' + '{AUTH_TOKEN}' } }, + ], + auth: { + type: 'oauth', + provider: 'nodesource', + accountsUrl: 'https://accounts.nodesource.com', + callbackPort: 8769, + }, + }) + const bundlePath = writeBundle(bundle) + const skillsSource = createSkillSource('ns-test-skill') + seedCredentials({ organizationId: 'org-original' }) + // Make the OpenCode MCP config path un-writable after auth by colliding it + // with a directory: skills still copy, but the config write must fail. + mkdirSync(join(tmpDir, '.config', 'opencode', 'opencode.jsonc'), { recursive: true }) + globalThis.fetch = (async () => ({ + ok: true, + status: 200, + headers: new Headers({ 'content-type': 'application/json' }), + json: async () => ({ permissions: [] }), + })) as unknown as typeof fetch + const progress: ProgressReporter = { header: () => {}, step: () => {}, done: () => {}, warn: () => {} } + + const promise = setup({ harness: 'opencode', bundlePath, skillsSource, progress, force: true, harnessSpecificSkills: true }) + + const { state, port } = await pollForState() + await sendCallback(port, state, { consoleId: 'org-456' }) + const result = await promise + + // Auth succeeded (the org switch itself is done and saved globally)... + assert.strictEqual(result.authSucceeded, true) + // ...but the config refresh after it failed. + assert.strictEqual(result.success, false) + assert.ok(result.errors.some((e) => e.includes('MCP configuration failed')), 'config write failure must be surfaced') + // The switched credentials MUST NOT be rolled back. + assert.strictEqual(loadCredentials()?.organizationId, 'org-456', 'globally switched credentials are kept despite the refresh failure') + }) + it('setup for Antigravity authenticates only and does not write global skills/MCP config', async () => { const { setup } = await import('../../src/index.js') const bundle = createBundle({ diff --git a/packages/core/test/unit/auth/mcp-url.test.ts b/packages/core/test/unit/auth/mcp-url.test.ts index 556f1c4..7a59218 100644 --- a/packages/core/test/unit/auth/mcp-url.test.ts +++ b/packages/core/test/unit/auth/mcp-url.test.ts @@ -38,4 +38,23 @@ describe('deriveMcpUrlFromConsoleUrl', () => { it('returns null for a bare hostname with no suffix to trust', () => { assert.strictEqual(deriveMcpUrlFromConsoleUrl('https://localhost', 'org-123'), null) }) + + it('rejects a console URL whose suffix only contains saas as a label substring', () => { + // Regression: a dot-less endsWith check would accept `foo-saas.nodesource.io` + // because the naive check matches "…saas.nodesource.io" as a suffix even + // though `saas` is not a whole label. That host is not an ingress route. + assert.strictEqual(deriveMcpUrlFromConsoleUrl('https://alias.extra-saas.nodesource.io', 'org-123'), null) + assert.strictEqual(deriveMcpUrlFromConsoleUrl('https://alias.foosaas.nodesource.io', 'org-123'), null) + }) + + it('accepts the exact saas suffix and dot-delimited deeper suffixes only', () => { + assert.strictEqual( + deriveMcpUrlFromConsoleUrl('https://pretty-name.saas.nodesource.io', 'org-1'), + 'https://org-1.mcp.saas.nodesource.io/' + ) + assert.strictEqual( + deriveMcpUrlFromConsoleUrl('https://pretty-name.staging.saas.nodesource.io', 'org-2'), + 'https://org-2.mcp.staging.saas.nodesource.io/' + ) + }) }) diff --git a/packages/core/test/unit/mcp/mcp-wrapper.test.ts b/packages/core/test/unit/mcp/mcp-wrapper.test.ts index 02333da..8280b88 100644 --- a/packages/core/test/unit/mcp/mcp-wrapper.test.ts +++ b/packages/core/test/unit/mcp/mcp-wrapper.test.ts @@ -81,6 +81,52 @@ describe('MCP wrapper fallback', () => { }) for (const wrapper of ['source', 'generated'] as const) { + it(`${wrapper} wrapper derives the console MCP URL from the org id when no mcpUrl is stored`, { skip: process.platform === 'win32' }, () => { + const fixture = createWrapperFixture(wrapper) + // Blank out the stored mcpUrl so the wrapper must derive it from + // consoleUrl + org id (matching the TS deriveMcpUrlFromConsoleUrl). + writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ + serviceToken: token, organizationId: 'org-123', consoleUrl: 'https://pretty-name.saas.nodesource.io', mcpUrl: '', expiresAt: '2099-01-01T00:00:00.000Z', + })) + const npx = join(fixture.bin, 'npx') + writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') + chmodSync(npx, 0o755) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + assert.strictEqual(result.status, 0, result.stderr) + const args = readFileSync(fixture.output, 'utf8').trimEnd().split('\n') + assert.strictEqual(args[2], 'https://org-123.mcp.saas.nodesource.io/') + }) + + it(`${wrapper} wrapper forces https on the derived MCP URL even for an http consoleUrl`, { skip: process.platform === 'win32' }, () => { + const fixture = createWrapperFixture(wrapper) + writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ + serviceToken: token, organizationId: 'org-456', consoleUrl: 'http://pretty-name.saas.nodesource.io', mcpUrl: '', expiresAt: '2099-01-01T00:00:00.000Z', + })) + const npx = join(fixture.bin, 'npx') + writeFileSync(npx, '#!/bin/sh\nprintf "%s\\n" "$@" > "$NSOLID_TEST_OUTPUT"\n') + chmodSync(npx, 0o755) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + assert.strictEqual(result.status, 0, result.stderr) + const args = readFileSync(fixture.output, 'utf8').trimEnd().split('\n') + assert.strictEqual(args[2], 'https://org-456.mcp.saas.nodesource.io/') + }) + + it(`${wrapper} wrapper rejects a console URL that is not a recognized NodeSource SaaS host`, { skip: process.platform === 'win32' }, () => { + const fixture = createWrapperFixture(wrapper) + writeFileSync(join(fixture.home, '.agents', '.nodesource-auth.json'), JSON.stringify({ + serviceToken: token, organizationId: 'org-123', consoleUrl: 'https://console.example.com', mcpUrl: '', expiresAt: '2099-01-01T00:00:00.000Z', + })) + const npx = join(fixture.bin, 'npx') + writeFileSync(npx, '#!/bin/sh\nexit 0\n') + chmodSync(npx, 0o755) + + const result = spawnSync(process.execPath, [fixture.wrapperPath, 'nsolid-console'], { env: wrapperEnvironment(fixture), encoding: 'utf8' }) + assert.notStrictEqual(result.status, 0) + assert.match(result.stderr, /Could not derive NodeSource console MCP URL/) + }) + it(`${wrapper} wrapper preserves argv boundaries outside Windows`, { skip: process.platform === 'win32' }, () => { const fixture = createWrapperFixture(wrapper) const npx = join(fixture.bin, 'npx') diff --git a/packages/core/test/unit/utils/format.test.ts b/packages/core/test/unit/utils/format.test.ts index 047a368..4dae547 100644 --- a/packages/core/test/unit/utils/format.test.ts +++ b/packages/core/test/unit/utils/format.test.ts @@ -328,6 +328,91 @@ describe('formatSwitchOrgGuidance', () => { }) }) +describe('buildSwitchOrgOutcome', () => { + it('treats a failed auth as a switch failure with a nonzero exit', async () => { + const { buildSwitchOrgOutcome } = await import('../../../src/utils/format.js') + const outcome = buildSwitchOrgOutcome({ + success: false, + authSucceeded: false, + errors: ['Authentication timed out. Please try again.'], + harness: 'opencode', + harnessLabel: 'OpenCode', + isPluginOwned: false, + }) + + assert.strictEqual(outcome.kind, 'auth-failed') + assert.strictEqual(outcome.exitCode, 1) + assert.ok(outcome.errorHeader?.includes('Switch organization failed for opencode')) + assert.ok(outcome.detail.some((l) => l.includes('Authentication timed out'))) + assert.strictEqual(outcome.warning, null) + }) + + it('reports a partial success (nonzero exit, org switched, retry guidance) when the post-auth config refresh fails', async () => { + const { buildSwitchOrgOutcome } = await import('../../../src/utils/format.js') + const outcome = buildSwitchOrgOutcome({ + success: false, + authSucceeded: true, + errors: ['MCP configuration failed: opencode.jsonc'], + previousOrg: 'org-original', + currentOrg: 'org-456', + harness: 'opencode', + harnessLabel: 'OpenCode', + isPluginOwned: false, + }) + + assert.strictEqual(outcome.kind, 'partial') + assert.strictEqual(outcome.exitCode, 1, 'incomplete refresh must still exit nonzero') + assert.strictEqual(outcome.currentOrg, 'org-456') + assert.strictEqual(outcome.orgChanged, true) + assert.match(outcome.stateLine, /Now signed in to org: org-456/) + assert.ok(outcome.warning?.includes('Organization switched to org-456'), 'must state the org switched (not that the switch failed)') + assert.ok(outcome.warning?.includes('MCP config could not be fully refreshed')) + assert.ok(outcome.detail.some((l) => l.includes('MCP configuration failed'))) + assert.ok(outcome.commands.some((c) => c === 'nsolid-plugin install --harness opencode')) + assert.ok(outcome.commands.some((c) => c.includes('nsolid-plugin setup --harness opencode'))) + }) + + it('words an unchanged selected org as "Still signed in to org"', async () => { + const { buildSwitchOrgOutcome } = await import('../../../src/utils/format.js') + const outcome = buildSwitchOrgOutcome({ + success: true, + authSucceeded: true, + errors: [], + previousOrg: 'org-456', + currentOrg: 'org-456', + harness: 'opencode', + harnessLabel: 'OpenCode', + isPluginOwned: false, + }) + + assert.strictEqual(outcome.kind, 'success') + assert.strictEqual(outcome.exitCode, 0) + assert.strictEqual(outcome.orgChanged, false) + assert.match(outcome.stateLine, /Still signed in to org: org-456/) + }) + + it('treats a full org change as success (exit 0) with a "Now signed in" line', async () => { + const { buildSwitchOrgOutcome } = await import('../../../src/utils/format.js') + const outcome = buildSwitchOrgOutcome({ + success: true, + authSucceeded: true, + errors: [], + previousOrg: 'org-original', + currentOrg: 'org-456', + harness: 'pi', + harnessLabel: 'Pi Agent', + isPluginOwned: true, + }) + + assert.strictEqual(outcome.kind, 'success') + assert.strictEqual(outcome.exitCode, 0) + assert.strictEqual(outcome.orgChanged, true) + assert.match(outcome.stateLine, /Now signed in to org: org-456/) + assert.strictEqual(outcome.warning, null) + assert.deepStrictEqual(outcome.commands, []) + }) +}) + describe('supportsColor', () => { let originalNoColor: string | undefined let originalForceColor: string | undefined diff --git a/packages/core/test/unit/utils/test-concurrency.test.ts b/packages/core/test/unit/utils/test-concurrency.test.ts new file mode 100644 index 0000000..6b0ce7a --- /dev/null +++ b/packages/core/test/unit/utils/test-concurrency.test.ts @@ -0,0 +1,41 @@ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +// @ts-expect-error The repository's JavaScript test-runner helper has no TypeScript declarations. +import { parseTestConcurrency } from '../../../../../scripts/test-concurrency.mjs' + +describe('parseTestConcurrency', () => { + it('returns the fallback when unset or empty', () => { + assert.strictEqual(parseTestConcurrency(undefined, 4), 4) + assert.strictEqual(parseTestConcurrency('', 4), 4) + }) + + it('accepts a positive safe integer override', () => { + assert.strictEqual(parseTestConcurrency('1', 4), 1) + assert.strictEqual(parseTestConcurrency('8', 4), 8) + assert.strictEqual(parseTestConcurrency(String(Number.MAX_SAFE_INTEGER), 4), Number.MAX_SAFE_INTEGER) + }) + + it('ignores surrounding whitespace as valid numeric input', () => { + assert.strictEqual(parseTestConcurrency(' 2 ', 4), 2) + }) + + it('rejects zero and negatives', () => { + assert.throws(() => parseTestConcurrency('0', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + assert.throws(() => parseTestConcurrency('-1', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + }) + + it('rejects non-numeric input', () => { + assert.throws(() => parseTestConcurrency('abc', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + assert.throws(() => parseTestConcurrency('Infinity', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + assert.throws(() => parseTestConcurrency('NaN', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + }) + + it('rejects non-integer numbers', () => { + assert.throws(() => parseTestConcurrency('2.5', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + }) + + it('rejects values outside the safe-integer range', () => { + assert.throws(() => parseTestConcurrency('9007199254740992', 4), /Invalid NSOLID_TEST_CONCURRENCY/) + }) +}) diff --git a/scripts/mcp-wrapper.js b/scripts/mcp-wrapper.js index 0a0e005..09d652e 100644 --- a/scripts/mcp-wrapper.js +++ b/scripts/mcp-wrapper.js @@ -52,7 +52,7 @@ function resolveServer (name, credentials) { switch (name) { case 'nsolid-console': { const derivedUrl = credentials.mcpUrl ? null : deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) - const url = credentials.mcpUrl ?? derivedUrl + const url = credentials.mcpUrl || derivedUrl if (!url) { fail(`Could not derive NodeSource console MCP URL from stored credentials. Run: ${SETUP_COMMAND}`) } @@ -95,9 +95,9 @@ function deriveMcpUrlFromConsoleUrl (consoleUrl, organizationId) { if (labels.length < 2) return null const suffix = labels.slice(1).join('.') - if (!suffix.endsWith('saas.nodesource.io')) return null + if (suffix !== 'saas.nodesource.io' && !suffix.endsWith('.saas.nodesource.io')) return null - return `${parsed.protocol}//${organizationId}.mcp.${suffix}/` + return `https://${organizationId}.mcp.${suffix}/` } async function runMcpRemote (url, headers) { diff --git a/scripts/plugin-generators.mjs b/scripts/plugin-generators.mjs index d3799d2..590229d 100644 --- a/scripts/plugin-generators.mjs +++ b/scripts/plugin-generators.mjs @@ -196,7 +196,7 @@ function resolveServer (name, credentials) { switch (name) { case 'nsolid-console': { const derivedUrl = credentials.mcpUrl ? null : deriveMcpUrlFromConsoleUrl(credentials.consoleUrl, credentials.organizationId) - const url = credentials.mcpUrl ?? derivedUrl + const url = credentials.mcpUrl || derivedUrl if (!url) { fail(\`Could not derive NodeSource console MCP URL from stored credentials. Run: \${SETUP_COMMAND}\`) } @@ -239,9 +239,9 @@ function deriveMcpUrlFromConsoleUrl (consoleUrl, organizationId) { if (labels.length < 2) return null const suffix = labels.slice(1).join('.') - if (!suffix.endsWith('saas.nodesource.io')) return null + if (suffix !== 'saas.nodesource.io' && !suffix.endsWith('.saas.nodesource.io')) return null - return \`\${parsed.protocol}//\${organizationId}.mcp.\${suffix}/\` + return \`https://\${organizationId}.mcp.\${suffix}/\` } async function runMcpRemote (url, headers) { diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 24d9720..1f267ef 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -19,6 +19,7 @@ import { existsSync, readdirSync, statSync, writeFileSync } from 'node:fs' import os from 'node:os' import { join, dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' +import { parseTestConcurrency } from './test-concurrency.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const LOG_PATH = join(ROOT, 'test-results.log') @@ -97,7 +98,17 @@ const REPORTER = pathToFileURL(join(ROOT, 'scripts', 'test-reporter.mjs')).href // husky pre-commit hook (`pnpm test`) in practice. Cap it, with an escape // hatch for anyone who wants to override it (e.g. a beefier CI runner). const DEFAULT_CONCURRENCY = Math.max(1, Math.floor(os.cpus().length / 2)) -const concurrency = Number(process.env.NSOLID_TEST_CONCURRENCY) || DEFAULT_CONCURRENCY +let concurrency +try { + // Validate the override before spawning anything: an invalid value (0, + // negative, non-numeric, or non-integer) is a broken environment that should + // fail immediately with a clear message rather than spawning a runner with + // a garbage --test-concurrency value. + concurrency = parseTestConcurrency(process.env.NSOLID_TEST_CONCURRENCY, DEFAULT_CONCURRENCY) +} catch (err) { + console.error(err instanceof Error ? err.message : String(err)) + process.exit(1) +} const args = [ '--experimental-test-module-mocks', diff --git a/scripts/test-concurrency.mjs b/scripts/test-concurrency.mjs new file mode 100644 index 0000000..5c4be87 --- /dev/null +++ b/scripts/test-concurrency.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +/** + * Validate the `NSOLID_TEST_CONCURRENCY` override used by scripts/run-tests.mjs. + * + * Kept as a pure, separately-importable module so it can be unit-tested + * without recursively spawning the test runner it feeds. + * + * @param {string | undefined} raw - The env value (undefined when unset). + * @param {number} fallback - The default concurrency to return when unset/empty. + * @returns {number} A validated positive safe-integer concurrency. + * @throws {RangeError} When `raw` is present but not a finite positive safe integer. + */ +export function parseTestConcurrency (raw, fallback) { + if (raw === undefined || raw === '') return fallback + const value = Number(raw) + if (typeof value !== 'number' || !Number.isFinite(value) || !Number.isSafeInteger(value) || value <= 0) { + throw new RangeError( + `Invalid NSOLID_TEST_CONCURRENCY value: "${raw}" (must be a finite positive safe integer).` + ) + } + return value +}