Skip to content
Closed
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
42 changes: 38 additions & 4 deletions src/auth/token-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,40 @@ describe('attachTokenViewCommand', () => {
expect(emitted).toBe('tok-xyz\n')
})

it('ignores EPIPE when downstream closes the pipe early', async () => {
const { program, parent: auth } = buildProgram('auth')
const { store } = buildStore({ token: 'redacted-token', account })
const brokenPipe = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })
attachTokenViewCommand<Account>(auth, { store })
stdoutSpy().mockImplementation(((...args: unknown[]) => {
const callback = args.findLast((arg) => typeof arg === 'function') as
| ((error?: Error) => void)
| undefined
queueMicrotask(() => {
process.stdout.emit('error', brokenPipe)
callback?.(brokenPipe)
})
return false
}) as typeof process.stdout.write)

await program.parseAsync(['node', 'cli', 'auth', 'token'])
})

it('propagates non-EPIPE stdout write errors', async () => {
const { program, parent: auth } = buildProgram('auth')
const { store } = buildStore({ token: 'redacted-token', account })
const writeError = Object.assign(new Error('write failed'), { code: 'EIO' })
attachTokenViewCommand<Account>(auth, { store })
stdoutSpy().mockImplementation((() => {
queueMicrotask(() => {
process.stdout.emit('error', writeError)
})
return false
}) as typeof process.stdout.write)

await expect(program.parseAsync(['node', 'cli', 'auth', 'token'])).rejects.toBe(writeError)
})

it('throws CliError(TOKEN_FROM_ENV) when envVarName is set and env is populated', async () => {
vi.stubEnv('TODOIST_API_TOKEN', 'env-token')
const { program, parent: auth } = buildProgram('auth')
Expand All @@ -92,7 +126,7 @@ describe('attachTokenViewCommand', () => {

await program.parseAsync(['node', 'cli', 'auth', 'token'])

expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz')
expect(stdoutSpy().mock.calls[0]?.[0]).toBe('tok-xyz')
})

it('throws CliError(NOT_AUTHENTICATED) when the store is empty', async () => {
Expand All @@ -115,7 +149,7 @@ describe('attachTokenViewCommand', () => {
expect(cmd.name()).toBe('view')

await program.parseAsync(['node', 'cli', 'auth', 'view'])
expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz')
expect(stdoutSpy().mock.calls[0]?.[0]).toBe('tok-xyz')
})

it('returns the new Command so the consumer can chain', () => {
Expand All @@ -134,7 +168,7 @@ describe('attachTokenViewCommand', () => {
await program.parseAsync(['node', 'cli', 'auth', 'token', '--user', 'alan@ingen.com'])

expect(activeSpy).toHaveBeenCalledWith('alan@ingen.com')
expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz')
expect(stdoutSpy().mock.calls[0]?.[0]).toBe('tok-xyz')
})

it('calls store.active(undefined) when --user is absent', async () => {
Expand All @@ -145,7 +179,7 @@ describe('attachTokenViewCommand', () => {
await program.parseAsync(['node', 'cli', 'auth', 'token'])

expect(activeSpy).toHaveBeenCalledWith(undefined)
expect(stdoutSpy()).toHaveBeenCalledWith('tok-xyz')
expect(stdoutSpy().mock.calls[0]?.[0]).toBe('tok-xyz')
})

it('throws ACCOUNT_NOT_FOUND when --user does not match a stored account', async () => {
Expand Down
52 changes: 50 additions & 2 deletions src/auth/token-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,55 @@ export function attachTokenViewCommand<TAccount extends AuthAccount = AuthAccoun
if (!snapshot) {
throw new CliError('NOT_AUTHENTICATED', 'Not signed in.')
}
process.stdout.write(snapshot.token)
if (isStdoutTTY()) process.stdout.write('\n')
await writeStdout(snapshot.token + (isStdoutTTY() ? '\n' : ''))
})
}

function writeStdout(chunk: string): Promise<void> {
return new Promise((resolve, reject) => {
let settled = false

function cleanup(): void {
process.stdout.off('error', onError)
}

function settle(error?: unknown): void {
if (settled) return
settled = true
cleanup()
if (!error || isBrokenPipeError(error)) {
resolve()
return
}
reject(error)
}

function settleErrorFromCallback(error: Error): void {
if (settled) return
setImmediate(() => {
settle(error)
})
}

function onError(error: Error): void {
settle(error)
}

process.stdout.once('error', onError)
try {
process.stdout.write(chunk, (error?: Error | null) => {
if (error) {
settleErrorFromCallback(error)
return
}
settle()
})
} catch (error) {
settle(error)
}
})
}

function isBrokenPipeError(error: unknown): boolean {
return error instanceof Error && (error as { code?: unknown }).code === 'EPIPE'
}
Loading