diff --git a/README.md b/README.md index 27785a1..d8a05ac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index b19dd05..bfc3ad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,8 +10,8 @@ "hasInstallScript": true, "license": "MIT", "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", @@ -138,9 +138,9 @@ } }, "node_modules/@doist/cli-core": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.25.0.tgz", - "integrity": "sha512-exItRfQUuVaJHEZzcu5SbAN+reAe//e3Eb8rLd0BfCEJyrA0znDBURV9SMVWDx1zBv8lnXeBCbwYDM62KAUEfw==", + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/@doist/cli-core/-/cli-core-0.26.0.tgz", + "integrity": "sha512-YWx4uats8WscoROqnD/HUROQelSrnE9vDj02MFUscxl3O/W4vML9R8loJEBD5BUvY+misY1FYEibZHm9aC/Q3A==", "license": "MIT", "dependencies": { "chalk": "5.6.2", @@ -182,9 +182,9 @@ } }, "node_modules/@doist/comms-sdk": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.4.6.tgz", - "integrity": "sha512-Y1IJAsqwxLw7GYZ7cYpaS1YY2lUA7cngemtjcLzB+3/VCY8rLXfyIFqRt9jzI+30yYoq8kBDSPOtQvYoJxXz0w==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@doist/comms-sdk/-/comms-sdk-0.5.0.tgz", + "integrity": "sha512-voccKFXUKwdvquMduJygsL0tiidoZtOSnmIWhzeAgd0omQYHR3hMzbuKqZI3Y9DSsaARGNKz8LUAixYrZyaF8A==", "license": "MIT", "dependencies": { "camelcase": "8.0.0", diff --git a/package.json b/package.json index 2481734..a23cb3b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skills/comms-cli/SKILL.md b/skills/comms-cli/SKILL.md index 9c9d3cd..c1119ee 100644 --- a/skills/comms-cli/SKILL.md +++ b/skills/comms-cli/SKILL.md @@ -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 # Target a specific stored account (id, id:, or display name) -tdc --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it +tdc --user auth # 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 # 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 # 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 # Print the saved OAuth refresh token for a specific stored account tdc account [list|current|use |remove ] # 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 diff --git a/src/commands/auth/auth.test.ts b/src/commands/auth/auth.test.ts index 62aa54e..e7eadba 100644 --- a/src/commands/auth/auth.test.ts +++ b/src/commands/auth/auth.test.ts @@ -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(), @@ -354,6 +355,94 @@ describe('auth command', () => { }) }) + describe('refresh-token view subcommand', () => { + let writeSpy: ReturnType + + 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 @@ -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(() => { @@ -388,10 +480,33 @@ describe('auth command', () => { ) }) + it('threads `tdc --user 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 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: { @@ -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', diff --git a/src/commands/auth/index.ts b/src/commands/auth/index.ts index e971d99..8a29805 100644 --- a/src/commands/auth/index.ts +++ b/src/commands/auth/index.ts @@ -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' @@ -35,4 +35,10 @@ export function registerAuthCommand(program: Command): void { description: 'Print the stored API token for the active user (or --user ) to stdout for use in scripts', }) + + attachRefreshTokenViewCommand(auth, { + store: refAware, + description: + 'Print the stored OAuth refresh token for the active user (or --user ) to stdout for use in scripts', + }) } diff --git a/src/commands/auth/store-wrap.ts b/src/commands/auth/store-wrap.ts index 596487f..4c8bf36 100644 --- a/src/commands/auth/store-wrap.ts +++ b/src/commands/auth/store-wrap.ts @@ -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 auth logout` print `✓ Logged out`. export function withUserRefAware( @@ -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), clear: async (ref?: AccountRef) => { if (ref === undefined && requestedRef !== undefined) { const account = await findAccountInStore(store, requestedRef) diff --git a/src/lib/skills/content.ts b/src/lib/skills/content.ts index de08000..962d1d4 100644 --- a/src/lib/skills/content.ts +++ b/src/lib/skills/content.ts @@ -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 # Target a specific stored account (id, id:, or display name) -tdc --user auth # Equivalent to passing --user after the subcommand; other commands accept the flag but ignore it +tdc --user auth # 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 # 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 # 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 # Print the saved OAuth refresh token for a specific stored account tdc account [list|current|use |remove ] # 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