Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion skills/twist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ tw auth status --user <ref> # Target a specific stored account (id, id:<n>,
tw auth logout # Remove saved token and auth metadata
tw auth logout --json # Emits `{"ok": true}` (--ndjson is silent)
tw auth logout --user <ref> # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND
tw auth token --user <ref> # 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 <ref> # Print the saved token for a specific stored account
tw workspaces # List available workspaces
tw workspace use <ref> # Set current workspace
tw completion install # Install shell completions
Expand Down
106 changes: 105 additions & 1 deletion src/commands/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ vi.mock('../../lib/auth.js', async (importOriginal) => {
saveApiToken: vi.fn(),
clearApiToken: vi.fn(),
getAuthMetadata: vi.fn(),
probeApiToken: vi.fn(),
}
})

Expand Down Expand Up @@ -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'

Expand All @@ -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)

Expand Down Expand Up @@ -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<typeof vi.spyOn>

// 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` →
Expand Down
20 changes: 18 additions & 2 deletions src/commands/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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, {
Comment thread
scottlovegrove marked this conversation as resolved.
name: 'view',
store,
envVarName: TOKEN_ENV_VAR,
description:
'Print the stored API token for the active user (or --user <ref>) to stdout for use in scripts',
})
}
3 changes: 2 additions & 1 deletion src/lib/skills/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ tw auth status --user <ref> # Target a specific stored account (id, id:<n>,
tw auth logout # Remove saved token and auth metadata
tw auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent)
tw auth logout --user <ref> # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND
tw auth token --user <ref> # 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 <ref> # Print the saved token for a specific stored account
tw workspaces # List available workspaces
tw workspace use <ref> # Set current workspace
tw completion install # Install shell completions
Expand Down
Loading