Skip to content

Commit 04877b7

Browse files
authored
feat(auth): Add auth refresh-token view command (#36)
Co-authored-by: Henning Muszynski <henningmu@users.noreply.github.com>
1 parent 58bd451 commit 04877b7

8 files changed

Lines changed: 147 additions & 18 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,8 @@ The base URL is used for OAuth, the SDK, and search. OAuth login supports `comms
109109
```bash
110110
tdc auth status # check if authenticated
111111
tdc auth logout # remove saved token
112+
tdc auth token view # print the stored access token
113+
tdc auth refresh-token view # print the stored OAuth refresh token
112114
```
113115

114116
## Usage

package-lock.json

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@
5050
"CHANGELOG.md"
5151
],
5252
"dependencies": {
53-
"@doist/cli-core": "0.25.0",
54-
"@doist/comms-sdk": "0.4.6",
53+
"@doist/cli-core": "0.26.0",
54+
"@doist/comms-sdk": "0.5.0",
5555
"@pnpm/tabtab": "0.5.4",
5656
"chalk": "5.6.2",
5757
"commander": "14.0.3",

skills/comms-cli/SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ tdc auth token # Save API token manually (prompts securely; s
2424
tdc auth status # Verify authentication + show mode
2525
tdc auth status --json # Full status payload as JSON (--ndjson also supported)
2626
tdc auth status --user <ref> # Target a specific stored account (id, id:<n>, or display name)
27-
tdc --user <ref> auth <status|logout|token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
27+
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
2828
tdc auth logout # Remove saved token and auth metadata
2929
tdc auth logout --json # Emits `{"ok": true}` (--ndjson is silent)
3030
tdc auth logout --user <ref> # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND
3131
tdc auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set)
3232
tdc auth token view --user <ref> # Print the saved token for a specific stored account
33+
tdc auth refresh-token view # Print the saved OAuth refresh token to stdout (pipe-safe; OAuth logins only)
34+
tdc auth refresh-token view --user <ref> # Print the saved OAuth refresh token for a specific stored account
3335
tdc account [list|current|use <ref>|remove <ref>] # Manage stored accounts; all support --json/--ndjson
3436
# current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"}
3537
tdc auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set

src/commands/auth/auth.test.ts

Lines changed: 117 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const storeMocks = vi.hoisted(() => ({
2020
set: vi.fn(),
2121
clear: vi.fn(),
2222
active: vi.fn(),
23+
activeBundle: vi.fn(),
2324
list: vi.fn(),
2425
setDefault: vi.fn(),
2526
getLastStorageResult: vi.fn(),
@@ -354,6 +355,94 @@ describe('auth command', () => {
354355
})
355356
})
356357

358+
describe('refresh-token view subcommand', () => {
359+
let writeSpy: ReturnType<typeof vi.spyOn>
360+
361+
function stdoutPayload(): string {
362+
return writeSpy.mock.calls.map((call: unknown[]) => String(call[0])).join('')
363+
}
364+
365+
beforeEach(() => {
366+
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
367+
storeMocks.activeBundle.mockReset()
368+
storeMocks.list.mockReset()
369+
})
370+
371+
afterEach(() => {
372+
writeSpy.mockRestore()
373+
vi.unstubAllEnvs()
374+
})
375+
376+
it('prints exactly the stored OAuth refresh token to stdout with no envelope', async () => {
377+
storeMocks.activeBundle.mockResolvedValue({
378+
account: STORED_ACCOUNT,
379+
bundle: {
380+
accessToken: 'tk_stored_1234567890',
381+
refreshToken: 'rt_stored_1234567890',
382+
},
383+
})
384+
385+
await createProgram().parseAsync(['node', 'tdc', 'auth', 'refresh-token', 'view'])
386+
387+
expect(stdoutPayload()).toBe('rt_stored_1234567890')
388+
expect(consoleSpy).not.toHaveBeenCalled()
389+
})
390+
391+
it('throws AUTH_REFRESH_UNAVAILABLE when the active credential has no refresh token', async () => {
392+
storeMocks.activeBundle.mockResolvedValue({
393+
account: STORED_ACCOUNT,
394+
bundle: { accessToken: 'tk_stored_1234567890' },
395+
})
396+
397+
await expect(
398+
createProgram().parseAsync(['node', 'tdc', 'auth', 'refresh-token', 'view']),
399+
).rejects.toHaveProperty('code', 'AUTH_REFRESH_UNAVAILABLE')
400+
401+
expect(stdoutPayload()).toBe('')
402+
})
403+
404+
it('matches per-command --user against the stored account by id', async () => {
405+
storeMocks.activeBundle.mockResolvedValue({
406+
account: STORED_ACCOUNT,
407+
bundle: {
408+
accessToken: 'tk_stored_1234567890',
409+
refreshToken: 'rt_stored_1234567890',
410+
},
411+
})
412+
413+
await createProgram().parseAsync([
414+
'node',
415+
'tdc',
416+
'auth',
417+
'refresh-token',
418+
'view',
419+
'--user',
420+
'1',
421+
])
422+
423+
expect(storeMocks.activeBundle).toHaveBeenCalledWith('1')
424+
expect(stdoutPayload()).toBe('rt_stored_1234567890')
425+
})
426+
427+
it('rejects per-command --user with ACCOUNT_NOT_FOUND when the ref does not match', async () => {
428+
storeMocks.activeBundle.mockResolvedValue(null)
429+
430+
await expect(
431+
createProgram().parseAsync([
432+
'node',
433+
'tdc',
434+
'auth',
435+
'refresh-token',
436+
'view',
437+
'--user',
438+
'999',
439+
]),
440+
).rejects.toHaveProperty('code', 'ACCOUNT_NOT_FOUND')
441+
442+
expect(stdoutPayload()).toBe('')
443+
})
444+
})
445+
357446
describe('global --user flag', () => {
358447
// Tests simulate `src/index.ts`'s startup: mutate `process.argv` +
359448
// `resetGlobalArgs()` to rebuild the parser cache, then hand
@@ -364,6 +453,9 @@ describe('auth command', () => {
364453
beforeEach(() => {
365454
originalArgv = process.argv
366455
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true)
456+
storeMocks.active.mockReset()
457+
storeMocks.activeBundle.mockReset()
458+
storeMocks.list.mockReset()
367459
})
368460

369461
afterEach(() => {
@@ -388,10 +480,33 @@ describe('auth command', () => {
388480
)
389481
})
390482

483+
it('threads `tdc --user <ref> auth refresh-token view` into store.activeBundle', async () => {
484+
storeMocks.list.mockResolvedValue(STORED_RECORDS)
485+
storeMocks.activeBundle.mockResolvedValue({
486+
account: STORED_ACCOUNT,
487+
bundle: {
488+
accessToken: 'tk_stored_1234567890',
489+
refreshToken: 'rt_stored_1234567890',
490+
},
491+
})
492+
process.argv = ['node', 'tdc', '--user', '1', 'auth', 'refresh-token', 'view']
493+
resetGlobalArgs()
494+
495+
await createProgram().parseAsync(['node', 'tdc', 'auth', 'refresh-token', 'view'])
496+
497+
expect(storeMocks.activeBundle).toHaveBeenCalledWith('1')
498+
expect(writeSpy.mock.calls.map((c: unknown[]) => String(c[0])).join('')).toBe(
499+
'rt_stored_1234567890',
500+
)
501+
})
502+
391503
it('threads `tdc --user <ref> auth status` into the snapshot used by fetchLive', async () => {
392504
vi.stubEnv(TOKEN_ENV_VAR, '')
393505
storeMocks.list.mockResolvedValue(STORED_RECORDS)
394-
storeMocks.active.mockResolvedValue(STORED_SNAPSHOT)
506+
storeMocks.activeBundle.mockResolvedValue({
507+
account: STORED_ACCOUNT,
508+
bundle: { accessToken: 'tk_stored_1234567890' },
509+
})
395510
mockGetApiTokenSnapshot.mockResolvedValue({
396511
token: 'tk_refreshed_1234567890',
397512
account: {
@@ -408,7 +523,7 @@ describe('auth command', () => {
408523

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

411-
expect(storeMocks.active).toHaveBeenCalledWith('1')
526+
expect(storeMocks.activeBundle).toHaveBeenCalledWith('1')
412527
expect(mockGetApiTokenSnapshot).toHaveBeenCalledWith('1')
413528
expect(mockCreateWrappedCommsClient).toHaveBeenCalledWith('tk_refreshed_1234567890', {
414529
baseUrl: 'https://comms.staging.todoist.com',

src/commands/auth/index.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { attachTokenViewCommand } from '@doist/cli-core/auth'
1+
import { attachRefreshTokenViewCommand, attachTokenViewCommand } from '@doist/cli-core/auth'
22
import { Command } from 'commander'
33
import { createCommsTokenStore } from '../../lib/auth-provider.js'
44
import { TOKEN_ENV_VAR } from '../../lib/auth.js'
@@ -35,4 +35,10 @@ export function registerAuthCommand(program: Command): void {
3535
description:
3636
'Print the stored API token for the active user (or --user <ref>) to stdout for use in scripts',
3737
})
38+
39+
attachRefreshTokenViewCommand(auth, {
40+
store: refAware,
41+
description:
42+
'Print the stored OAuth refresh token for the active user (or --user <ref>) to stdout for use in scripts',
43+
})
3844
}

src/commands/auth/store-wrap.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ import { findAccountInStore, type CommsTokenStore } from '../../lib/auth-provide
55
// cli-core's attachers, which only see per-command `--user`. Explicit ref
66
// passed by commander wins over the captured global ref.
77
//
8-
// `active()` passes the substituted ref straight through — cli-core's
8+
// `active()` / `activeBundle()` pass the substituted ref straight through — cli-core's
99
// `KeyringTokenStore.active` returns `null` on a miss, which the attachers
10-
// surface via `onNotAuthenticated` (status / token view). `clear()` does the
11-
// extra existence check first via `findAccountInStore`, because cli-core's
10+
// surface via `onNotAuthenticated` (status / token view). Bundle-aware attachers
11+
// like `refresh-token view` need the same substitution. `clear()` does the extra
12+
// existence check first via `findAccountInStore`, because cli-core's
1213
// `KeyringTokenStore.clear` is a silent no-op on a non-matching ref and
1314
// would otherwise let `tdc --user <wrong> auth logout` print `✓ Logged out`.
1415
export function withUserRefAware(
@@ -17,6 +18,7 @@ export function withUserRefAware(
1718
): CommsTokenStore {
1819
return Object.assign(Object.create(store) as CommsTokenStore, {
1920
active: (ref?: AccountRef) => store.active(ref ?? requestedRef),
21+
activeBundle: (ref?: AccountRef) => store.activeBundle(ref ?? requestedRef),
2022
clear: async (ref?: AccountRef) => {
2123
if (ref === undefined && requestedRef !== undefined) {
2224
const account = await findAccountInStore(store, requestedRef)

src/lib/skills/content.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,14 @@ tdc auth token # Save API token manually (prompts securely; s
2828
tdc auth status # Verify authentication + show mode
2929
tdc auth status --json # Full status payload as JSON (--ndjson also supported)
3030
tdc auth status --user <ref> # Target a specific stored account (id, id:<n>, or display name)
31-
tdc --user <ref> auth <status|logout|token view> # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it
31+
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
3232
tdc auth logout # Remove saved token and auth metadata
3333
tdc auth logout --json # Emits \`{"ok": true}\` (--ndjson is silent)
3434
tdc auth logout --user <ref> # Target a specific stored account; mismatched ref errors with ACCOUNT_NOT_FOUND
3535
tdc auth token view # Print the saved token to stdout (pipe-safe; refuses if COMMS_API_TOKEN is set)
3636
tdc auth token view --user <ref> # Print the saved token for a specific stored account
37+
tdc auth refresh-token view # Print the saved OAuth refresh token to stdout (pipe-safe; OAuth logins only)
38+
tdc auth refresh-token view --user <ref> # Print the saved OAuth refresh token for a specific stored account
3739
tdc account [list|current|use <ref>|remove <ref>] # Manage stored accounts; all support --json/--ndjson
3840
# current's payload is {id, label, authMode, authScope, source:"config"} | {source:"env"} | {source:"token-only"}
3941
tdc auth login # Re-running auth login with a different OAuth grant adds a NEW account; default stays pinned unless none was set

0 commit comments

Comments
 (0)