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
1 change: 1 addition & 0 deletions skills/twist-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ tw auth token # Save API token manually (prompts securely; sc
tw auth status # Verify authentication + show mode
tw auth status --json # Full status payload as JSON (--ndjson also supported)
tw auth status --user <ref> # Target a specific stored account (id, id:<n>, or display name)
tw --user <ref> auth <status|logout|token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
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
Expand Down
98 changes: 90 additions & 8 deletions src/commands/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
saveApiToken,
TOKEN_ENV_VAR,
} from '../../lib/auth.js'
import { resetGlobalArgs } from '../../lib/global-args.js'
import { registerAuthCommand } from './index.js'
import { attachTwistStatusCommand } from './status.js'

Expand Down Expand Up @@ -113,6 +114,14 @@ describe('auth command', () => {
errorSpy.mockRestore()
})

const STORED_METADATA = {
authMode: 'read-write' as const,
authScope: 'user:read',
authUserId: 1,
authUserName: 'Test User',
source: 'secure-store' as const,
}

describe('token subcommand', () => {
it('successfully saves a token', async () => {
const program = createProgram()
Expand Down Expand Up @@ -280,14 +289,6 @@ 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
Expand Down Expand Up @@ -374,6 +375,87 @@ describe('auth command', () => {
})
})

describe('global --user flag', () => {
Comment thread
scottlovegrove marked this conversation as resolved.
// Tests simulate `src/index.ts`'s startup: mutate `process.argv` +
Comment thread
scottlovegrove marked this conversation as resolved.
// `resetGlobalArgs()` to rebuild the parser cache, then hand
// commander the already-stripped argv (no `--user` token).
let originalArgv: string[]
let writeSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
originalArgv = process.argv
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
})

afterEach(() => {
process.argv = originalArgv
resetGlobalArgs()
writeSpy.mockRestore()
vi.unstubAllEnvs()
})

it('threads `tw --user <ref> auth token view` into store.active', async () => {
vi.stubEnv(TOKEN_ENV_VAR, '')
mockProbeApiToken.mockResolvedValueOnce({
token: 'tk_stored_1234567890',
metadata: STORED_METADATA,
})
process.argv = ['node', 'tw', '--user', '1', 'auth', 'token', 'view']
resetGlobalArgs()

const program = createProgram()
await program.parseAsync(['node', 'tw', 'auth', 'token', 'view'])

expect(writeSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('')).toBe(
'tk_stored_1234567890',
)
})

it('threads `tw --user <ref> auth status` into the snapshot used by fetchLive', async () => {
vi.stubEnv(TOKEN_ENV_VAR, '')
mockProbeApiToken.mockResolvedValueOnce({
token: 'tk_stored_1234567890',
metadata: STORED_METADATA,
})
mockCreateWrappedTwistClient.mockReturnValue({
users: { getSessionUser: vi.fn().mockResolvedValue(TEST_USER) },
// biome-ignore lint/suspicious/noExplicitAny: only the methods used in this test matter
} as any)
mockGetAuthMetadata.mockResolvedValue({
authMode: 'read-write',
authScope: 'user:read',
source: 'config',
})
process.argv = ['node', 'tw', '--user', '1', 'auth', 'status']
resetGlobalArgs()

const program = createProgram()
await program.parseAsync(['node', 'tw', 'auth', 'status'])

expect(mockCreateWrappedTwistClient).toHaveBeenCalledWith('tk_stored_1234567890')
expect(consoleSpy).toHaveBeenCalledWith('✓ Authenticated')
})

it('blocks `tw --user <wrong> auth logout` with ACCOUNT_NOT_FOUND before touching storage', async () => {
// cli-core's logout swallows the active() error when cmd.user is
// undefined (true for the global form), so the typed miss must
// also bubble out of clear() — re-armed mock covers both probes.
mockProbeApiToken.mockResolvedValue({
token: 'tk_stored_1234567890',
metadata: STORED_METADATA,
})
process.argv = ['node', 'tw', '--user', '999', 'auth', 'logout']
resetGlobalArgs()

const program = createProgram()
await expect(
program.parseAsync(['node', 'tw', 'auth', 'logout']),
).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND')

expect(mockClearApiToken).not.toHaveBeenCalled()
})
})

