@pugi/cli 0.1.0-alpha.6 → 0.1.0-alpha.8
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.
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +194 -1
- package/dist/core/repl/slash-commands.js +133 -22
- package/dist/core/settings.js +13 -0
- package/dist/runtime/cli.js +392 -66
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/registry.js +1 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +410 -27
- package/dist/tui/render.js +57 -0
- package/dist/tui/repl-render.js +1 -1
- package/dist/tui/repl.js +39 -3
- package/dist/tui/slash-palette.js +69 -0
- package/dist/tui/update-banner.js +8 -0
- package/package.json +7 -2
package/dist/runtime/cli.js
CHANGED
|
@@ -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.
|
|
38
|
+
const PUGI_CLI_VERSION = '0.1.0-alpha.8';
|
|
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
|
|
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 =
|
|
2434
|
+
const deadline = now() + expiresInSec * 1000;
|
|
2228
2435
|
const pollInterval = start.interval;
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
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
|
-
|
|
2235
|
-
|
|
2451
|
+
return {
|
|
2452
|
+
kind: 'failed',
|
|
2453
|
+
failure,
|
|
2236
2454
|
code: 'code' in pollResult ? pollResult.code : undefined,
|
|
2237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2298
|
-
|
|
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) =>
|
|
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
|