WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)

Fix #17: detect locked keychain and preserve credentials on access failure #3

merged opened by markbennett.ca targeting main from fix/keychain-locked-detection
  • Export KeychainAccessError from session.ts; thrown when getPassword() fails (platform error like locked keychain), not when an entry is missing (undefined)
  • resumeSession() now rethrows KeychainAccessError without clearing metadata, so temporarily locked keychains no longer wipe stored credentials
  • Add ensureAuthenticated(client) to auth-helpers.ts: on KeychainAccessError, attempts to unlock the keychain via security unlock-keychain (macOS only), retries once, then falls back to a clear error message with manual instructions
  • Replace 7 repeated inline auth-check blocks in issue.ts with ensureAuthenticated()
  • Add/update tests for KeychainAccessError propagation and ensureAuthenticated behavior

Co-Authored-By: Claude Sonnet 4.5 noreply@anthropic.com

Labels

None yet.

assignee

None yet.

Participants 1
AT URI
at://did:plc:b2mcbcamkwyznc5fkplwlxbf/sh.tangled.repo.pull/3mekb442mr322
+381 -764
Diff #1
+1 -10
.claude/settings.json
··· 1 1 { 2 2 "permissions": { 3 - "allow": [ 4 - "Bash(npm run test:*)", 5 - "Bash(npm run build:*)", 6 - "Bash(npm test:*)", 7 - "Bash(npm run typecheck:*)", 8 - "Bash(npm run lint:*)", 9 - "Bash(npm run lint:fix:*)", 10 - "Bash(git checkout:*)", 11 - "Bash(npm run format:*)" 12 - ] 3 + "allow": ["Bash(npm run test:*)", "Bash(npm run build:*)", "Bash(npm test:*)"] 13 4 } 14 5 }
-59
CLAUDE.md
··· 1 - # CLAUDE.md 2 - 3 - This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 - 5 - ## Commands 6 - 7 - ```bash 8 - npm run dev -- <args> # Run CLI in development (use this, not ./tangled) 9 - npm run build # Compile TypeScript to dist/ 10 - npm test # Run all tests once 11 - npm run test:watch # Run tests in watch mode 12 - npm run typecheck # Type-check without building (prefer over npx tsc --noEmit) 13 - npm run lint # Check with Biome 14 - npm run lint:fix # Auto-fix lint/format issues 15 - ``` 16 - 17 - Run a single test file: 18 - ```bash 19 - npx vitest run tests/commands/issue.test.ts 20 - ``` 21 - 22 - ## Architecture 23 - 24 - `src/index.ts` is the entry point โ€” it registers all commands and parses `process.argv`. 25 - 26 - ### Layer structure 27 - 28 - - **`src/commands/`** โ€” Commander.js command factories (e.g. `createIssueCommand()`). Each command: resumes session, gets repo context, calls lib functions, outputs results. 29 - - **`src/lib/`** โ€” Business logic with no Commander dependency: 30 - - `api-client.ts` โ€” `TangledApiClient` wraps `AtpAgent`; `isAuthenticated()` is **synchronous** 31 - - `session.ts` โ€” OS keychain storage via `@napi-rs/keyring`; throws `KeychainAccessError` if keychain is inaccessible (not just missing) 32 - - `context.ts` โ€” Infers repo from `git remote` URLs; resolves `RepositoryContext` with owner DID/handle and repo name 33 - - `issues-api.ts` โ€” All issue CRUD; exports `IssueData` (canonical JSON shape), `getCompleteIssueData`, `resolveSequentialNumber` 34 - - **`src/utils/`** โ€” Stateless helpers: 35 - - `auth-helpers.ts` โ€” `requireAuth(client)` throws if unauthenticated (use in lib functions); `ensureAuthenticated(client)` for commands (calls `resumeSession`, exits on failure) 36 - - `validation.ts` โ€” **All** validation logic lives here (Zod schemas + boolean helpers) 37 - - `formatting.ts` โ€” `outputJson<T extends object>(data, fields?)`, `formatDate`, `formatIssueState` 38 - - `at-uri.ts` โ€” Parse/build AT-URIs and repo AT-URIs 39 - - `body-input.ts` โ€” Reads `--body` / `--body-file` / stdin (`-F -`) 40 - - **`src/lexicon/`** โ€” Auto-generated AT Protocol type definitions; regenerate with `npm run codegen` 41 - 42 - ### Key patterns 43 - 44 - **Issue numbering** โ€” Sequential numbers are not stored; they are computed by sorting all issues for a repo by `createdAt` ascending. The 1-based index is the display number. 45 - 46 - **Issue state** โ€” Stored as separate `sh.tangled.repo.issue.state` records. The latest record wins; default is `'open'` if no record exists. 47 - 48 - **JSON output** โ€” All issue sub-commands use `IssueCommand extends Command` (in `issue.ts`) to share a `--json [fields]` option. The canonical field set is: `number, title, body, state, author, createdAt, uri, cid`. Use `getCompleteIssueData()` to populate all fields. 49 - 50 - **Auth flow** โ€” Commands call `client.resumeSession()` directly, then proceed. Lib functions call `requireAuth(client)`. `KeychainAccessError` from `session.ts` propagates through `resumeSession()` without clearing metadata. 51 - 52 - ### Tests 53 - 54 - Tests mirror `src/` under `tests/`. Command tests mock the entire `issuesApi` module: 55 - ```typescript 56 - vi.mock('../../src/lib/issues-api.js'); 57 - // Use importOriginal to preserve exported classes/errors if needed 58 - ``` 59 - `isAuthenticated()` is synchronous โ€” mock as `vi.fn(() => true)`, not `vi.fn(async () => true)`.
+71 -136
src/commands/issue.ts
··· 7 7 closeIssue, 8 8 createIssue, 9 9 deleteIssue, 10 - getCompleteIssueData, 10 + getIssue, 11 11 getIssueState, 12 12 listIssues, 13 13 reopenIssue, 14 - resolveSequentialNumber, 15 14 updateIssue, 16 15 } from '../lib/issues-api.js'; 17 - import type { IssueData } from '../lib/issues-api.js'; 18 16 import { buildRepoAtUri } from '../utils/at-uri.js'; 19 - import { requireAuth } from '../utils/auth-helpers.js'; 17 + import { ensureAuthenticated, requireAuth } from '../utils/auth-helpers.js'; 20 18 import { readBodyInput } from '../utils/body-input.js'; 21 19 import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; 22 20 import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; ··· 89 87 } 90 88 91 89 /** 92 - * A custom subclass of Command with support for adding the common issue JSON flag. 93 - */ 94 - class IssueCommand extends Command { 95 - addIssueJsonOption() { 96 - return this.option( 97 - '--json [fields]', 98 - 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 99 - ); 100 - } 101 - } 102 - 103 - /** 104 90 * Issue view subcommand 105 91 */ 106 92 function createViewCommand(): Command { 107 - return new IssueCommand('view') 93 + return new Command('view') 108 94 .description('View details of a specific issue') 109 95 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 110 - .addIssueJsonOption() 96 + .option( 97 + '--json [fields]', 98 + 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' 99 + ) 111 100 .action(async (issueId: string, options: { json?: string | true }) => { 112 101 try { 113 102 // 1. Validate auth 114 103 const client = createApiClient(); 115 - if (!(await client.resumeSession())) { 116 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 117 - process.exit(1); 118 - } 104 + await ensureAuthenticated(client); 119 105 120 106 // 2. Get repo context 121 107 const context = await getCurrentRepoContext(); ··· 132 118 // 4. Resolve issue ID to URI 133 119 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 134 120 135 - // 5. Fetch complete issue data (record, sequential number, state) 136 - const issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 121 + // 5. Fetch issue details 122 + const issue = await getIssue({ client, issueUri }); 123 + 124 + // 6. Fetch issue state 125 + const state = await getIssueState({ client, issueUri: issue.uri }); 137 126 138 - // 6. Output result 127 + // 7. Output result 139 128 if (options.json !== undefined) { 129 + const issueData = { 130 + title: issue.title, 131 + body: issue.body, 132 + state, 133 + author: issue.author, 134 + createdAt: issue.createdAt, 135 + uri: issue.uri, 136 + cid: issue.cid, 137 + }; 140 138 outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 141 139 return; 142 140 } 143 141 144 - console.log(`\nIssue ${displayId} ${formatIssueState(issueData.state)}`); 145 - console.log(`Title: ${issueData.title}`); 146 - console.log(`Author: ${issueData.author}`); 147 - console.log(`Created: ${formatDate(issueData.createdAt)}`); 142 + console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 143 + console.log(`Title: ${issue.title}`); 144 + console.log(`Author: ${issue.author}`); 145 + console.log(`Created: ${formatDate(issue.createdAt)}`); 148 146 console.log(`Repo: ${context.name}`); 149 - console.log(`URI: ${issueData.uri}`); 147 + console.log(`URI: ${issue.uri}`); 150 148 151 - if (issueData.body) { 149 + if (issue.body) { 152 150 console.log('\nBody:'); 153 - console.log(issueData.body); 151 + console.log(issue.body); 154 152 } 155 153 156 154 console.log(); // Empty line at end ··· 167 165 * Issue edit subcommand 168 166 */ 169 167 function createEditCommand(): Command { 170 - return new IssueCommand('edit') 168 + return new Command('edit') 171 169 .description('Edit an issue title and/or body') 172 170 .argument('<issue-id>', 'Issue number or rkey') 173 171 .option('-t, --title <string>', 'New issue title') 174 172 .option('-b, --body <string>', 'New issue body text') 175 173 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 176 - .addIssueJsonOption() 174 + .option( 175 + '--json [fields]', 176 + 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 177 + ) 177 178 .action( 178 179 async ( 179 180 issueId: string, ··· 188 189 189 190 // 2. Validate auth 190 191 const client = createApiClient(); 191 - if (!(await client.resumeSession())) { 192 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 193 - process.exit(1); 194 - } 192 + await ensureAuthenticated(client); 195 193 196 194 // 3. Get repo context 197 195 const context = await getCurrentRepoContext(); ··· 225 223 226 224 // 9. Output result 227 225 if (options.json !== undefined) { 228 - const [number, state] = await Promise.all([ 229 - resolveSequentialNumber(displayId, updatedIssue.uri, client, repoAtUri), 230 - getIssueState({ client, issueUri: updatedIssue.uri }), 231 - ]); 232 - const issueData: IssueData = { 233 - number, 226 + const issueData = { 234 227 title: updatedIssue.title, 235 228 body: updatedIssue.body, 236 - state, 237 229 author: updatedIssue.author, 238 230 createdAt: updatedIssue.createdAt, 239 231 uri: updatedIssue.uri, ··· 263 255 * Issue close subcommand 264 256 */ 265 257 function createCloseCommand(): Command { 266 - return new IssueCommand('close') 258 + return new Command('close') 267 259 .description('Close an issue') 268 260 .argument('<issue-id>', 'Issue number or rkey') 269 - .addIssueJsonOption() 270 - .action(async (issueId: string, options: { json?: string | true }) => { 261 + .action(async (issueId: string) => { 271 262 try { 272 263 // 1. Validate auth 273 264 const client = createApiClient(); 274 - if (!(await client.resumeSession())) { 275 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 276 - process.exit(1); 277 - } 265 + await ensureAuthenticated(client); 278 266 279 267 // 2. Get repo context 280 268 const context = await getCurrentRepoContext(); ··· 291 279 // 4. Resolve issue ID to URI 292 280 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 293 281 294 - // 5. Fetch complete issue data (state will be 'closed' after operation) 295 - const issueData = await getCompleteIssueData( 296 - client, 297 - issueUri, 298 - displayId, 299 - repoAtUri, 300 - 'closed' 301 - ); 302 - 303 - // 6. Close issue 282 + // 5. Close issue 304 283 await closeIssue({ client, issueUri }); 305 284 306 - // 7. Display success 307 - if (options.json !== undefined) { 308 - outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 309 - } else { 310 - console.log(`โœ“ Issue ${displayId} closed`); 311 - console.log(` Title: ${issueData.title}`); 312 - } 285 + // 6. Display success 286 + console.log(`โœ“ Issue ${displayId} closed`); 313 287 } catch (error) { 314 288 console.error( 315 289 `โœ— Failed to close issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 323 297 * Issue reopen subcommand 324 298 */ 325 299 function createReopenCommand(): Command { 326 - return new IssueCommand('reopen') 300 + return new Command('reopen') 327 301 .description('Reopen a closed issue') 328 302 .argument('<issue-id>', 'Issue number or rkey') 329 - .addIssueJsonOption() 330 - .action(async (issueId: string, options: { json?: string | true }) => { 303 + .action(async (issueId: string) => { 331 304 try { 332 305 // 1. Validate auth 333 306 const client = createApiClient(); 334 - if (!(await client.resumeSession())) { 335 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 336 - process.exit(1); 337 - } 307 + await ensureAuthenticated(client); 338 308 339 309 // 2. Get repo context 340 310 const context = await getCurrentRepoContext(); ··· 351 321 // 4. Resolve issue ID to URI 352 322 const { uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri); 353 323 354 - // 5. Fetch complete issue data (state will be 'open' after operation) 355 - const issueData = await getCompleteIssueData( 356 - client, 357 - issueUri, 358 - displayId, 359 - repoAtUri, 360 - 'open' 361 - ); 362 - 363 - // 6. Reopen issue 324 + // 5. Reopen issue 364 325 await reopenIssue({ client, issueUri }); 365 326 366 - // 7. Display success 367 - if (options.json !== undefined) { 368 - outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 369 - } else { 370 - console.log(`โœ“ Issue ${displayId} reopened`); 371 - console.log(` Title: ${issueData.title}`); 372 - } 327 + // 6. Display success 328 + console.log(`โœ“ Issue ${displayId} reopened`); 373 329 } catch (error) { 374 330 console.error( 375 331 `โœ— Failed to reopen issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 383 339 * Issue delete subcommand 384 340 */ 385 341 function createDeleteCommand(): Command { 386 - return new IssueCommand('delete') 342 + return new Command('delete') 387 343 .description('Delete an issue permanently') 388 344 .argument('<issue-id>', 'Issue number or rkey') 389 345 .option('-f, --force', 'Skip confirmation prompt') 390 - .addIssueJsonOption() 391 - .action(async (issueId: string, options: { force?: boolean; json?: string | true }) => { 346 + .action(async (issueId: string, options: { force?: boolean }) => { 392 347 // 1. Validate auth 393 348 const client = createApiClient(); 394 - if (!(await client.resumeSession())) { 395 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 396 - process.exit(1); 397 - } 349 + await ensureAuthenticated(client); 398 350 399 351 // 2. Get repo context 400 352 const context = await getCurrentRepoContext(); ··· 405 357 process.exit(1); 406 358 } 407 359 408 - // 3. Build repo AT-URI, resolve issue ID, and fetch issue details 360 + // 3. Build repo AT-URI and resolve issue ID 409 361 let issueUri: string; 410 362 let displayId: string; 411 - let issueData: IssueData; 412 363 try { 413 364 const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 414 365 ({ uri: issueUri, displayId } = await resolveIssueUri(issueId, client, repoAtUri)); 415 - issueData = await getCompleteIssueData(client, issueUri, displayId, repoAtUri); 416 366 } catch (error) { 417 367 console.error( 418 368 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 423 373 // 4. Confirm deletion if not --force (outside try so process.exit(0) propagates cleanly) 424 374 if (!options.force) { 425 375 const confirmed = await confirm({ 426 - message: `Are you sure you want to delete issue ${displayId} "${issueData.title}"? This cannot be undone.`, 376 + message: `Are you sure you want to delete issue ${displayId}? This cannot be undone.`, 427 377 default: false, 428 378 }); 429 379 ··· 436 386 // 5. Delete issue 437 387 try { 438 388 await deleteIssue({ client, issueUri }); 439 - if (options.json !== undefined) { 440 - outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 441 - } else { 442 - console.log(`โœ“ Issue ${displayId} deleted`); 443 - console.log(` Title: ${issueData.title}`); 444 - } 389 + console.log(`โœ“ Issue ${displayId} deleted`); 445 390 } catch (error) { 446 391 console.error( 447 392 `โœ— Failed to delete issue: ${error instanceof Error ? error.message : 'Unknown error'}` ··· 473 418 * Issue create subcommand 474 419 */ 475 420 function createCreateCommand(): Command { 476 - return new IssueCommand('create') 421 + return new Command('create') 477 422 .description('Create a new issue') 478 423 .argument('<title>', 'Issue title') 479 424 .option('-b, --body <string>', 'Issue body text') 480 425 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 481 - .addIssueJsonOption() 426 + .option( 427 + '--json [fields]', 428 + 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 429 + ) 482 430 .action( 483 431 async ( 484 432 title: string, ··· 487 435 try { 488 436 // 1. Validate auth 489 437 const client = createApiClient(); 490 - if (!(await client.resumeSession())) { 491 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 492 - process.exit(1); 493 - } 438 + await ensureAuthenticated(client); 494 439 495 440 // 2. Get repo context 496 441 const context = await getCurrentRepoContext(); ··· 524 469 body, 525 470 }); 526 471 527 - // 7. Compute sequential number 528 - const { issues: allIssues } = await listIssues({ client, repoAtUri, limit: 100 }); 529 - const sortedAll = allIssues.sort( 530 - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 531 - ); 532 - const idx = sortedAll.findIndex((i) => i.uri === issue.uri); 533 - const number = idx >= 0 ? idx + 1 : undefined; 534 - 535 - // 8. Output result 472 + // 7. Output result 536 473 if (options.json !== undefined) { 537 - const issueData: IssueData = { 538 - number, 474 + const issueData = { 539 475 title: issue.title, 540 476 body: issue.body, 541 - state: 'open', 542 477 author: issue.author, 543 478 createdAt: issue.createdAt, 544 479 uri: issue.uri, ··· 548 483 return; 549 484 } 550 485 551 - const displayNumber = number !== undefined ? `#${number}` : extractRkey(issue.uri); 552 - console.log(`\nโœ“ Issue ${displayNumber} created`); 486 + const rkey = extractRkey(issue.uri); 487 + console.log(`\nโœ“ Issue created: #${rkey}`); 553 488 console.log(` Title: ${issue.title}`); 554 489 console.log(` URI: ${issue.uri}`); 555 490 } catch (error) { ··· 566 501 * Issue list subcommand 567 502 */ 568 503 function createListCommand(): Command { 569 - return new IssueCommand('list') 504 + return new Command('list') 570 505 .description('List issues for the current repository') 571 506 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 572 - .addIssueJsonOption() 507 + .option( 508 + '--json [fields]', 509 + 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 510 + ) 573 511 .action(async (options: { limit: string; json?: string | true }) => { 574 512 try { 575 513 // 1. Validate auth 576 514 const client = createApiClient(); 577 - if (!(await client.resumeSession())) { 578 - console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 579 - process.exit(1); 580 - } 515 + await ensureAuthenticated(client); 581 516 582 517 // 2. Get repo context 583 518 const context = await getCurrentRepoContext();
+8 -2
src/lib/api-client.ts
··· 1 1 import { AtpAgent } from '@atproto/api'; 2 2 import type { AtpSessionData } from '@atproto/api'; 3 3 import { 4 + KeychainAccessError, 4 5 clearCurrentSessionMetadata, 5 6 deleteSession, 6 7 getCurrentSessionMetadata, ··· 106 107 107 108 return true; 108 109 } catch (error) { 109 - // If resume fails, clear invalid session 110 - await clearCurrentSessionMetadata(); 110 + if (error instanceof KeychainAccessError) { 111 + // Don't clear credentials โ€” keychain may just be temporarily locked 112 + throw error; 113 + } 114 + // Session resume failed (network error, expired refresh token, etc.) 115 + // Don't clear credentials โ€” the error may be transient. The user can 116 + // run "auth login" explicitly if they need to re-authenticate. 111 117 return false; 112 118 } 113 119 }
-66
src/lib/issues-api.ts
··· 424 424 } 425 425 426 426 /** 427 - * Resolve a sequential issue number from a displayId or by scanning the issue list. 428 - * Fast path: if displayId is "#N", return N directly. 429 - * Fallback: fetch all issues, sort oldest-first, return 1-based position. 430 - */ 431 - export async function resolveSequentialNumber( 432 - displayId: string, 433 - issueUri: string, 434 - client: TangledApiClient, 435 - repoAtUri: string 436 - ): Promise<number | undefined> { 437 - const match = displayId.match(/^#(\d+)$/); 438 - if (match) return Number.parseInt(match[1], 10); 439 - 440 - const { issues } = await listIssues({ client, repoAtUri, limit: 100 }); 441 - const sorted = issues.sort( 442 - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 443 - ); 444 - const idx = sorted.findIndex((i) => i.uri === issueUri); 445 - return idx >= 0 ? idx + 1 : undefined; 446 - } 447 - 448 - /** 449 - * Canonical JSON shape for a single issue, used by all issue commands. 450 - */ 451 - export interface IssueData { 452 - number: number | undefined; 453 - title: string; 454 - body?: string; 455 - state: 'open' | 'closed'; 456 - author: string; 457 - createdAt: string; 458 - uri: string; 459 - cid: string; 460 - } 461 - 462 - /** 463 - * Fetch a complete IssueData object ready for JSON output. 464 - * Fetches the issue record and sequential number in parallel. 465 - * If stateOverride is supplied (e.g. 'closed' after a close operation), 466 - * getIssueState is skipped; otherwise the current state is fetched. 467 - */ 468 - export async function getCompleteIssueData( 469 - client: TangledApiClient, 470 - issueUri: string, 471 - displayId: string, 472 - repoAtUri: string, 473 - stateOverride?: 'open' | 'closed' 474 - ): Promise<IssueData> { 475 - const [issue, number] = await Promise.all([ 476 - getIssue({ client, issueUri }), 477 - resolveSequentialNumber(displayId, issueUri, client, repoAtUri), 478 - ]); 479 - const state = stateOverride ?? (await getIssueState({ client, issueUri })); 480 - return { 481 - number, 482 - title: issue.title, 483 - body: issue.body, 484 - state, 485 - author: issue.author, 486 - createdAt: issue.createdAt, 487 - uri: issue.uri, 488 - cid: issue.cid, 489 - }; 490 - } 491 - 492 - /** 493 427 * Reopen a closed issue by creating an open state record 494 428 */ 495 429 export async function reopenIssue(params: ReopenIssueParams): Promise<void> {
+33 -14
src/lib/session.ts
··· 1 + import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; 2 + import { homedir } from 'node:os'; 3 + import { join } from 'node:path'; 1 4 import type { AtpSessionData } from '@atproto/api'; 2 5 import { AsyncEntry } from '@napi-rs/keyring'; 3 6 4 7 const SERVICE_NAME = 'tangled-cli'; 8 + const SESSION_METADATA_PATH = join(homedir(), '.config', 'tangled', 'session.json'); 9 + 10 + export class KeychainAccessError extends Error { 11 + constructor(message: string) { 12 + super(message); 13 + this.name = 'KeychainAccessError'; 14 + } 15 + } 5 16 6 17 export interface SessionMetadata { 7 18 handle: string; ··· 44 55 } 45 56 return JSON.parse(serialized) as AtpSessionData; 46 57 } catch (error) { 47 - throw new Error( 48 - `Failed to load session from keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 58 + throw new KeychainAccessError( 59 + `Cannot access keychain: ${error instanceof Error ? error.message : 'Unknown error'}` 49 60 ); 50 61 } 51 62 } ··· 66 77 } 67 78 68 79 /** 69 - * Store metadata about current session for CLI to track active user 70 - * Uses a special "current" account in keychain 80 + * Store metadata about current session for CLI to track active user. 81 + * Written to a plain file โ€” metadata is not secret and must be readable 82 + * even when the keychain is locked (e.g. after sleep/wake). 71 83 */ 72 84 export async function saveCurrentSessionMetadata(metadata: SessionMetadata): Promise<void> { 73 - const serialized = JSON.stringify(metadata); 74 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 75 - await entry.setPassword(serialized); 85 + await mkdir(join(homedir(), '.config', 'tangled'), { recursive: true }); 86 + await writeFile(SESSION_METADATA_PATH, JSON.stringify(metadata, null, 2), 'utf-8'); 76 87 } 77 88 78 89 /** 79 90 * Get metadata about current active session 80 91 */ 81 92 export async function getCurrentSessionMetadata(): Promise<SessionMetadata | null> { 82 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 83 - const serialized = await entry.getPassword(); 84 - if (!serialized) { 85 - return null; 93 + try { 94 + const content = await readFile(SESSION_METADATA_PATH, 'utf-8'); 95 + return JSON.parse(content) as SessionMetadata; 96 + } catch (error) { 97 + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { 98 + return null; 99 + } 100 + throw error; 86 101 } 87 - return JSON.parse(serialized) as SessionMetadata; 88 102 } 89 103 90 104 /** 91 105 * Clear current session metadata 92 106 */ 93 107 export async function clearCurrentSessionMetadata(): Promise<void> { 94 - const entry = new AsyncEntry(SERVICE_NAME, 'current-session-metadata'); 95 - await entry.deleteCredential(); 108 + try { 109 + await unlink(SESSION_METADATA_PATH); 110 + } catch (error) { 111 + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 112 + throw error; 113 + } 114 + } 96 115 }
+45 -1
src/utils/auth-helpers.ts
··· 1 + import { execSync } from 'node:child_process'; 1 2 import type { TangledApiClient } from '../lib/api-client.js'; 3 + import { KeychainAccessError } from '../lib/session.js'; 2 4 3 5 /** 4 6 * Validate that the client is authenticated and has an active session ··· 9 11 did: string; 10 12 handle: string; 11 13 }> { 12 - if (!(await client.isAuthenticated())) { 14 + if (!client.isAuthenticated()) { 13 15 throw new Error('Must be authenticated. Run "tangled auth login" first.'); 14 16 } 15 17 ··· 20 22 21 23 return session; 22 24 } 25 + 26 + function tryUnlockKeychain(): boolean { 27 + if (process.platform !== 'darwin') return false; 28 + try { 29 + execSync('security unlock-keychain', { stdio: 'inherit' }); 30 + return true; 31 + } catch { 32 + return false; 33 + } 34 + } 35 + 36 + /** 37 + * Resume session and ensure the client is authenticated. 38 + * On macOS, if the keychain is locked, attempts to unlock it interactively 39 + * via `security unlock-keychain` before falling back to an error message. 40 + * Exits the process with a clear error message if authentication fails. 41 + */ 42 + export async function ensureAuthenticated(client: TangledApiClient): Promise<void> { 43 + try { 44 + const authenticated = await client.resumeSession(); 45 + if (!authenticated) { 46 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 47 + process.exit(1); 48 + } 49 + } catch (error) { 50 + if (error instanceof KeychainAccessError) { 51 + const unlocked = tryUnlockKeychain(); 52 + if (unlocked) { 53 + try { 54 + const retried = await client.resumeSession(); 55 + if (retried) return; 56 + } catch { 57 + // fall through to error message 58 + } 59 + } 60 + console.error('โœ— Cannot access keychain. Please unlock your Mac keychain and try again.'); 61 + console.error(' You can unlock it manually with: security unlock-keychain'); 62 + process.exit(1); 63 + } 64 + throw error; 65 + } 66 + }
+4 -4
src/utils/formatting.ts
··· 45 45 * @param data - The data to output (object or array of objects) 46 46 * @param fields - Comma-separated field names to include; omit for all fields 47 47 */ 48 - export function outputJson<T extends object>( 49 - data: T | T[], 48 + export function outputJson( 49 + data: Record<string, unknown> | Record<string, unknown>[], 50 50 fields?: string 51 51 ): void { 52 52 if (fields) { ··· 57 57 if (Array.isArray(data)) { 58 58 console.log( 59 59 JSON.stringify( 60 - data.map((item) => pickFields(item as Record<string, unknown>, fieldList)), 60 + data.map((item) => pickFields(item, fieldList)), 61 61 null, 62 62 2 63 63 ) 64 64 ); 65 65 } else { 66 - console.log(JSON.stringify(pickFields(data as Record<string, unknown>, fieldList), null, 2)); 66 + console.log(JSON.stringify(pickFields(data, fieldList), null, 2)); 67 67 } 68 68 } else { 69 69 console.log(JSON.stringify(data, null, 2));
+55 -233
tests/commands/issue.test.ts
··· 55 55 56 56 // Mock body input 57 57 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 58 - 59 - // Default listIssues mock (needed for sequential number computation after create) 60 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 61 58 }); 62 59 63 60 afterEach(() => { ··· 79 76 80 77 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Test body'); 81 78 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 82 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 83 79 84 80 const command = createIssueCommand(); 85 81 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--body', 'Test body']); ··· 92 88 }); 93 89 94 90 expect(consoleLogSpy).toHaveBeenCalledWith('Creating issue...'); 95 - expect(consoleLogSpy).toHaveBeenCalledWith('\nโœ“ Issue #1 created'); 91 + expect(consoleLogSpy).toHaveBeenCalledWith('\nโœ“ Issue created: #abc123'); 96 92 }); 97 93 }); 98 94 ··· 111 107 112 108 vi.mocked(bodyInput.readBodyInput).mockResolvedValue('Body from file'); 113 109 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 114 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 115 110 116 111 const command = createIssueCommand(); 117 112 await command.parseAsync([ ··· 147 142 148 143 vi.mocked(bodyInput.readBodyInput).mockResolvedValue(undefined); 149 144 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 150 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 151 145 152 146 const command = createIssueCommand(); 153 147 await command.parseAsync(['node', 'test', 'create', 'Test Issue']); ··· 163 157 164 158 describe('authentication required', () => { 165 159 it('should fail when not authenticated', async () => { 166 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 160 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 161 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 162 + process.exit(1); 163 + }); 167 164 168 165 const command = createIssueCommand(); 169 166 ··· 269 266 270 267 it('should output JSON of created issue when --json is passed', async () => { 271 268 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 272 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 273 269 274 270 const command = createIssueCommand(); 275 271 await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); ··· 279 275 280 276 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 281 277 expect(jsonOutput).toMatchObject({ 282 - number: 1, 283 278 title: 'Test Issue', 284 279 body: 'Test body', 285 280 author: 'did:plc:abc123', ··· 290 285 291 286 it('should output filtered JSON when --json with fields is passed', async () => { 292 287 vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 293 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 294 288 295 289 const command = createIssueCommand(); 296 - await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'number,uri']); 290 + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']); 297 291 298 292 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 299 293 expect(jsonOutput).toEqual({ 300 - number: 1, 294 + title: 'Test Issue', 301 295 uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 302 296 }); 303 - expect(jsonOutput).not.toHaveProperty('title'); 304 297 expect(jsonOutput).not.toHaveProperty('body'); 305 298 expect(jsonOutput).not.toHaveProperty('author'); 306 299 }); ··· 419 412 420 413 describe('authentication required', () => { 421 414 it('should fail when not authenticated', async () => { 422 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 415 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 416 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 417 + process.exit(1); 418 + }); 423 419 424 420 const command = createIssueCommand(); 425 421 ··· 615 611 issues: [mockIssue], 616 612 cursor: undefined, 617 613 }); 618 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 619 - number: 1, 620 - title: mockIssue.title, 621 - body: mockIssue.body, 622 - state: 'open', 623 - author: mockIssue.author, 624 - createdAt: mockIssue.createdAt, 625 - uri: mockIssue.uri, 626 - cid: mockIssue.cid, 627 - }); 614 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 615 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 628 616 629 617 const command = createIssueCommand(); 630 618 await command.parseAsync(['node', 'test', 'view', '1']); 631 619 632 - expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 633 - mockClient, 634 - mockIssue.uri, 635 - '#1', 636 - 'at://did:plc:abc123/sh.tangled.repo/xyz789' 637 - ); 620 + expect(issuesApi.getIssue).toHaveBeenCalledWith({ 621 + client: mockClient, 622 + issueUri: mockIssue.uri, 623 + }); 624 + expect(issuesApi.getIssueState).toHaveBeenCalledWith({ 625 + client: mockClient, 626 + issueUri: mockIssue.uri, 627 + }); 638 628 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue #1 [OPEN]'); 639 629 expect(consoleLogSpy).toHaveBeenCalledWith('Title: Test Issue'); 640 630 expect(consoleLogSpy).toHaveBeenCalledWith('\nBody:'); ··· 642 632 }); 643 633 644 634 it('should view issue by rkey', async () => { 645 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 646 - number: undefined, 647 - title: mockIssue.title, 648 - body: mockIssue.body, 649 - state: 'closed', 650 - author: mockIssue.author, 651 - createdAt: mockIssue.createdAt, 652 - uri: mockIssue.uri, 653 - cid: mockIssue.cid, 654 - }); 635 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 636 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 655 637 656 638 const command = createIssueCommand(); 657 639 await command.parseAsync(['node', 'test', 'view', 'issue1']); 658 640 659 - expect(issuesApi.getCompleteIssueData).toHaveBeenCalledWith( 660 - mockClient, 661 - 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 662 - 'issue1', 663 - 'at://did:plc:abc123/sh.tangled.repo/xyz789' 664 - ); 641 + expect(issuesApi.getIssue).toHaveBeenCalledWith({ 642 + client: mockClient, 643 + issueUri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 644 + }); 665 645 expect(consoleLogSpy).toHaveBeenCalledWith('\nIssue issue1 [CLOSED]'); 666 646 }); 667 647 668 648 it('should show issue without body', async () => { 649 + const issueWithoutBody = { ...mockIssue, body: undefined }; 669 650 vi.mocked(issuesApi.listIssues).mockResolvedValue({ 670 - issues: [mockIssue], 651 + issues: [issueWithoutBody], 671 652 cursor: undefined, 672 653 }); 673 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 674 - number: 1, 675 - title: mockIssue.title, 676 - body: undefined, 677 - state: 'open', 678 - author: mockIssue.author, 679 - createdAt: mockIssue.createdAt, 680 - uri: mockIssue.uri, 681 - cid: mockIssue.cid, 682 - }); 654 + vi.mocked(issuesApi.getIssue).mockResolvedValue(issueWithoutBody); 655 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 683 656 684 657 const command = createIssueCommand(); 685 658 await command.parseAsync(['node', 'test', 'view', '1']); ··· 689 662 }); 690 663 691 664 it('should fail when not authenticated', async () => { 692 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 665 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 666 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 667 + process.exit(1); 668 + }); 693 669 694 670 const command = createIssueCommand(); 695 671 await expect(command.parseAsync(['node', 'test', 'view', '1'])).rejects.toThrow( ··· 732 708 issues: [mockIssue], 733 709 cursor: undefined, 734 710 }); 735 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 736 - number: 1, 737 - title: mockIssue.title, 738 - body: mockIssue.body, 739 - state: 'open', 740 - author: mockIssue.author, 741 - createdAt: mockIssue.createdAt, 742 - uri: mockIssue.uri, 743 - cid: mockIssue.cid, 744 - }); 711 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 712 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 745 713 746 714 const command = createIssueCommand(); 747 715 await command.parseAsync(['node', 'test', 'view', '1', '--json']); 748 716 749 717 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 750 718 expect(jsonOutput).toMatchObject({ 751 - number: 1, 752 719 title: 'Test Issue', 753 720 body: 'Issue body', 754 721 state: 'open', ··· 763 730 issues: [mockIssue], 764 731 cursor: undefined, 765 732 }); 766 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 767 - number: 1, 768 - title: mockIssue.title, 769 - body: mockIssue.body, 770 - state: 'closed', 771 - author: mockIssue.author, 772 - createdAt: mockIssue.createdAt, 773 - uri: mockIssue.uri, 774 - cid: mockIssue.cid, 775 - }); 733 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 734 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 776 735 777 736 const command = createIssueCommand(); 778 737 await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); ··· 886 845 }); 887 846 888 847 it('should fail when not authenticated', async () => { 889 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 848 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 849 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 850 + process.exit(1); 851 + }); 890 852 891 853 const command = createIssueCommand(); 892 854 await expect( ··· 906 868 cursor: undefined, 907 869 }); 908 870 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 909 - vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 910 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 911 871 912 872 const command = createIssueCommand(); 913 873 await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 914 874 915 875 const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 916 876 expect(jsonOutput).toMatchObject({ 917 - number: 1, 918 877 title: 'New Title', 919 - state: 'open', 920 878 author: 'did:plc:abc123', 921 879 uri: mockIssue.uri, 922 880 cid: mockIssue.cid, ··· 932 890 cursor: undefined, 933 891 }); 934 892 vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 935 - vi.mocked(issuesApi.resolveSequentialNumber).mockResolvedValue(1); 936 - vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 937 893 938 894 const command = createIssueCommand(); 939 895 await command.parseAsync([ ··· 993 949 }); 994 950 995 951 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 996 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 997 - number: 1, 998 - title: mockIssue.title, 999 - body: undefined, 1000 - state: 'closed', 1001 - author: mockIssue.author, 1002 - createdAt: mockIssue.createdAt, 1003 - uri: mockIssue.uri, 1004 - cid: mockIssue.cid, 1005 - }); 1006 952 }); 1007 953 1008 954 afterEach(() => { ··· 1024 970 issueUri: mockIssue.uri, 1025 971 }); 1026 972 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 closed'); 1027 - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1028 973 }); 1029 974 1030 975 it('should fail when not authenticated', async () => { 1031 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 976 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 977 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 978 + process.exit(1); 979 + }); 1032 980 1033 981 const command = createIssueCommand(); 1034 982 await expect(command.parseAsync(['node', 'test', 'close', '1'])).rejects.toThrow( 1035 983 'process.exit(1)' 1036 984 ); 1037 - }); 1038 - 1039 - describe('JSON output', () => { 1040 - it('should output JSON when --json is passed', async () => { 1041 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1042 - vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1043 - 1044 - const command = createIssueCommand(); 1045 - await command.parseAsync(['node', 'test', 'close', '1', '--json']); 1046 - 1047 - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1048 - expect(jsonOutput).toEqual({ 1049 - number: 1, 1050 - title: 'Test Issue', 1051 - state: 'closed', 1052 - author: mockIssue.author, 1053 - createdAt: mockIssue.createdAt, 1054 - uri: mockIssue.uri, 1055 - cid: mockIssue.cid, 1056 - }); 1057 - }); 1058 - 1059 - it('should output filtered JSON when --json with fields is passed', async () => { 1060 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1061 - vi.mocked(issuesApi.closeIssue).mockResolvedValue(undefined); 1062 - 1063 - const command = createIssueCommand(); 1064 - await command.parseAsync(['node', 'test', 'close', '1', '--json', 'number,state']); 1065 - 1066 - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1067 - expect(jsonOutput).toEqual({ number: 1, state: 'closed' }); 1068 - expect(jsonOutput).not.toHaveProperty('title'); 1069 - expect(jsonOutput).not.toHaveProperty('uri'); 1070 - }); 1071 985 }); 1072 986 }); 1073 987 ··· 1107 1021 }); 1108 1022 1109 1023 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1110 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1111 - number: 1, 1112 - title: mockIssue.title, 1113 - body: undefined, 1114 - state: 'open', 1115 - author: mockIssue.author, 1116 - createdAt: mockIssue.createdAt, 1117 - uri: mockIssue.uri, 1118 - cid: mockIssue.cid, 1119 - }); 1120 1024 }); 1121 1025 1122 1026 afterEach(() => { ··· 1138 1042 issueUri: mockIssue.uri, 1139 1043 }); 1140 1044 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 reopened'); 1141 - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1142 1045 }); 1143 1046 1144 1047 it('should fail when not authenticated', async () => { 1145 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1048 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1049 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 1050 + process.exit(1); 1051 + }); 1146 1052 1147 1053 const command = createIssueCommand(); 1148 1054 await expect(command.parseAsync(['node', 'test', 'reopen', '1'])).rejects.toThrow( 1149 1055 'process.exit(1)' 1150 1056 ); 1151 1057 }); 1152 - 1153 - describe('JSON output', () => { 1154 - it('should output JSON when --json is passed', async () => { 1155 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1156 - vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1157 - 1158 - const command = createIssueCommand(); 1159 - await command.parseAsync(['node', 'test', 'reopen', '1', '--json']); 1160 - 1161 - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1162 - expect(jsonOutput).toEqual({ 1163 - number: 1, 1164 - title: 'Test Issue', 1165 - state: 'open', 1166 - author: mockIssue.author, 1167 - createdAt: mockIssue.createdAt, 1168 - uri: mockIssue.uri, 1169 - cid: mockIssue.cid, 1170 - }); 1171 - }); 1172 - 1173 - it('should output filtered JSON when --json with fields is passed', async () => { 1174 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1175 - vi.mocked(issuesApi.reopenIssue).mockResolvedValue(undefined); 1176 - 1177 - const command = createIssueCommand(); 1178 - await command.parseAsync(['node', 'test', 'reopen', '1', '--json', 'number,state']); 1179 - 1180 - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1181 - expect(jsonOutput).toEqual({ number: 1, state: 'open' }); 1182 - expect(jsonOutput).not.toHaveProperty('title'); 1183 - }); 1184 - }); 1185 1058 }); 1186 1059 1187 1060 describe('issue delete command', () => { ··· 1222 1095 }); 1223 1096 1224 1097 vi.mocked(atUri.buildRepoAtUri).mockResolvedValue('at://did:plc:abc123/sh.tangled.repo/xyz789'); 1225 - vi.mocked(issuesApi.getCompleteIssueData).mockResolvedValue({ 1226 - number: 1, 1227 - title: mockIssue.title, 1228 - body: undefined, 1229 - state: 'open', 1230 - author: mockIssue.author, 1231 - createdAt: mockIssue.createdAt, 1232 - uri: mockIssue.uri, 1233 - cid: mockIssue.cid, 1234 - }); 1235 1098 }); 1236 1099 1237 1100 afterEach(() => { ··· 1253 1116 issueUri: mockIssue.uri, 1254 1117 }); 1255 1118 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1256 - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1257 1119 }); 1258 1120 1259 1121 it('should cancel deletion when user declines confirmation', async () => { ··· 1290 1152 1291 1153 expect(issuesApi.deleteIssue).toHaveBeenCalled(); 1292 1154 expect(consoleLogSpy).toHaveBeenCalledWith('โœ“ Issue #1 deleted'); 1293 - expect(consoleLogSpy).toHaveBeenCalledWith(' Title: Test Issue'); 1294 1155 }); 1295 1156 1296 1157 it('should fail when not authenticated', async () => { 1297 - vi.mocked(mockClient.resumeSession).mockResolvedValue(false); 1158 + vi.mocked(authHelpers.ensureAuthenticated).mockImplementationOnce(async () => { 1159 + console.error('โœ— Not authenticated. Run "tangled auth login" first.'); 1160 + process.exit(1); 1161 + }); 1298 1162 1299 1163 const command = createIssueCommand(); 1300 1164 await expect(command.parseAsync(['node', 'test', 'delete', '1', '--force'])).rejects.toThrow( ··· 1304 1168 expect(consoleErrorSpy).toHaveBeenCalledWith( 1305 1169 'โœ— Not authenticated. Run "tangled auth login" first.' 1306 1170 ); 1307 - }); 1308 - 1309 - describe('JSON output', () => { 1310 - it('should output JSON when --json is passed', async () => { 1311 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1312 - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1313 - 1314 - const command = createIssueCommand(); 1315 - await command.parseAsync(['node', 'test', 'delete', '1', '--force', '--json']); 1316 - 1317 - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1318 - expect(jsonOutput).toEqual({ 1319 - number: 1, 1320 - title: 'Test Issue', 1321 - state: 'open', 1322 - author: mockIssue.author, 1323 - createdAt: mockIssue.createdAt, 1324 - uri: mockIssue.uri, 1325 - cid: mockIssue.cid, 1326 - }); 1327 - }); 1328 - 1329 - it('should output filtered JSON when --json with fields is passed', async () => { 1330 - vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [mockIssue], cursor: undefined }); 1331 - vi.mocked(issuesApi.deleteIssue).mockResolvedValue(undefined); 1332 - 1333 - const command = createIssueCommand(); 1334 - await command.parseAsync([ 1335 - 'node', 1336 - 'test', 1337 - 'delete', 1338 - '1', 1339 - '--force', 1340 - '--json', 1341 - 'number,title', 1342 - ]); 1343 - 1344 - const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 1345 - expect(jsonOutput).toEqual({ number: 1, title: 'Test Issue' }); 1346 - expect(jsonOutput).not.toHaveProperty('uri'); 1347 - expect(jsonOutput).not.toHaveProperty('cid'); 1348 - }); 1349 1171 }); 1350 1172 });
+25 -11
tests/lib/api-client.test.ts
··· 1 1 import type { AtpSessionData } from '@atproto/api'; 2 2 import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 3 import { TangledApiClient } from '../../src/lib/api-client.js'; 4 + import { KeychainAccessError } from '../../src/lib/session.js'; 4 5 import * as sessionModule from '../../src/lib/session.js'; 5 6 import { mockSessionData, mockSessionMetadata } from '../helpers/mock-data.js'; 6 7 ··· 30 31 }; 31 32 }); 32 33 33 - // Mock session management 34 - vi.mock('../../src/lib/session.js', () => ({ 35 - saveSession: vi.fn(), 36 - loadSession: vi.fn(), 37 - deleteSession: vi.fn(), 38 - saveCurrentSessionMetadata: vi.fn(), 39 - getCurrentSessionMetadata: vi.fn(), 40 - clearCurrentSessionMetadata: vi.fn(), 41 - })); 34 + // Mock session management (use importOriginal to preserve KeychainAccessError class) 35 + vi.mock('../../src/lib/session.js', async (importOriginal) => { 36 + const actual = await importOriginal<typeof import('../../src/lib/session.js')>(); 37 + return { 38 + ...actual, 39 + saveSession: vi.fn(), 40 + loadSession: vi.fn(), 41 + deleteSession: vi.fn(), 42 + saveCurrentSessionMetadata: vi.fn(), 43 + getCurrentSessionMetadata: vi.fn(), 44 + clearCurrentSessionMetadata: vi.fn(), 45 + }; 46 + }); 42 47 43 48 describe('TangledApiClient', () => { 44 49 let client: TangledApiClient; ··· 138 143 expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 139 144 }); 140 145 141 - it('should return false and cleanup on resume error', async () => { 146 + it('should return false without clearing metadata on transient resume error', async () => { 142 147 vi.mocked(sessionModule.getCurrentSessionMetadata).mockResolvedValue(mockSessionMetadata); 143 148 vi.mocked(sessionModule.loadSession).mockResolvedValue(mockSessionData); 144 149 ··· 148 153 const resumed = await client.resumeSession(); 149 154 150 155 expect(resumed).toBe(false); 151 - expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).toHaveBeenCalled(); 156 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 157 + }); 158 + 159 + it('should rethrow KeychainAccessError without clearing metadata', async () => { 160 + vi.mocked(sessionModule.getCurrentSessionMetadata).mockRejectedValueOnce( 161 + new KeychainAccessError('Cannot access keychain: locked') 162 + ); 163 + 164 + await expect(client.resumeSession()).rejects.toThrow(KeychainAccessError); 165 + expect(vi.mocked(sessionModule.clearCurrentSessionMetadata)).not.toHaveBeenCalled(); 152 166 }); 153 167 }); 154 168
+1 -225
tests/lib/issues-api.test.ts
··· 4 4 closeIssue, 5 5 createIssue, 6 6 deleteIssue, 7 - getCompleteIssueData, 8 7 getIssue, 9 8 getIssueState, 10 9 listIssues, 11 10 reopenIssue, 12 - resolveSequentialNumber, 13 11 updateIssue, 14 12 } from '../../src/lib/issues-api.js'; 15 13 ··· 30 28 }; 31 29 32 30 return { 33 - isAuthenticated: vi.fn(async () => authenticated), 31 + isAuthenticated: vi.fn(() => authenticated), 34 32 getSession: vi.fn(() => 35 33 authenticated ? { did: 'did:plc:test123', handle: 'test.bsky.social' } : null 36 34 ), ··· 865 863 ).rejects.toThrow('Must be authenticated'); 866 864 }); 867 865 }); 868 - 869 - describe('resolveSequentialNumber', () => { 870 - let mockClient: TangledApiClient; 871 - 872 - beforeEach(() => { 873 - mockClient = createMockClient(true); 874 - }); 875 - 876 - it('should return number directly for #N displayId without an API call (fast path)', async () => { 877 - const result = await resolveSequentialNumber( 878 - '#3', 879 - 'at://did:plc:owner/sh.tangled.repo.issue/issue3', 880 - mockClient, 881 - 'at://did:plc:owner/sh.tangled.repo/my-repo' 882 - ); 883 - expect(result).toBe(3); 884 - }); 885 - 886 - it('should scan issue list and return 1-based position for rkey displayId', async () => { 887 - const mockListRecords = vi.fn().mockResolvedValue({ 888 - data: { 889 - records: [ 890 - { 891 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 892 - cid: 'cid1', 893 - value: { 894 - $type: 'sh.tangled.repo.issue', 895 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 896 - title: 'First', 897 - createdAt: '2024-01-01T00:00:00.000Z', 898 - }, 899 - }, 900 - { 901 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 902 - cid: 'cid2', 903 - value: { 904 - $type: 'sh.tangled.repo.issue', 905 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 906 - title: 'Second', 907 - createdAt: '2024-01-02T00:00:00.000Z', 908 - }, 909 - }, 910 - ], 911 - cursor: undefined, 912 - }, 913 - }); 914 - 915 - vi.mocked(mockClient.getAgent).mockReturnValue({ 916 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 917 - } as never); 918 - 919 - const result = await resolveSequentialNumber( 920 - 'issue-b', 921 - 'at://did:plc:owner/sh.tangled.repo.issue/issue-b', 922 - mockClient, 923 - 'at://did:plc:owner/sh.tangled.repo/my-repo' 924 - ); 925 - expect(result).toBe(2); 926 - }); 927 - 928 - it('should return undefined when issue URI not found in list', async () => { 929 - const mockListRecords = vi.fn().mockResolvedValue({ 930 - data: { 931 - records: [ 932 - { 933 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue-a', 934 - cid: 'cid1', 935 - value: { 936 - $type: 'sh.tangled.repo.issue', 937 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 938 - title: 'First', 939 - createdAt: '2024-01-01T00:00:00.000Z', 940 - }, 941 - }, 942 - ], 943 - cursor: undefined, 944 - }, 945 - }); 946 - 947 - vi.mocked(mockClient.getAgent).mockReturnValue({ 948 - com: { atproto: { repo: { listRecords: mockListRecords } } }, 949 - } as never); 950 - 951 - const result = await resolveSequentialNumber( 952 - 'nonexistent', 953 - 'at://did:plc:owner/sh.tangled.repo.issue/nonexistent', 954 - mockClient, 955 - 'at://did:plc:owner/sh.tangled.repo/my-repo' 956 - ); 957 - expect(result).toBeUndefined(); 958 - }); 959 - }); 960 - 961 - describe('getCompleteIssueData', () => { 962 - let mockClient: TangledApiClient; 963 - 964 - beforeEach(() => { 965 - mockClient = createMockClient(true); 966 - }); 967 - 968 - it('should return all fields including fetched state', async () => { 969 - const mockGetRecord = vi.fn().mockResolvedValue({ 970 - data: { 971 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 972 - cid: 'cid1', 973 - value: { 974 - $type: 'sh.tangled.repo.issue', 975 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 976 - title: 'Test Issue', 977 - body: 'Test body', 978 - createdAt: '2024-01-01T00:00:00.000Z', 979 - }, 980 - }, 981 - }); 982 - 983 - // getIssueState uses listRecords on the state collection 984 - const mockListRecords = vi.fn().mockResolvedValue({ 985 - data: { 986 - records: [ 987 - { 988 - uri: 'at://did:plc:owner/sh.tangled.repo.issue.state/s1', 989 - cid: 'scid1', 990 - value: { 991 - issue: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 992 - state: 'sh.tangled.repo.issue.state.closed', 993 - }, 994 - }, 995 - ], 996 - }, 997 - }); 998 - 999 - vi.mocked(mockClient.getAgent).mockReturnValue({ 1000 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 1001 - } as never); 1002 - 1003 - const result = await getCompleteIssueData( 1004 - mockClient, 1005 - 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1006 - '#1', // fast-path for number โ€” no listRecords call for issues 1007 - 'at://did:plc:owner/sh.tangled.repo/my-repo' 1008 - ); 1009 - 1010 - expect(result).toEqual({ 1011 - number: 1, 1012 - title: 'Test Issue', 1013 - body: 'Test body', 1014 - state: 'closed', 1015 - author: 'did:plc:owner', 1016 - createdAt: '2024-01-01T00:00:00.000Z', 1017 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1018 - cid: 'cid1', 1019 - }); 1020 - }); 1021 - 1022 - it('should use stateOverride and skip the getIssueState network call', async () => { 1023 - const mockGetRecord = vi.fn().mockResolvedValue({ 1024 - data: { 1025 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1026 - cid: 'cid1', 1027 - value: { 1028 - $type: 'sh.tangled.repo.issue', 1029 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 1030 - title: 'Test Issue', 1031 - createdAt: '2024-01-01T00:00:00.000Z', 1032 - }, 1033 - }, 1034 - }); 1035 - 1036 - const mockListRecords = vi.fn(); 1037 - vi.mocked(mockClient.getAgent).mockReturnValue({ 1038 - com: { atproto: { repo: { getRecord: mockGetRecord, listRecords: mockListRecords } } }, 1039 - } as never); 1040 - 1041 - const result = await getCompleteIssueData( 1042 - mockClient, 1043 - 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1044 - '#2', 1045 - 'at://did:plc:owner/sh.tangled.repo/my-repo', 1046 - 'closed' 1047 - ); 1048 - 1049 - expect(result.number).toBe(2); 1050 - expect(result.state).toBe('closed'); 1051 - expect(mockListRecords).not.toHaveBeenCalled(); 1052 - }); 1053 - 1054 - it('should return undefined body and default open state when issue has no body or state records', async () => { 1055 - const mockGetRecord = vi.fn().mockResolvedValue({ 1056 - data: { 1057 - uri: 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1058 - cid: 'cid1', 1059 - value: { 1060 - $type: 'sh.tangled.repo.issue', 1061 - repo: 'at://did:plc:owner/sh.tangled.repo/my-repo', 1062 - title: 'No body issue', 1063 - createdAt: '2024-01-01T00:00:00.000Z', 1064 - }, 1065 - }, 1066 - }); 1067 - 1068 - vi.mocked(mockClient.getAgent).mockReturnValue({ 1069 - com: { 1070 - atproto: { 1071 - repo: { 1072 - getRecord: mockGetRecord, 1073 - listRecords: vi.fn().mockResolvedValue({ data: { records: [] } }), 1074 - }, 1075 - }, 1076 - }, 1077 - } as never); 1078 - 1079 - const result = await getCompleteIssueData( 1080 - mockClient, 1081 - 'at://did:plc:owner/sh.tangled.repo.issue/issue1', 1082 - '#1', 1083 - 'at://did:plc:owner/sh.tangled.repo/my-repo' 1084 - ); 1085 - 1086 - expect(result.body).toBeUndefined(); 1087 - expect(result.state).toBe('open'); 1088 - }); 1089 - });
+31
tests/lib/session.test.ts
··· 33 33 }; 34 34 }); 35 35 36 + // Mock node:fs/promises for metadata file storage 37 + const mockFileStorage = new Map<string, string>(); 38 + 39 + vi.mock('node:fs/promises', () => ({ 40 + mkdir: vi.fn().mockResolvedValue(undefined), 41 + writeFile: vi.fn().mockImplementation(async (path: string, content: string) => { 42 + mockFileStorage.set(path as string, content); 43 + }), 44 + readFile: vi.fn().mockImplementation(async (path: string) => { 45 + const content = mockFileStorage.get(path as string); 46 + if (content === undefined) { 47 + const err = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { 48 + code: 'ENOENT', 49 + }); 50 + throw err; 51 + } 52 + return content; 53 + }), 54 + unlink: vi.fn().mockImplementation(async (path: string) => { 55 + if (!mockFileStorage.has(path as string)) { 56 + const err = Object.assign(new Error(`ENOENT: no such file or directory, unlink '${path}'`), { 57 + code: 'ENOENT', 58 + }); 59 + throw err; 60 + } 61 + mockFileStorage.delete(path as string); 62 + }), 63 + })); 64 + 36 65 describe('Session Management', () => { 37 66 beforeEach(() => { 38 67 // Clear mock storage before each test 39 68 mockKeyringStorage.clear(); 69 + mockFileStorage.clear(); 40 70 vi.clearAllMocks(); 41 71 }); 42 72 43 73 afterEach(() => { 44 74 // Clean up after each test 45 75 mockKeyringStorage.clear(); 76 + mockFileStorage.clear(); 46 77 }); 47 78 48 79 describe('saveSession', () => {
+107 -3
tests/utils/auth-helpers.test.ts
··· 1 - import { describe, expect, it, vi } from 'vitest'; 1 + import { execSync } from 'node:child_process'; 2 + import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 3 import type { TangledApiClient } from '../../src/lib/api-client.js'; 3 - import { requireAuth } from '../../src/utils/auth-helpers.js'; 4 + import { KeychainAccessError } from '../../src/lib/session.js'; 5 + import { ensureAuthenticated, requireAuth } from '../../src/utils/auth-helpers.js'; 6 + 7 + vi.mock('node:child_process', () => ({ 8 + execSync: vi.fn(), 9 + })); 4 10 5 11 // Mock API client factory 6 12 const createMockClient = ( ··· 8 14 session: { did: string; handle: string } | null 9 15 ): TangledApiClient => { 10 16 return { 11 - isAuthenticated: vi.fn(async () => authenticated), 17 + isAuthenticated: vi.fn(() => authenticated), 12 18 getSession: vi.fn(() => session), 13 19 } as unknown as TangledApiClient; 14 20 }; ··· 37 43 await expect(requireAuth(mockClient)).rejects.toThrow('No active session found'); 38 44 }); 39 45 }); 46 + 47 + describe('ensureAuthenticated', () => { 48 + // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 49 + let mockExit: any; 50 + // biome-ignore lint/suspicious/noExplicitAny: spy instance types vary by platform signature 51 + let mockConsoleError: any; 52 + 53 + beforeEach(() => { 54 + mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { 55 + throw new Error('process.exit called'); 56 + }); 57 + mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); 58 + vi.mocked(execSync).mockReset(); 59 + }); 60 + 61 + afterEach(() => { 62 + mockExit.mockRestore(); 63 + mockConsoleError.mockRestore(); 64 + }); 65 + 66 + it('should return normally when resumeSession succeeds', async () => { 67 + const mockClient = { 68 + resumeSession: vi.fn().mockResolvedValue(true), 69 + } as unknown as TangledApiClient; 70 + 71 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 72 + expect(mockExit).not.toHaveBeenCalled(); 73 + }); 74 + 75 + it('should exit with error when not authenticated', async () => { 76 + const mockClient = { 77 + resumeSession: vi.fn().mockResolvedValue(false), 78 + } as unknown as TangledApiClient; 79 + 80 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 81 + expect(mockConsoleError).toHaveBeenCalledWith( 82 + 'โœ— Not authenticated. Run "tangled auth login" first.' 83 + ); 84 + expect(mockExit).toHaveBeenCalledWith(1); 85 + }); 86 + 87 + it('should unlock keychain and retry when KeychainAccessError is thrown', async () => { 88 + const mockClient = { 89 + resumeSession: vi 90 + .fn() 91 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 92 + .mockResolvedValueOnce(true), 93 + } as unknown as TangledApiClient; 94 + 95 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 96 + 97 + await expect(ensureAuthenticated(mockClient)).resolves.toBeUndefined(); 98 + expect(execSync).toHaveBeenCalledWith('security unlock-keychain', { stdio: 'inherit' }); 99 + expect(mockExit).not.toHaveBeenCalled(); 100 + }); 101 + 102 + it('should exit with keychain error when unlock fails', async () => { 103 + const mockClient = { 104 + resumeSession: vi.fn().mockRejectedValue(new KeychainAccessError('locked')), 105 + } as unknown as TangledApiClient; 106 + 107 + vi.mocked(execSync).mockImplementation(() => { 108 + throw new Error('unlock failed'); 109 + }); 110 + 111 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 112 + expect(mockConsoleError).toHaveBeenCalledWith( 113 + 'โœ— Cannot access keychain. Please unlock your Mac keychain and try again.' 114 + ); 115 + expect(mockExit).toHaveBeenCalledWith(1); 116 + }); 117 + 118 + it('should exit with keychain error when unlock succeeds but retry fails', async () => { 119 + const mockClient = { 120 + resumeSession: vi 121 + .fn() 122 + .mockRejectedValueOnce(new KeychainAccessError('locked')) 123 + .mockRejectedValueOnce(new KeychainAccessError('still locked')), 124 + } as unknown as TangledApiClient; 125 + 126 + vi.mocked(execSync).mockReturnValue(Buffer.from('')); 127 + 128 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('process.exit called'); 129 + expect(mockConsoleError).toHaveBeenCalledWith( 130 + 'โœ— Cannot access keychain. Please unlock your Mac keychain and try again.' 131 + ); 132 + expect(mockExit).toHaveBeenCalledWith(1); 133 + }); 134 + 135 + it('should rethrow unexpected errors', async () => { 136 + const mockClient = { 137 + resumeSession: vi.fn().mockRejectedValue(new Error('unexpected network error')), 138 + } as unknown as TangledApiClient; 139 + 140 + await expect(ensureAuthenticated(mockClient)).rejects.toThrow('unexpected network error'); 141 + expect(mockExit).not.toHaveBeenCalled(); 142 + }); 143 + });

History

4 rounds 1 comment
sign up or login to add to the discussion
5 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
chore: allow git fetch in Claude permissions
Formatting
Update pipeline cloning
expand 1 comment

Alright, I haven't seen the error for a while now. Will try this and create a new issue if it comes up again!

pull request successfully merged
4 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
chore: allow git fetch in Claude permissions
Formatting
expand 0 comments
2 commits
expand
Fix #17: detect locked keychain and preserve credentials on access failure
Move session metadata from keychain to plain file to prevent credential loss
expand 0 comments
1 commit
expand
Fix #17: detect locked keychain and preserve credentials on access failure
expand 0 comments