diff --git a/skills/twist-cli/SKILL.md b/skills/twist-cli/SKILL.md index 597bc57..806fd30 100644 --- a/skills/twist-cli/SKILL.md +++ b/skills/twist-cli/SKILL.md @@ -26,7 +26,8 @@ tw auth status --user # Target a specific stored account (id, id:, tw auth logout # Remove saved token and auth metadata tw auth logout --json # Emits `{"ok": true}` (--ndjson is silent) tw auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -tw auth token --user # Print the saved token for a specific stored account +tw auth token view # Print the saved token to stdout (pipe-safe; refuses if TWIST_API_TOKEN is set) +tw auth token view --user # Print the saved token for a specific stored account tw workspaces # List available workspaces tw workspace use # Set current workspace tw completion install # Install shell completions diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 23b6813..98a3915 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -9,6 +9,7 @@ vi.mock('../../lib/auth.js', async (importOriginal) => { saveApiToken: vi.fn(), clearApiToken: vi.fn(), getAuthMetadata: vi.fn(), + probeApiToken: vi.fn(), } }) @@ -57,7 +58,14 @@ import { attachLoginCommand } from '@doist/cli-core/auth' import { TwistRequestError, type User } from '@doist/twist-sdk' import { createWrappedTwistClient } from '../../lib/api.js' import { type TwistAccount, type TwistTokenStore } from '../../lib/auth-provider.js' -import { clearApiToken, getAuthMetadata, saveApiToken } from '../../lib/auth.js' +import { + clearApiToken, + getAuthMetadata, + NoTokenError, + probeApiToken, + saveApiToken, + TOKEN_ENV_VAR, +} from '../../lib/auth.js' import { registerAuthCommand } from './index.js' import { attachTwistStatusCommand } from './status.js' @@ -66,6 +74,7 @@ const mockCreateInterface = vi.mocked(createInterface) const mockSaveApiToken = vi.mocked(saveApiToken) const mockClearApiToken = vi.mocked(clearApiToken) const mockGetAuthMetadata = vi.mocked(getAuthMetadata) +const mockProbeApiToken = vi.mocked(probeApiToken) const mockCreateWrappedTwistClient = vi.mocked(createWrappedTwistClient) const mockAttachLoginCommand = vi.mocked(attachLoginCommand) @@ -270,6 +279,101 @@ describe('auth command', () => { }) }) + describe('token view subcommand', () => { + const STORED_METADATA = { + authMode: 'read-write' as const, + authScope: 'user:read', + authUserId: 1, + authUserName: 'Test User', + source: 'secure-store' as const, + } + + let writeSpy: ReturnType + + // Capture the full stdout payload so we can assert pipe-safety: any + // extra preamble/trailer added by a future change would change this + // string and fail the test. + function stdoutPayload(): string { + return writeSpy.mock.calls.map((call: unknown[]) => String(call[0])).join('') + } + + beforeEach(() => { + writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + }) + + afterEach(() => { + writeSpy.mockRestore() + vi.unstubAllEnvs() + }) + + it('prints exactly the stored token to stdout with no envelope (pipe-safe)', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') + mockProbeApiToken.mockResolvedValueOnce({ + token: 'tk_stored_1234567890', + metadata: STORED_METADATA, + }) + + const program = createProgram() + await program.parseAsync(['node', 'tw', 'auth', 'token', 'view']) + + expect(stdoutPayload()).toBe('tk_stored_1234567890') + expect(consoleSpy).not.toHaveBeenCalled() + }) + + it('refuses to print when the env var is set so the CLI does not disclose an unmanaged token', async () => { + vi.stubEnv(TOKEN_ENV_VAR, 'env_token_supplied_externally') + + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'auth', 'token', 'view']), + ).rejects.toHaveProperty('code', 'TOKEN_FROM_ENV') + + expect(mockProbeApiToken).not.toHaveBeenCalled() + expect(stdoutPayload()).toBe('') + }) + + it('throws NOT_AUTHENTICATED when no token is stored', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') + mockProbeApiToken.mockRejectedValueOnce(new NoTokenError()) + + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'auth', 'token', 'view']), + ).rejects.toHaveProperty('code', 'NOT_AUTHENTICATED') + + expect(stdoutPayload()).toBe('') + }) + + it('matches --user against the stored account by numeric id', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') + mockProbeApiToken.mockResolvedValueOnce({ + token: 'tk_stored_1234567890', + metadata: STORED_METADATA, + }) + + const program = createProgram() + await program.parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '1']) + + expect(stdoutPayload()).toBe('tk_stored_1234567890') + expect(consoleSpy).not.toHaveBeenCalled() + }) + + it('rejects --user with ACCOUNT_NOT_FOUND when the ref does not match the stored account', async () => { + vi.stubEnv(TOKEN_ENV_VAR, '') + mockProbeApiToken.mockResolvedValueOnce({ + token: 'tk_stored_1234567890', + metadata: STORED_METADATA, + }) + + const program = createProgram() + await expect( + program.parseAsync(['node', 'tw', 'auth', 'token', 'view', '--user', '999']), + ).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND') + + expect(stdoutPayload()).toBe('') + }) + }) + describe('status subcommand', () => { // All happy-path tests drive a controllable snapshot store directly // into `attachTwistStatusCommand` so the `fetchLive` → diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index 20d329b..958c4b7 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -1,5 +1,7 @@ +import { attachTokenViewCommand } from '@doist/cli-core/auth' import { Command } from 'commander' import { createTwistTokenStore } from '../../lib/auth-provider.js' +import { TOKEN_ENV_VAR } from '../../lib/auth.js' import { attachTwistLoginCommand } from './login.js' import { attachTwistLogoutCommand } from './logout.js' import { attachTwistStatusCommand } from './status.js' @@ -18,7 +20,21 @@ export function registerAuthCommand(program: Command): void { attachTwistLogoutCommand(auth, store) attachTwistStatusCommand(auth, store) - auth.command('token [token]') - .description('Save API token for CLI authentication') + // `token` is a hybrid: the positional `[token]` saves, and the `view` + // subcommand prints. Commander matches subcommand names before the parent + // action, so `tw auth token view` always dispatches to the view path — + // Twist OAuth tokens are opaque random strings so the literal "view" can + // never collide with a real token value. + const tokenCmd = auth + .command('token [token]') + .description('Save API token for CLI authentication (or use a subcommand: `view`)') .action(loginWithToken) + + attachTokenViewCommand(tokenCmd, { + name: 'view', + store, + envVarName: TOKEN_ENV_VAR, + description: + 'Print the stored API token for the active user (or --user ) to stdout for use in scripts', + }) } diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index e33f5ac..d2e8689 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -30,7 +30,8 @@ tw auth status --user # Target a specific stored account (id, id:, tw auth logout # Remove saved token and auth metadata tw auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent) tw auth logout --user # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND -tw auth token --user # Print the saved token for a specific stored account +tw auth token view # Print the saved token to stdout (pipe-safe; refuses if TWIST_API_TOKEN is set) +tw auth token view --user # Print the saved token for a specific stored account tw workspaces # List available workspaces tw workspace use # Set current workspace tw completion install # Install shell completions