@pugi/cli 0.1.0-alpha.6 → 0.1.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,6 +14,7 @@ import { FileReadCache } from '../core/file-cache.js';
14
14
  import { resolveWorkspacePath } from '../core/path-security.js';
15
15
  import { globTool, grepTool, readTool } from '../tools/file-tools.js';
16
16
  import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
17
+ import { webFetchTool } from '../tools/web-fetch.js';
17
18
  import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
18
19
  import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
19
20
  import { PUGI_TAGLINE } from '@pugi/personas';
@@ -34,7 +35,7 @@ import { runBudgetCommand } from './commands/budget.js';
34
35
  * packages/pugi-sdk/package.json); the publish workflow validates the
35
36
  * three are in lockstep.
36
37
  */
37
- const PUGI_CLI_VERSION = '0.1.0-alpha.6';
38
+ const PUGI_CLI_VERSION = '0.1.0-alpha.7';
38
39
  const handlers = {
39
40
  accounts,
40
41
  build: runEngineTask('build_task'),
@@ -59,6 +60,7 @@ const handlers = {
59
60
  sync,
60
61
  undo: dispatchUndo,
61
62
  version,
63
+ web: dispatchWeb,
62
64
  whoami,
63
65
  };
64
66
  async function dispatchConfig(args, flags, _session) {
@@ -87,6 +89,47 @@ async function dispatchBudget(args, flags, _session) {
87
89
  writeOutput: (payload, text) => writeOutput(flags, payload, text),
88
90
  });
89
91
  }
