From 12f62c089276bff316597e966ea794c2859d4229 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 20:37:03 +0000 Subject: [PATCH] fix(cli): auth-config guidance names a command that resolves (#11313) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readAuthConfig()` threw "Please run `os auth login` first." — the one instruction a user gets when they are already stuck. `os auth login` does not resolve: `login.ts` sits at the root of `packages/cli/src/commands/`, so oclif's pattern-strategy loader registers it as `login`, and no `auth` topic has ever existed. Measured against the built CLI: `auth login --help` -> `Error: Command auth:login not found.` (exit 2); `login --help` -> exit 0; the built oclif Config enumerates 61 ids, zero containing `auth`, with login/logout/register/whoami/dev/serve present as the control. Also in the same file, `AuthConfig.activeEnvironmentId`'s doc comment said `os projects switch`; `projects` became `environments` in v5.0 with no aliases (ADR-0006) and the same enumeration shows no `projects` topic. The pin asserts the property, not the spelling: every invocation this file documents must resolve to an id derived from `src/commands/**` via oclif's own path->id algorithm, checked both on the real thrown message (real ENOENT branch, redirected $HOME) and across the whole source file. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../cli-auth-config-guidance-resolves.md | 41 +++ packages/cli/src/utils/auth-config.test.ts | 254 ++++++++++++++++++ packages/cli/src/utils/auth-config.ts | 4 +- 3 files changed, 297 insertions(+), 2 deletions(-) create mode 100644 .changeset/cli-auth-config-guidance-resolves.md create mode 100644 packages/cli/src/utils/auth-config.test.ts diff --git a/.changeset/cli-auth-config-guidance-resolves.md b/.changeset/cli-auth-config-guidance-resolves.md new file mode 100644 index 0000000000..72afe18452 --- /dev/null +++ b/.changeset/cli-auth-config-guidance-resolves.md @@ -0,0 +1,41 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): the "no stored credentials" error stops telling a stuck user to run `os auth login` (#11313) + +`readAuthConfig()` (`packages/cli/src/utils/auth-config.ts`) throws the one instruction a user +gets at the moment they are **already stuck**: they have no stored credentials, the command +they wanted has just failed, and this string is what tells them how to recover. It said +`os auth login`, which does not resolve — `login.ts` sits at the **root** of +`packages/cli/src/commands/`, so oclif's pattern-strategy loader registers it as `login`, and +no `auth` topic has ever existed. The second failure reads as "the tool is broken", not as +"a typo in a help string", which is why this is graded on its own terms rather than as another +stale-`examples` docs nit (#11221, #10967, #10927 were all `examples` arrays and README prose +— a user reading *ahead*). + +Measured against the built CLI (`packages/cli/bin/run.js`, after building the package and its +dependency closure) before the fix: `os auth login --help` → `Error: Command auth:login not +found.` (exit 2), `os login --help` → exit 0. Loading the built oclif `Config` enumerates 61 +registered ids, **zero** containing `auth`, and no `auth` topic — with `login`, `logout`, +`register`, `whoami`, `dev` and `serve` all present as the control that the zero is a real +absence rather than a broken probe. The message now says `os login`, the spelling `login.ts`'s +own `examples` already used. + +One further invocation in the same file is corrected in the same pass: +`AuthConfig.activeEnvironmentId`'s doc comment said `os projects switch`, and `projects` was +renamed to `environments` in v5.0 with no aliases (ADR-0006) — the same enumeration shows no +`projects` topic and no id containing `project`, while `environments switch` is registered. It +is fixed rather than excluded because the new pin scans the whole file, and an exclusion is how +a line stops being checked without anyone deciding to stop checking it. + +The pin (`packages/cli/src/utils/auth-config.test.ts`) asserts the **property**, not the new +spelling: every command invocation this file documents must resolve to an id the CLI actually +registers, with the id set re-derived from `src/commands/**` using oclif's own path→id +algorithm. A pin on the literal text `os login` would still pass on the day someone renames +`login.ts`; this one reds. It has two legs — the real `readAuthConfig()` driven into its real +ENOENT branch against a redirected `$HOME`, so what is checked is the message a user actually +reads, and a source-wide scan so a guidance string added to this file later is held to the same +property without anyone remembering to extend the pin. `environments.test.ts`'s `#10967` pin +reads `static override examples` via AST and structurally cannot see a thrown-error string, +which is why this class needed its own pin rather than an extension of that one. diff --git a/packages/cli/src/utils/auth-config.test.ts b/packages/cli/src/utils/auth-config.test.ts new file mode 100644 index 0000000000..45e49feb46 --- /dev/null +++ b/packages/cli/src/utils/auth-config.test.ts @@ -0,0 +1,254 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, rmSync, readFileSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { getCredentialsPath, readAuthConfig } from './auth-config.js'; + +/** + * # `#11313` pin: the guidance this file prints names a command that resolves + * + * `readAuthConfig()` throws the one instruction a **stuck** user gets: they + * have no stored credentials, the command they wanted has just failed, and + * this string tells them how to get unstuck. It used to say + * `os auth login`, which does not resolve — `login.ts` sits at the ROOT of + * `src/commands/`, so oclif's pattern strategy registers it as `login`, and + * no `auth` topic has ever existed. Measured against the built CLI before + * the fix: `os auth login --help` -> `Error: Command auth:login not found.` + * (exit 2), `os login --help` -> exit 0; loading the built oclif `Config` + * enumerates 61 ids, ZERO containing `auth`, and no `auth` topic, with + * `login`/`logout`/`register`/`whoami`/`dev`/`serve` all present as the + * control that the zero is a real absence rather than a broken probe. + * + * The second failure is the expensive one: it reads as "the tool is broken", + * not as "a typo in a help string". + * + * ## What is pinned — the property, not the spelling + * + * A pin asserting the literal new text (`os login`) would go green forever + * and catch nothing: it would still pass on the day someone renames + * `login.ts`, which is the next way this string can go stale. So what is + * asserted is that **every command invocation this file documents resolves + * to an id this CLI actually registers**, with the id set derived from the + * command tree. Rename `login.ts` and this pin reds. + * + * Two legs, deliberately: + * + * 1. **Behavioural** — the real `readAuthConfig()` is driven into its real + * ENOENT branch (a temp `$HOME` with no credentials file) and the message + * that a user would actually see is parsed. This pins the string a stuck + * user reads, not a string that happens to sit in the source. + * 2. **Source-wide** — every backticked invocation in `auth-config.ts`, so a + * guidance string or doc comment added here later is held to the same + * property without anyone remembering to extend this file. That leg is + * why the stale `os projects switch` in `AuthConfig.activeEnvironmentId`'s + * doc comment was fixed in the same commit (`projects` was renamed to + * `environments` in v5.0 with no aliases — ADR-0006): the alternative was + * an exclusion, and an exclusion is how a line stops being checked + * without anyone deciding to stop checking it. + * + * ## Why the id set is derived from SOURCE + * + * The direct route — `Config.load()` against the built CLI — is unreachable + * from a vitest suite here: `turbo.json` declares `test`'s `dependsOn` as + * `["^build"]`, dependencies' builds only, never this package's own, so + * `packages/cli/dist` (what oclif's pattern strategy scans) is not + * guaranteed to exist when this file runs. `registeredCommandIds()` below + * re-derives the set from `src/commands/**` using oclif's own path->id + * algorithm; `src/commands` is a 1:1 mirror of `dist/commands` (this + * package's build renames and relocates nothing). Its output was checked + * against the built `Config` while writing this pin: same ids. The same + * reasoning, and the same derivation, is documented at length in + * `src/commands/environments/environments.test.ts`'s `#10967` pin — which + * covers `static override examples` arrays via AST and structurally cannot + * see a thrown-error string, which is why this class needed its own pin + * rather than an extension of that one. The derivation is duplicated rather + * than shared because that file is outside this change's file surface; + * hoisting it into one helper is a follow-up, not a rider here. + */ +describe('#11313 pin: auth-config guidance resolves to a real command id', () => { + const HERE = fileURLToPath(new URL('.', import.meta.url)); + const COMMANDS_ROOT = path.resolve(HERE, '../commands'); + const AUTH_CONFIG_SOURCE = path.resolve(HERE, 'auth-config.ts'); + const PACKAGE_JSON = path.resolve(HERE, '../../package.json'); + + /** + * oclif's separator between topic and command in a user-typed invocation, + * read from this package's own manifest rather than hardcoded — the + * property under test is about what a user types, so a change to + * `oclif.topicSeparator` must move this pin with it. + */ + const TOPIC_SEPARATOR: string = JSON.parse(readFileSync(PACKAGE_JSON, 'utf8')).oclif.topicSeparator; + + const isCommandSource = (name: string): boolean => + name.endsWith('.ts') + && !name.endsWith('.d.ts') + && !/\.(test|pin\.test|contract\.test|integration\.test|e2e\.test)\.ts$/.test(name); + + /** Every command source file, as a path relative to `COMMANDS_ROOT` (posix separators). */ + function commandSourceFiles(): string[] { + const out: string[] = []; + const walk = (dir: string, prefix: string): void => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${entry.name}` : entry.name; + if (entry.isDirectory()) { + walk(path.join(dir, entry.name), rel); + continue; + } + if (!isCommandSource(entry.name)) continue; + out.push(rel); + } + }; + walk(COMMANDS_ROOT, ''); + return out.sort(); + } + + /** The command ids oclif registers, in the form a user types them. */ + function registeredCommandIds(): Set { + const ids = new Set(); + for (const rel of commandSourceFiles()) { + const parts = rel.split('/'); + const base = parts[parts.length - 1].slice(0, -'.ts'.length); + const topics = parts.slice(0, -1); + const command = base === 'index' ? undefined : base; + ids.add([...topics, command].filter((s): s is string => Boolean(s)).join(TOPIC_SEPARATOR)); + } + return ids; + } + + const registeredIds = registeredCommandIds(); + + /** + * True when `invocation` (`os `, without the `$ ` prompt) names a + * registered command — exactly, or as the command plus its arguments. + */ + function namesARegisteredCommand(invocation: string): boolean { + let rest = invocation.trim(); + if (!rest.startsWith('os ')) return false; + rest = rest.slice('os '.length); + for (const id of registeredIds) { + if (rest === id || rest.startsWith(`${id} `)) return true; + } + return false; + } + + /** + * Every `` `os …` `` span in a blob of text — the one shape this file uses + * to name a command, in thrown messages and in doc comments alike. A + * trailing backslash is dropped: inside a template literal the closing + * backtick is written escaped. + */ + function documentedInvocations(text: string): string[] { + const found: string[] = []; + const re = /`(?:\\?\$ )?(os [^`\n]*)`/g; + let m: RegExpExecArray | null; + while ((m = re.exec(text)) !== null) { + const invocation = m[1].replace(/\\$/, '').trim(); + if (invocation !== 'os') found.push(invocation); + } + return found; + } + + it('the derived id set is real: known ids across several topics, including the index.ts case', () => { + expect(TOPIC_SEPARATOR).toBe(' '); + expect(registeredIds.has('login')).toBe(true); + expect(registeredIds.has('logout')).toBe(true); + expect(registeredIds.has('environments switch')).toBe(true); + expect(registeredIds.has('data query')).toBe(true); + expect(registeredIds.has('migrate')).toBe(true); // migrate/index.ts -> `migrate`, no trailing segment + expect(registeredIds.size).toBeGreaterThan(30); + }); + + it('anti-vacuity: the predicate clears a good invocation and rejects both pre-fix spellings', () => { + // Cleared, so the assertions below are not passing because nothing ever passes. + expect(namesARegisteredCommand('os login')).toBe(true); + expect(namesARegisteredCommand('os login --email a@b.c')).toBe(true); + expect(namesARegisteredCommand('os environments switch proj-123')).toBe(true); + + // Rejected, and rejected for the declared reason: no file tree produces these ids. + expect(registeredIds.has('auth')).toBe(false); + expect(registeredIds.has('auth login')).toBe(false); + expect(namesARegisteredCommand('os auth login')).toBe(false); + expect(registeredIds.has('projects')).toBe(false); + expect(registeredIds.has('projects switch')).toBe(false); + expect(namesARegisteredCommand('os projects switch')).toBe(false); + }); + + describe('the credentials-missing guidance a stuck user actually reads', () => { + let home: string; + let previousHome: string | undefined; + let previousUserProfile: string | undefined; + + beforeAll(() => { + previousHome = process.env.HOME; + previousUserProfile = process.env.USERPROFILE; + home = mkdtempSync(path.join(tmpdir(), 'os-auth-config-pin-')); + process.env.HOME = home; + process.env.USERPROFILE = home; + }); + + afterAll(() => { + if (previousHome === undefined) delete process.env.HOME; + else process.env.HOME = previousHome; + if (previousUserProfile === undefined) delete process.env.USERPROFILE; + else process.env.USERPROFILE = previousUserProfile; + rmSync(home, { recursive: true, force: true }); + }); + + it('reads credentials from the redirected home (the ENOENT below is the real branch)', () => { + // Self-validating: if `homedir()` ignored the redirect, the throw under + // test would be about the developer's own machine and could even not + // happen at all (real credentials present). Fail loudly instead. + expect(getCredentialsPath().startsWith(home)).toBe(true); + }); + + it('names a command this CLI registers', async () => { + const error = await readAuthConfig().then( + () => undefined, + (e: unknown) => e as Error, + ); + expect(error, 'readAuthConfig() must reject when no credentials file exists').toBeInstanceOf(Error); + + const message = error!.message; + expect(message).toContain('No stored credentials found'); + + const invocations = documentedInvocations(message); + expect( + invocations.length, + `the credentials-missing message must tell the user which command to run; got: ${message}`, + ).toBeGreaterThan(0); + + const unresolved = invocations.filter((i) => !namesARegisteredCommand(i)); + expect( + unresolved, + 'the message a user sees when they have no credentials names a command this CLI does not ' + + 'register -- they are already stuck, and running what it tells them prints ' + + '"Command ... not found.", which reads as a broken tool rather than a stale string. ' + + `Registered ids are derived from src/commands/**; message was: ${message}`, + ).toEqual([]); + }); + }); + + it('every invocation documented in auth-config.ts source names a registered command', () => { + const text = readFileSync(AUTH_CONFIG_SOURCE, 'utf8'); + const invocations = documentedInvocations(text); + + // Anti-vacuity: an empty scan (a moved file, a changed quoting style) must + // not read as a pass. + expect( + invocations.length, + 'the scan of auth-config.ts found no `os ...` invocation at all -- the file moved, or it ' + + 'stopped naming commands in backticks and this pin is now checking nothing.', + ).toBeGreaterThanOrEqual(2); + + const unresolved = invocations.filter((i) => !namesARegisteredCommand(i)); + expect( + unresolved, + 'auth-config.ts documents a command invocation that does not resolve to an id this CLI ' + + 'registers. Fix the spelling against src/commands/** rather than excluding the line: an ' + + 'exclusion is how a line stops being checked without anyone deciding to stop checking it.', + ).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/auth-config.ts b/packages/cli/src/utils/auth-config.ts index 3ec1978de7..ac693fc1ed 100644 --- a/packages/cli/src/utils/auth-config.ts +++ b/packages/cli/src/utils/auth-config.ts @@ -33,7 +33,7 @@ export interface AuthConfig { */ lastUsedAt?: string; /** - * Active project id for subsequent CLI calls. Set via `os projects switch`; + * Active project id for subsequent CLI calls. Set via `os environments switch`; * read by `createApiClient` so every command lands in the right project * without an explicit flag. */ @@ -57,7 +57,7 @@ export async function readAuthConfig(): Promise { return JSON.parse(content) as AuthConfig; } catch (error: any) { if (error.code === 'ENOENT') { - throw new Error('No stored credentials found. Please run `os auth login` first.'); + throw new Error('No stored credentials found. Please run `os login` first.'); } throw new Error(`Failed to read credentials: ${error.message}`); }