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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ The base URL is used for OAuth, the SDK, and search. OAuth login supports `comms
```bash
tdc auth status # check if authenticated
tdc auth logout # remove saved token
tdc auth token view # print the stored access token
tdc auth refresh-token view # print the stored OAuth refresh token
```

## Usage
Expand Down
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@
"CHANGELOG.md"
],
"dependencies": {
"@doist/cli-core": "0.25.0",
"@doist/comms-sdk": "0.4.6",
"@doist/cli-core": "0.26.0",
"@doist/comms-sdk": "0.5.0",
"@pnpm/tabtab": "0.5.4",
"chalk": "5.6.2",
"commander": "14.0.3",
Expand Down
4 changes: 3 additions & 1 deletion skills/comms-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ tdc auth token # Save API token manually (prompts securely; s
tdc auth status # Verify authentication + show mode
tdc auth status --json # Full status payload as JSON (--ndjson also supported)
tdc auth status --user <ref> # Target a specific stored account (id, id:<n>, or display name)
tdc --user <ref> auth <status|logout|token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
tdc --user <ref> auth <status|logout|token view|refresh-token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
tdc auth logout # Remove saved token and auth metadata
tdc auth logout --json # Emits `{"ok": true}` (--ndjson is silent)
tdc auth logout --user <ref> # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND
tdc auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set)
tdc auth token view --user <ref> # Print the saved token for a specific stored account
tdc auth refresh-token view # Print the saved OAuth refresh token to stdout (pipe-safe; OAuth logins only)
tdc auth refresh-token view --user <ref> # Print the saved OAuth refresh token for a specific stored account
tdc account [list|current|use <ref>|remove <ref>] # Manage stored accounts; all support --json/--ndjson
# current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"}
tdc auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set
Expand Down
119 changes: 117 additions & 2 deletions src/commands/auth/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const storeMocks = vi.hoisted(() => ({
set: vi.fn(),
clear: vi.fn(),
active: vi.fn(),
activeBundle: vi.fn(),
list: vi.fn(),
setDefault: vi.fn(),
getLastStorageResult: vi.fn(),
Expand Down Expand Up @@ -354,6 +355,94 @@ describe('auth command', () => {
})
})

describe('refresh-token view subcommand', () => {
let writeSpy: ReturnType<typeof vi.spyOn>

function stdoutPayload(): string {
return writeSpy.mock.calls.map((call: unknown[]) => String(call[0])).join('')
}

beforeEach(() => {
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
storeMocks.activeBundle.mockReset()
storeMocks.list.mockReset()
})

afterEach(() => {
writeSpy.mockRestore()
vi.unstubAllEnvs()
})

it('prints exactly the stored OAuth refresh token to stdout with no envelope', async () => {
storeMocks.activeBundle.mockResolvedValue({
account: STORED_ACCOUNT,
bundle: {
accessToken: 'tk_stored_1234567890',
refreshToken: 'rt_stored_1234567890',
},
})

await createProgram().parseAsync(['node', 'tdc', 'auth', 'refresh-token', 'view'])

expect(stdoutPayload()).toBe('rt_stored_1234567890')
expect(consoleSpy).not.toHaveBeenCalled()
})

it('throws AUTH_REFRESH_UNAVAILABLE when the active credential has no refresh token', async () => {
storeMocks.activeBundle.mockResolvedValue({
account: STORED_ACCOUNT,
bundle: { accessToken: 'tk_stored_1234567890' },
})

await expect(
createProgram().parseAsync(['node', 'tdc', 'auth', 'refresh-token', 'view']),
).rejects.toHaveProperty('code', 'AUTH_REFRESH_UNAVAILABLE')

expect(stdoutPayload()).toBe('')
})

it('matches per-command --user against the stored account by id', async () => {
storeMocks.activeBundle.mockResolvedValue({
account: STORED_ACCOUNT,
bundle: {
accessToken: 'tk_stored_1234567890',
refreshToken: 'rt_stored_1234567890',
},
})

await createProgram().parseAsync([
'node',
'tdc',
'auth',
'refresh-token',
'view',
'--user',
'1',
])

expect(storeMocks.activeBundle).toHaveBeenCalledWith('1')
expect(stdoutPayload()).toBe('rt_stored_1234567890')
})

it('rejects per-command --user with ACCOUNT_NOT_FOUND when the ref does not match', async () => {
storeMocks.activeBundle.mockResolvedValue(null)

await expect(
createProgram().parseAsync([
'node',
'tdc',
'auth',
'refresh-token',
'view',
'--user',
'999',
]),
).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND')

expect(stdoutPayload()).toBe('')
})
})

describe('global --user flag', () => {
// Tests simulate `src/index.ts`'s startup: mutate `process.argv` +
// `resetGlobalArgs()` to rebuild the parser cache, then hand
Expand All @@ -364,6 +453,9 @@ describe('auth command', () => {
beforeEach(() => {
originalArgv = process.argv
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
storeMocks.active.mockReset()
storeMocks.activeBundle.mockReset()
storeMocks.list.mockReset()
})

afterEach(() => {
Expand All @@ -388,10 +480,33 @@ describe('auth command', () => {
)
})

it('threads `tdc --user <ref> auth refresh-token view` into store.activeBundle', async () => {
storeMocks.list.mockResolvedValue(STORED_RECORDS)
storeMocks.activeBundle.mockResolvedValue({
account: STORED_ACCOUNT,
bundle: {
accessToken: 'tk_stored_1234567890',
refreshToken: 'rt_stored_1234567890',
},
})
process.argv = ['node', 'tdc', '--user', '1', 'auth', 'refresh-token', 'view']
resetGlobalArgs()

await createProgram().parseAsync(['node', 'tdc', 'auth', 'refresh-token', 'view'])

expect(storeMocks.activeBundle).toHaveBeenCalledWith('1')
expect(writeSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('')).toBe(
'rt_stored_1234567890',
)
})

it('threads `tdc --user <ref> auth status` into the snapshot used by fetchLive', async () => {
vi.stubEnv(TOKEN_ENV_VAR, '')
storeMocks.list.mockResolvedValue(STORED_RECORDS)
storeMocks.active.mockResolvedValue(STORED_SNAPSHOT)
storeMocks.activeBundle.mockResolvedValue({
account: STORED_ACCOUNT,
bundle: { accessToken: 'tk_stored_1234567890' },
})
mockGetApiTokenSnapshot.mockResolvedValue({
token: 'tk_refreshed_1234567890',
account: {
Expand All @@ -408,7 +523,7 @@ describe('auth command', () => {

await createProgram().parseAsync(['node', 'tdc', 'auth', 'status'])

expect(storeMocks.active).toHaveBeenCalledWith('1')
expect(storeMocks.activeBundle).toHaveBeenCalledWith('1')
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith('1')
expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('tk_refreshed_1234567890', {
baseUrl: 'https://comms.staging.todoist.com',
Expand Down
8 changes: 7 additions & 1 deletion src/commands/auth/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { attachTokenViewCommand } from '@doist/cli-core/auth'
import { attachRefreshTokenViewCommand, attachTokenViewCommand } from '@doist/cli-core/auth'
import { Command } from 'commander'
import { createCommsTokenStore } from '../../lib/auth-provider.js'
import { TOKEN_ENV_VAR } from '../../lib/auth.js'
Expand Down Expand Up @@ -35,4 +35,10 @@ export function registerAuthCommand(program: Command): void {
description:
'Print the stored API token for the active user (or --user <ref>) to stdout for use in scripts',
})

attachRefreshTokenViewCommand(auth, {
Comment thread
henningmu marked this conversation as resolved.
store: refAware,
description:
'Print the stored OAuth refresh token for the active user (or --user <ref>) to stdout for use in scripts',
})
}
8 changes: 5 additions & 3 deletions src/commands/auth/store-wrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provide
// cli-core's attachers, which only see per-command `--user`. Explicit ref
// passed by commander wins over the captured global ref.
//
// `active()` passes the substituted ref straight through — cli-core's
// `active()` / `activeBundle()` pass the substituted ref straight through — cli-core's
// `KeyringTokenStore.active` returns `null` on a miss, which the attachers
// surface via `onNotAuthenticated` (status / token view). `clear()` does the
// extra existence check first via `findAccountInStore`, because cli-core's
// surface via `onNotAuthenticated` (status / token view). Bundle-aware attachers
// like `refresh-token view` need the same substitution. `clear()` does the extra
// existence check first via `findAccountInStore`, because cli-core's
// `KeyringTokenStore.clear` is a silent no-op on a non-matching ref and
// would otherwise let `tdc --user <wrong> auth logout` print `✓ Logged out`.
export function withUserRefAware(
Expand All @@ -17,6 +18,7 @@ export function withUserRefAware(
): CommsTokenStore {
return Object.assign(Object.create(store) as CommsTokenStore, {
active: (ref?: AccountRef) => store.active(ref ?? requestedRef),
activeBundle: (ref?: AccountRef) => store.activeBundle(ref ?? requestedRef),
Comment thread
henningmu marked this conversation as resolved.
clear: async (ref?: AccountRef) => {
if (ref === undefined && requestedRef !== undefined) {
const account = await findAccountInStore(store, requestedRef)
Expand Down
4 changes: 3 additions & 1 deletion src/lib/skills/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ tdc auth token # Save API token manually (prompts securely; s
tdc auth status # Verify authentication + show mode
tdc auth status --json # Full status payload as JSON (--ndjson also supported)
tdc auth status --user <ref> # Target a specific stored account (id, id:<n>, or display name)
tdc --user <ref> auth <status|logout|token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
tdc --user <ref> auth <status|logout|token view|refresh-token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
tdc auth logout # Remove saved token and auth metadata
tdc auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent)
tdc auth logout --user <ref> # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND
tdc auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set)
tdc auth token view --user <ref> # Print the saved token for a specific stored account
tdc auth refresh-token view # Print the saved OAuth refresh token to stdout (pipe-safe; OAuth logins only)
tdc auth refresh-token view --user <ref> # Print the saved OAuth refresh token for a specific stored account
tdc account [list|current|use <ref>|remove <ref>] # Manage stored accounts; all support --json/--ndjson
# current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"}
tdc auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set
Expand Down
Loading