92
+ /**
93
+ * `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
94
+ *
95
+ * One-shot fetch + Markdown convert + print to stdout. The REPL slash
96
+ * `/web <url>` goes through ReplSession; this surface is for non-REPL
97
+ * pipelines (CI, scripts, `pugi web ... | pbcopy`). Gated by either
98
+ * `--allow-fetch` on this invocation or `web.fetch.enabled` in
99
+ * `.pugi/settings.json`.
100
+ */
101
+ async function dispatchWeb(args, flags, _session) {
102
+ const url = args[0];
103
+ if (!url) {
104
+ writeOutput(flags, { ok: false, error: 'Usage: pugi web <url> [--allow-fetch]' }, 'Usage: pugi web <url> [--allow-fetch]');
105
+ process.exitCode = 2;
106
+ return;
107
+ }
108
+ // Malformed `.pugi/settings.json` must not crash the dispatch — the
109
+ // fetch is gated default-off so we fail safe: refuse with a clear
110
+ // error and let the operator repair the file.
111
+ let settings;
112
+ try {
113
+ settings = loadSettings(process.cwd());
114
+ }
115
+ catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ const result = {
118
+ ok: false,
119
+ error: `Failed to load .pugi/settings.json (${message}). web_fetch refused.`,
120
+ };
121
+ writeOutput(flags, result, `web_fetch refused: ${result.error}`);
122
+ process.exitCode = 1;
123
+ return;
124
+ }
125
+ const result = await webFetchTool({ url }, { settings, allowFetch: flags.allowFetch });
126
+ if (!result.ok) {
127
+ writeOutput(flags, result, `web_fetch refused: ${result.error}`);
128
+ process.exitCode = 1;
129
+ return;
130
+ }
131
+ writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
132
+ }
90
133
  export async function runCli(argv) {
91
134
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
92
135
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
@@ -99,12 +142,33 @@ export async function runCli(argv) {
99
142
  if (isBareInvocation && isInteractive(flags)) {
100
143
  const runtimeConfig = resolveRuntimeConfig();
101
144
  if (runtimeConfig) {
145
+ // The REPL session reads PUGI_ALLOW_FETCH from env to decide
146
+ // whether to honor `/web <url>` without the settings flag.
147
+ // Propagating via env keeps the session module transport-free.
148
+ if (flags.allowFetch)
149
+ process.env.PUGI_ALLOW_FETCH = '1';
150
+ // α6.2: peek the npm registry for a newer @pugi/cli before
151
+ // mounting Ink. Wrapped in a try/catch belt-and-braces even
152
+ // though `checkForUpdate` already swallows every failure mode —
153
+ // a thrown bug here must never block REPL startup.
154
+ const { checkForUpdate } = await import('./update-check.js');
155
+ let updateBanner = null;
156
+ try {
157
+ updateBanner = await checkForUpdate({
158
+ installed: PUGI_CLI_VERSION,
159
+ cliSkip: flags.noUpdateCheck,
160
+ });
161
+ }
162
+ catch {
163
+ updateBanner = null;
164
+ }
102
165
  const { renderRepl } = await import('../tui/repl-render.js');
103
166
  await renderRepl({
104
167
  apiUrl: runtimeConfig.apiUrl,
105
168
  apiKey: runtimeConfig.apiKey,
106
169
  workspaceLabel: workspaceLabel(process.cwd()),
107
170
  cliVersion: PUGI_CLI_VERSION,
171
+ updateBanner,
108
172
  });
109
173
  return;
110
174
  }
@@ -138,6 +202,8 @@ function parseArgs(argv) {
138
202
  triple: false,
139
203
  offline: false,
140
204
  noTty: false,
205
+ allowFetch: false,
206
+ noUpdateCheck: false,
141
207
  };
142
208
  const args = [];
143
209
  // Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
@@ -173,6 +239,12 @@ function parseArgs(argv) {
173
239
  else if (arg === '--no-tty') {
174
240
  flags.noTty = true;
175
241
  }
242
+ else if (arg === '--allow-fetch') {
243
+ flags.allowFetch = true;
244
+ }
245
+ else if (arg === '--no-update-check') {
246
+ flags.noUpdateCheck = true;
247
+ }
176
248
  else if (arg.startsWith('--privacy=')) {
177
249
  flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
178
250
  }
@@ -229,6 +301,8 @@ async function help(_args, flags, _session) {
229
301
  'Interactivity:',
230
302
  ' --no-tty Force the line-buffered output path (CI, pipes,',
231
303
  ' recording flows, dumb terminals).',
304
+ ' --no-update-check Silence the REPL startup update banner. Pairs',
305
+ ' with PUGI_SKIP_UPDATE_BANNER=1.',
232
306
  '',
233
307
  PUGI_TAGLINE,
234
308
  'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
@@ -2203,6 +2277,27 @@ async function performDeviceFlowLogin(apiUrl, flags, label) {
2203
2277
  return;
2204
2278
  }
2205
2279
  const start = startResult.response;
2280
+ // Two render strategies share the same poll loop:
2281
+ // - TTY (Ink): auto-opens the browser, renders the Claude Code
2282
+ // parity device-flow screen, drives the spinner, transitions to
2283
+ // a clean success/failure frame, returns on Enter / Esc.
2284
+ // - non-TTY (stdout): the legacy line-buffered output kept
2285
+ // verbatim for `--json`, `--no-tty`, CI and piped contexts so
2286
+ // scripts that parse stdout are not broken.
2287
+ if (isInteractive(flags) && !flags.json) {
2288
+ await runDeviceFlowLoginInk(apiUrl, flags, label, start);
2289
+ return;
2290
+ }
2291
+ await runDeviceFlowLoginStdout(apiUrl, flags, label, start);
2292
+ }
2293
+ /**
2294
+ * Legacy stdout polling path. Kept verbatim from the pre-parity
2295
+ * implementation so `--json`, `--no-tty`, CI scripts, and piped
2296
+ * invocations see exactly the same output they did before. Anything
2297
+ * a downstream tool parsed (the `Polling every Ns` line, the
2298
+ * `Pugi logged in for ...` block, the JSON shape) is unchanged.
2299
+ */
2300
+ async function runDeviceFlowLoginStdout(apiUrl, flags, label, start) {
2206
2301
  // Surface the user-visible parts to stderr so JSON consumers on
2207
2302
  // stdout are unaffected. The deviceCode itself is NEVER printed —
2208
2303
  // it is the secret poll handle.
@@ -2216,89 +2311,309 @@ async function performDeviceFlowLogin(apiUrl, flags, label) {
2216
2311
  `Polling every ${start.interval}s, expires in ${start.expires_in}s...`,
2217
2312
  '',
2218
2313
  ].join('\n'));
2314
+ const outcome = await pollDeviceFlowUntilTerminal(apiUrl, start);
2315
+ emitDeviceFlowTerminalToStdout(outcome, { apiUrl, flags, label });
2316
+ }
2317
+ /**
2318
+ * TTY device-flow path — Claude Code parity. Auto-opens the browser,
2319
+ * mounts the Ink device-flow component, drives status transitions as
2320
+ * the poll loop advances, and resolves on Enter (success) or Esc
2321
+ * (cancel). On non-TTY this entry point is never called.
2322
+ */
2323
+ async function runDeviceFlowLoginInk(apiUrl, flags, label, start) {
2324
+ const [{ autoOpenBrowser }, { writeClipboard }, { renderDeviceFlow, LoginCancelledError }] = await Promise.all([
2325
+ import('../core/auto-open-browser.js'),
2326
+ import('../core/clipboard.js'),
2327
+ import('../tui/render.js'),
2328
+ ]);
2329
+ // Best-effort: spawn the user's default browser. We do not block on
2330
+ // the browser process — auto-open resolves as soon as spawn returns
2331
+ // (the child is detached and ref-released, so its long-lived browser
2332
+ // handle does not keep this CLI alive). The device-flow loop is the
2333
+ // source of truth for success; if auto-open fails, the Ink screen
2334
+ // renders the copy-the-URL fallback and we keep polling.
2335
+ const open = await autoOpenBrowser(start.verification_uri_complete ?? start.verification_uri);
2336
+ const handle = renderDeviceFlow({
2337
+ verificationUrl: start.verification_uri_complete ?? start.verification_uri,
2338
+ userCode: start.user_code,
2339
+ browserOpened: open.opened,
2340
+ onCopy: () => {
2341
+ // Fire-and-forget; the visual flash is owned by the component.
2342
+ // Clipboard write failures are silent — the user already sees
2343
+ // the URL on screen.
2344
+ void writeClipboard(start.verification_uri_complete ?? start.verification_uri);
2345
+ },
2346
+ });
2347
+ // Race the poll loop against the user's Esc keystroke. We DO NOT
2348
+ // await `handle.done()` in parallel here: the loop pushes status
2349
+ // updates via handle.setStatus, and on terminal status we await
2350
+ // handle.done() (which resolves on Enter / rejects on Esc). On Esc
2351
+ // BEFORE a terminal status, handle.done() rejects and we surface a
2352
+ // cancel exit code without writing any credential.
2353
+ // P3 polish (triple-review 2026-05-24): the old code mirrored the
2354
+ // cancel state into a `cancelled` flag and then checked
2355
+ // `winner.kind === 'cancel' || cancelled` — the second clause was
2356
+ // already implied by the first, since the Promise.race winner is
2357
+ // the only way the cancel branch is taken. Drop the flag.
2358
+ const cancelWatch = handle.done().catch((error) => {
2359
+ if (error instanceof LoginCancelledError)
2360
+ return;
2361
+ throw error;
2362
+ });
2363
+ // P1-1 (triple-review 2026-05-24): the poll loop must be abortable
2364
+ // so an Esc cancel does not leave a background poll running until
2365
+ // the device-flow deadline (up to 1 hour). The controller is wired
2366
+ // into the loop's abortable `sleep()` so the loop returns within
2367
+ // one event-loop tick of `abort()`.
2368
+ const pollAbort = new AbortController();
2369
+ const pollPromise = pollDeviceFlowUntilTerminal(apiUrl, start, pollAbort.signal);
2370
+ // Whichever settles first wins. If the user pressed Esc before the
2371
+ // server returned a terminal status, the cancel branch aborts the
2372
+ // poll and short-circuits. Otherwise the poll outcome drives the
2373
+ // final render frame.
2374
+ const winner = await Promise.race([
2375
+ pollPromise.then((outcome) => ({ kind: 'poll', outcome })),
2376
+ cancelWatch.then(() => ({ kind: 'cancel' })),
2377
+ ]);
2378
+ if (winner.kind === 'cancel') {
2379
+ // Tell the poll loop to stop. The signal short-circuits the inter-
2380
+ // poll sleep and the in-flight HTTP call settles on its own; we
2381
+ // swallow the resulting rejection so an aborted poll does not
2382
+ // surface as an unhandled promise rejection.
2383
+ pollAbort.abort();
2384
+ await pollPromise.catch(() => undefined);
2385
+ // The handle is already unmounted by the Esc path inside
2386
+ // renderDeviceFlow's finish() helper. Surface the cancel state to
2387
+ // the surrounding dispatcher exactly as the legacy stdout path
2388
+ // would have done on a forced Ctrl+C.
2389
+ writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
2390
+ process.exitCode = 130;
2391
+ return;
2392
+ }
2393
+ const outcome = winner.outcome;
2394
+ // Translate the terminal outcome into both a final Ink frame AND
2395
+ // the same writeOutput payload the stdout path would have emitted —
2396
+ // tests that pin JSON shape stay green regardless of TTY.
2397
+ applyDeviceFlowOutcomeToInk(outcome, handle);
2398
+ emitDeviceFlowTerminalToStdout(outcome, { apiUrl, flags, label });
2399
+ // On success, the host now waits for the user's Enter keystroke
2400
+ // before returning control to the caller (REPL / shell). Failure
2401
+ // frames also wait — Esc dismisses them. Either way we await done()
2402
+ // so the Ink screen stays on the user's terminal until they react.
2403
+ await handle.done().catch((error) => {
2404
+ if (error instanceof LoginCancelledError)
2405
+ return;
2406
+ throw error;
2407
+ });
2408
+ }
2409
+ /**
2410
+ * Sentinel thrown (P1-1, triple-review 2026-05-24) when the caller
2411
+ * aborts the poll loop via the optional `AbortSignal`. The Ink host
2412
+ * treats this as a silent cancel — the surrounding
2413
+ * `runDeviceFlowLoginInk` already owns the cancel exit code via the
2414
+ * keystroke race.
2415
+ */
2416
+ class DeviceFlowPollAbortedError extends Error {
2417
+ constructor() {
2418
+ super('Device-flow poll aborted by caller');
2419
+ this.name = 'DeviceFlowPollAbortedError';
2420
+ }
2421
+ }
2422
+ async function pollDeviceFlowUntilTerminal(apiUrl, start, signal, deps = {}) {
2423
+ const poll = deps.poll ?? pollDeviceFlow;
2424
+ const sleepImpl = deps.sleepFn ?? sleep;
2425
+ const now = deps.now ?? Date.now;
2219
2426
  // Hard local cap on the polling deadline so a hostile or buggy
2220
2427
  // runtime returning an absurdly large `expires_in` cannot trap
2221
2428
  // the CLI in a long-running poll. The SDK schema already caps
2222
2429
  // `expires_in` at 3600s, but enforce the floor here too — the
2223
2430
  // SDK contract is what we own, and a future broadening must
2224
2431
  // still respect this local maximum.
2225
- const PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC = 60 * 60; // 1 hour
2432
+ const PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC = (deps.deadlineMaxMs ?? 60 * 60 * 1000) / 1000;
2226
2433
  const expiresInSec = Math.min(start.expires_in, PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC);
2227
- const deadline = Date.now() + expiresInSec * 1000;
2434
+ const deadline = now() + expiresInSec * 1000;
2228
2435
  const pollInterval = start.interval;
2229
- while (Date.now() < deadline) {
2230
- await sleep(pollInterval * 1000);
2231
- const pollResult = await pollDeviceFlow(apiUrl, start.device_code);
2436
+ // P1-1 (triple-review 2026-05-24): cancellable interval. If the
2437
+ // Ink host aborts (user pressed Esc), `sleep` resolves immediately
2438
+ // and the loop throws DeviceFlowPollAbortedError so the caller can
2439
+ // drain the rejection silently without waiting up to an hour.
2440
+ while (now() < deadline) {
2441
+ if (signal?.aborted)
2442
+ throw new DeviceFlowPollAbortedError();
2443
+ await sleepImpl(pollInterval * 1000, signal);
2444
+ if (signal?.aborted)
2445
+ throw new DeviceFlowPollAbortedError();
2446
+ const pollResult = await poll(apiUrl, start.device_code);
2447
+ if (signal?.aborted)
2448
+ throw new DeviceFlowPollAbortedError();
2232
2449
  if (pollResult.status !== 'ok') {
2233
2450
  const failure = describeDeviceFlowFailure(pollResult, 'poll');
2234
- writeOutput(flags, {
2235
- status: failure.status,
2451
+ return {
2452
+ kind: 'failed',
2453
+ failure,
2236
2454
  code: 'code' in pollResult ? pollResult.code : undefined,
2237
- message: failure.message,
2238
- }, [failure.headline, failure.next ? `Next: ${failure.next}` : '']
2239
- .filter(Boolean)
2240
- .join('\n'));
2241
- process.exitCode = failure.exitCode;
2242
- return;
2455
+ };
2243
2456
  }
2244
2457
  const outcome = pollResult.response;
2245
2458
  if (outcome.status === 'authorized') {
2246
- const record = storeApiKey({
2247
- apiUrl,
2248
- apiKey: outcome.access_token,
2249
- label: label ?? undefined,
2250
- source: 'device-flow',
2251
- });
2252
- // Defense-in-depth against the device-flow phishing class
2253
- // (P0, 2026-05-22 review): decode the JWT we just received
2254
- // and surface the principal identity to the user so they
2255
- // can confirm we landed in the expected tenant. Cabinet UI
2256
- // alone showing "you are about to grant access" is not
2257
- // enough — a phisher who tricked the victim into reading
2258
- // out a userCode would have approved on their own account
2259
- // and the JWT would silently authenticate the victim's CLI
2260
- // as the attacker. By showing the user/tenant on the CLI,
2261
- // the victim sees the mismatch before any sensitive
2262
- // operation runs.
2263
- const principal = decodeJwtPrincipal(outcome.access_token);
2264
- writeOutput(flags, {
2265
- status: 'logged_in',
2266
- apiUrl: record.apiUrl,
2267
- apiKeyMasked: maskApiKey(record.apiKey),
2268
- label: record.label ?? null,
2269
- createdAt: record.createdAt,
2270
- via: 'device-flow',
2271
- principal,
2272
- }, [
2273
- `Pugi logged in for ${record.apiUrl} via device flow`,
2274
- `Token: ${maskApiKey(record.apiKey)}${record.label ? ` (${record.label})` : ''}`,
2275
- principal
2276
- ? `Authenticated as user=${principal.email ?? principal.sub} tenant=${principal.customerId ?? '(unknown)'}.`
2277
- : 'Authenticated (JWT payload could not be decoded for principal display).',
2278
- 'If this is NOT the user/tenant you expected, run `pugi logout` immediately and re-check the verification URL.',
2279
- 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
2280
- ].join('\n'));
2281
- return;
2282
- }
2283
- if (outcome.status === 'denied') {
2284
- writeOutput(flags, { status: 'denied' }, 'Pugi device flow denied. The cabinet user clicked Deny.');
2285
- process.exitCode = 5;
2286
- return;
2287
- }
2288
- if (outcome.status === 'expired' || outcome.status === 'redeemed') {
2289
- writeOutput(flags, { status: outcome.status }, outcome.status === 'expired'
2290
- ? 'Pugi device flow expired before approval. Run `pugi login` again.'
2291
- : 'Pugi device flow already redeemed by another CLI. Run `pugi login` to start a fresh flow.');
2292
- process.exitCode = 5;
2293
- return;
2459
+ return { kind: 'authorized', accessToken: outcome.access_token };
2294
2460
  }
2461
+ if (outcome.status === 'denied')
2462
+ return { kind: 'denied' };
2463
+ if (outcome.status === 'expired')
2464
+ return { kind: 'expired' };
2465
+ if (outcome.status === 'redeemed')
2466
+ return { kind: 'redeemed' };
2295
2467
  // status === 'pending' — keep polling
2296
2468
  }
2297
- writeOutput(flags, { status: 'expired' }, 'Pugi device flow timed out locally. Run `pugi login` again.');
2298
- process.exitCode = 5;
2469
+ // P1-2 (triple-review 2026-05-24): the loop hit the LOCAL deadline
2470
+ // without the server returning a terminal status. The legacy stdout
2471
+ // path distinguished this from server-side `expired` so operators
2472
+ // could tell a local-cap timeout from an actual server expiry; we
2473
+ // preserve that signal via this dedicated outcome kind.
2474
+ return { kind: 'timed_out_local' };
2475
+ }
2476
+ /**
2477
+ * Final writeOutput emission, identical shape to the pre-parity
2478
+ * stdout path so JSON consumers, scripts, and CI snapshots are not
2479
+ * affected by the TTY-vs-stdout branch.
2480
+ */
2481
+ function emitDeviceFlowTerminalToStdout(outcome, ctx) {
2482
+ if (outcome.kind === 'authorized') {
2483
+ const record = storeApiKey({
2484
+ apiUrl: ctx.apiUrl,
2485
+ apiKey: outcome.accessToken,
2486
+ label: ctx.label ?? undefined,
2487
+ source: 'device-flow',
2488
+ });
2489
+ // Defense-in-depth against the device-flow phishing class
2490
+ // (P0, 2026-05-22 review): decode the JWT we just received and
2491
+ // surface the principal identity to the user so they can confirm
2492
+ // we landed in the expected tenant.
2493
+ const principal = decodeJwtPrincipal(outcome.accessToken);
2494
+ writeOutput(ctx.flags, {
2495
+ status: 'logged_in',
2496
+ apiUrl: record.apiUrl,
2497
+ apiKeyMasked: maskApiKey(record.apiKey),
2498
+ label: record.label ?? null,
2499
+ createdAt: record.createdAt,
2500
+ via: 'device-flow',
2501
+ principal,
2502
+ }, [
2503
+ `Pugi logged in for ${record.apiUrl} via device flow`,
2504
+ `Token: ${maskApiKey(record.apiKey)}${record.label ? ` (${record.label})` : ''}`,
2505
+ principal
2506
+ ? `Authenticated as user=${principal.email ?? principal.sub} tenant=${principal.customerId ?? '(unknown)'}.`
2507
+ : 'Authenticated (JWT payload could not be decoded for principal display).',
2508
+ 'If this is NOT the user/tenant you expected, run `pugi logout` immediately and re-check the verification URL.',
2509
+ 'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
2510
+ ].join('\n'));
2511
+ return;
2512
+ }
2513
+ if (outcome.kind === 'denied') {
2514
+ writeOutput(ctx.flags, { status: 'denied' }, 'Pugi device flow denied. The cabinet user clicked Deny.');
2515
+ process.exitCode = 5;
2516
+ return;
2517
+ }
2518
+ if (outcome.kind === 'expired' || outcome.kind === 'redeemed') {
2519
+ writeOutput(ctx.flags, { status: outcome.kind }, outcome.kind === 'expired'
2520
+ ? 'Pugi device flow expired before approval. Run `pugi login` again.'
2521
+ : 'Pugi device flow already redeemed by another CLI. Run `pugi login` to start a fresh flow.');
2522
+ process.exitCode = 5;
2523
+ return;
2524
+ }
2525
+ if (outcome.kind === 'timed_out_local') {
2526
+ // P1-2 (triple-review 2026-05-24): the legacy stdout path
2527
+ // surfaced local-cap timeouts under a dedicated message so
2528
+ // operators could tell a 1-hour client cap apart from a server
2529
+ // expiry. The JSON shape uses `status: 'timed_out_local'` so
2530
+ // downstream scripts can branch on it; the human message stays
2531
+ // close to the pre-parity wording.
2532
+ writeOutput(ctx.flags, { status: 'timed_out_local' }, 'Pugi device flow timed out locally before the server returned a terminal status. Run `pugi login` again.');
2533
+ process.exitCode = 5;
2534
+ return;
2535
+ }
2536
+ // failed
2537
+ writeOutput(ctx.flags, {
2538
+ status: outcome.failure.status,
2539
+ code: outcome.code,
2540
+ message: outcome.failure.message,
2541
+ }, [outcome.failure.headline, outcome.failure.next ? `Next: ${outcome.failure.next}` : '']
2542
+ .filter(Boolean)
2543
+ .join('\n'));
2544
+ process.exitCode = outcome.failure.exitCode;
2545
+ }
2546
+ /**
2547
+ * Drives the Ink screen toward the success / failure frame based on
2548
+ * the terminal outcome. The principal label mirrors the stdout
2549
+ * "Authenticated as user=… tenant=…" line so the on-screen identity
2550
+ * the user confirms matches the verbose message the stdout path
2551
+ * still emits.
2552
+ */
2553
+ function applyDeviceFlowOutcomeToInk(outcome, handle) {
2554
+ if (outcome.kind === 'authorized') {
2555
+ const principal = decodeJwtPrincipal(outcome.accessToken);
2556
+ const label = principal
2557
+ ? `${principal.email ?? principal.sub ?? 'authenticated'}${principal.customerId ? ` (tenant: ${principal.customerId})` : ''}`
2558
+ : 'authenticated';
2559
+ handle.setStatus({ kind: 'success', principalLabel: label });
2560
+ return;
2561
+ }
2562
+ if (outcome.kind === 'denied') {
2563
+ handle.setStatus({
2564
+ kind: 'failure',
2565
+ reason: 'Login was denied in the browser.',
2566
+ hint: 'Run `pugi login` again to retry.',
2567
+ });
2568
+ return;
2569
+ }
2570
+ if (outcome.kind === 'expired') {
2571
+ handle.setStatus({
2572
+ kind: 'failure',
2573
+ reason: 'Login code expired before approval.',
2574
+ hint: 'Run `pugi login` again to retry.',
2575
+ });
2576
+ return;
2577
+ }
2578
+ if (outcome.kind === 'timed_out_local') {
2579
+ handle.setStatus({
2580
+ kind: 'failure',
2581
+ reason: 'Login timed out locally before the server returned a terminal status.',
2582
+ hint: 'Run `pugi login` again to retry.',
2583
+ });
2584
+ return;
2585
+ }
2586
+ if (outcome.kind === 'redeemed') {
2587
+ handle.setStatus({
2588
+ kind: 'failure',
2589
+ reason: 'Login code already redeemed by another CLI.',
2590
+ hint: 'Run `pugi login` again to start a fresh flow.',
2591
+ });
2592
+ return;
2593
+ }
2594
+ handle.setStatus({
2595
+ kind: 'failure',
2596
+ reason: outcome.failure.headline,
2597
+ hint: outcome.failure.next ?? undefined,
2598
+ });
2299
2599
  }
2300
- function sleep(ms) {
2301
- return new Promise((resolve) => setTimeout(resolve, ms));
2600
+ function sleep(ms, signal) {
2601
+ return new Promise((resolve) => {
2602
+ if (signal?.aborted) {
2603
+ resolve();
2604
+ return;
2605
+ }
2606
+ const timer = setTimeout(() => {
2607
+ if (signal)
2608
+ signal.removeEventListener('abort', onAbort);
2609
+ resolve();
2610
+ }, ms);
2611
+ const onAbort = () => {
2612
+ clearTimeout(timer);
2613
+ resolve();
2614
+ };
2615
+ signal?.addEventListener('abort', onAbort, { once: true });
2616
+ });
2302
2617
  }
2303
2618
  /**
2304
2619
  * Best-effort JWT payload decode for principal display ONLY. Does
@@ -3044,4 +3359,15 @@ function writeOutput(flags, payload, text) {
3044
3359
  export function packageRoot() {
3045
3360
  return dirname(dirname(fileURLToPath(import.meta.url)));
3046
3361
  }
3362
+ /**
3363
+ * Test-only surface for the triple-review 2026-05-24 device-flow
3364
+ * fixes (P1-1 abort + P1-2 local-timeout distinction). Kept under an
3365
+ * explicit `__test__` namespace so consumers do not accidentally
3366
+ * import internals; the module's runtime contract is still the
3367
+ * `runCli` entry point above.
3368
+ */
3369
+ export const __test__ = {
3370
+ sleep,
3371
+ pollDeviceFlowUntilTerminal,
3372
+ };
3047
3373
  //# sourceMappingURL=cli.js.map