describe('status subcommand', () => {
// All happy-path tests drive a controllable snapshot store directly
// into `attachTwistStatusCommand` so the `fetchLive` →
Expand Down
13 changes: 6 additions & 7 deletions src/commands/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,22 @@ 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 { getRequestedUserRef } from '../../lib/global-args.js'
import { attachTwistLoginCommand } from './login.js'
import { attachTwistLogoutCommand } from './logout.js'
import { attachTwistStatusCommand } from './status.js'
import { withUserRefAware } from './store-wrap.js'
import { loginWithToken } from './token.js'

export function registerAuthCommand(program: Command): void {
const auth = program.command('auth').description('Manage authentication')

// Shared store instance: login stashes the post-`set` storage result for
// its success handler, logout reads the post-`clear` result for the same
// keyring-fallback warning surface. Status uses `active()` as the
// authenticated-snapshot gate.
const store = createTwistTokenStore()
const refAware = withUserRefAware(store, getRequestedUserRef())

attachTwistLoginCommand(auth, store)
attachTwistLogoutCommand(auth, store)
attachTwistStatusCommand(auth, store)
attachTwistLogoutCommand(auth, refAware)
attachTwistStatusCommand(auth, refAware)

// `token` is a hybrid: the positional `[token]` saves, and the `view`
// subcommand prints. Commander matches subcommand names before the parent
Expand All @@ -32,7 +31,7 @@ export function registerAuthCommand(program: Command): void {

attachTokenViewCommand(tokenCmd, {
name: 'view',
store,
store: refAware,
envVarName: TOKEN_ENV_VAR,
description:
'Print the stored API token for the active user (or --user <ref>) to stdout for use in scripts',
Expand Down
15 changes: 15 additions & 0 deletions src/commands/auth/store-wrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { AccountRef } from '@doist/cli-core/auth'
import type { TwistTokenStore } from '../../lib/auth-provider.js'

// Bridge the global `tw --user <ref>` (stripped by `src/index.ts`) into
// cli-core's attachers, which only see per-command `--user`. Explicit ref
// passed by commander wins over the captured global ref.
export function withUserRefAware(
store: TwistTokenStore,
requestedRef: AccountRef | undefined,
): TwistTokenStore {
return Object.assign(Object.create(store) as TwistTokenStore, {
active: (ref?: AccountRef) => store.active(ref ?? requestedRef),
clear: (ref?: AccountRef) => store.clear(ref ?? requestedRef),
})
}
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
#!/usr/bin/env node

import { stripUserFlag } from '@doist/cli-core'
import { type Command, program } from 'commander'
import pkg from '../package.json' with { type: 'json' }
import { BaseCliError } from './lib/errors.js'
import { isJsonMode, isNdjsonMode } from './lib/global-args.js'
import { getRequestedUserRef, isJsonMode, isNdjsonMode } from './lib/global-args.js'
import { preloadMarkdown } from './lib/markdown.js'
import { formatError, formatErrorJson } from './lib/output.js'
import { startEarlySpinner, stopEarlySpinner } from './lib/spinner.js'
Expand Down Expand Up @@ -112,6 +113,11 @@ for (const [name, [description]] of Object.entries(commands)) {
}
}

// Commander has no root `--user` option. Warm the global-args cache off
// the original argv before stripping, then hand commander the stripped form.
getRequestedUserRef()
process.argv = [process.argv[0], process.argv[1], ...stripUserFlag(process.argv.slice(2))]

// completion-server needs the command tree to walk for completions.
// Only load the completion module + the specific command being completed
// (extracted from COMP_LINE) to keep startup fast.
Expand Down
5 changes: 5 additions & 0 deletions src/lib/global-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,11 @@ export function isNdjsonMode(): boolean {
return store.get().ndjson
}

/** Pre-subcommand `tw --user <ref>` (see `stripUserFlag` in `src/index.ts`). */
export function getRequestedUserRef(): string | undefined {
return store.get().user
}

export function isNonInteractive(): boolean {
const args = store.get()
if (args.interactive) return false
Expand Down
1 change: 1 addition & 0 deletions src/lib/skills/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ tw auth token # Save API token manually (prompts securely; sc
tw auth status # Verify authentication + show mode
tw auth status --json # Full status payload as JSON (--ndjson also supported)
tw auth status --user <ref> # Target a specific stored account (id, id:<n>, or display name)
tw --user <ref> auth <status|logout|token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
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
Expand Down
Loading