agent-dag 1.43.0 → 1.45.0
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/README.md +13 -7
- package/bin/agent-dag.js +87 -9
- package/bin/deck.js +133 -22
- package/dist/web/assets/index-Bdl1LX0-.css +1 -0
- package/dist/web/assets/index-jXBjwwZC.js +89 -0
- package/dist/web/index.html +2 -2
- package/hook/hook.js +120 -31
- package/package.json +2 -2
- package/src/server/args.mjs +113 -15
- package/src/server/ccusage.mjs +105 -1
- package/src/server/claude-accounts.mjs +145 -1
- package/src/server/codex-auth.mjs +9 -4
- package/src/server/codex-quota.mjs +95 -3
- package/src/server/codex-usage.mjs +163 -9
- package/src/server/cswap-admin.mjs +346 -40
- package/src/server/cswap-auto.mjs +365 -12
- package/src/server/cswap-install.mjs +238 -17
- package/src/server/exec.mjs +233 -26
- package/src/server/index.mjs +1994 -157
- package/src/server/installer.mjs +173 -11
- package/src/server/invoked-as.mjs +16 -14
- package/src/server/quota.mjs +131 -34
- package/src/server/retire-sound-hook.mjs +315 -0
- package/src/server/self-update.mjs +262 -21
- package/src/server/supervisor.mjs +103 -0
- package/src/server/system-metrics.mjs +105 -7
- package/src/server/uv-bootstrap.mjs +43 -11
- package/dist/web/assets/index-BxAZQc7O.css +0 -1
- package/dist/web/assets/index-DRgZVqF-.js +0 -78
- package/hook/notify.js +0 -60
- package/src/server/sound-hook.mjs +0 -390
|
@@ -17,10 +17,13 @@
|
|
|
17
17
|
// first account's record. Nothing upstream prevents it, so every mutation here
|
|
18
18
|
// goes through one mutex.
|
|
19
19
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
20
|
+
import { existsSync } from "node:fs";
|
|
20
21
|
import { readFile } from "node:fs/promises";
|
|
22
|
+
import { homedir } from "node:os";
|
|
21
23
|
import { join } from "node:path";
|
|
22
|
-
import { looksMissing, run, runDetached, runInteractive } from "./exec.mjs";
|
|
24
|
+
import { looksMissing, pathLookup, run, runDetached, runInteractive } from "./exec.mjs";
|
|
23
25
|
import { backupRoot, invalidateClaudeAccountsCache } from "./claude-accounts.mjs";
|
|
26
|
+
import { claudeCliCandidates } from "./claude-dir.mjs";
|
|
24
27
|
import { cswapBin } from "./cswap-install.mjs";
|
|
25
28
|
import { PRODUCT } from "./brand.mjs";
|
|
26
29
|
|
|
@@ -77,8 +80,79 @@ export function withStoreLock(fn) {
|
|
|
77
80
|
// import, remove, rename, reorder — failed with cmd.exe's "is not recognized",
|
|
78
81
|
// while the read-only half of the panel worked, because it was already using
|
|
79
82
|
// the resolver. Reported from Windows on 2026-08-14.
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Which `claude` the account surface runs: the configured one, else the first
|
|
86
|
+
* candidate this machine actually has, else the bare name.
|
|
87
|
+
*
|
|
88
|
+
* WHY THIS IS NOT `AGENTS_DECK_CLAUDE ?? "claude"` ANY MORE (#570). That was
|
|
89
|
+
* the whole of this module's resolution, and it feeds every child the accounts
|
|
90
|
+
* panel starts — `claude auth status --json` for `currentIdentity`, and the
|
|
91
|
+
* `claude auth login` whose output the sign-in dialog reads a link out of. On a
|
|
92
|
+
* machine whose `claude` is at `~/.local/bin/claude` but whose deck was started
|
|
93
|
+
* from something that never sourced a shell rc — a LaunchAgent, a systemd user
|
|
94
|
+
* unit, pm2, a desktop shortcut — the bare name is an ENOENT, so the login
|
|
95
|
+
* child is dead within milliseconds, the flow reports `no_url`, and the dialog
|
|
96
|
+
* shows "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE
|
|
97
|
+
* to its full path." That sentence is a real remedy and it is why this was a
|
|
98
|
+
* smaller bug than #553; it is still a request to spell out a path the deck had
|
|
99
|
+
* already found for itself, because `hasClaudeInstalled()` stat'ed that exact
|
|
100
|
+
* file at boot to decide this was a Claude machine, and since #553 the quota
|
|
101
|
+
* panel beside this one runs the same binary without being told anything.
|
|
102
|
+
*
|
|
103
|
+
* SO IT READS THE SAME LIST, ON THE SAME TERMS #553 SETTLED ON. The list is
|
|
104
|
+
* `claudeCliCandidates` in claude-dir.mjs, whose other two readers are
|
|
105
|
+
* `hasClaudeInstalled()` — the boot question this module's whole surface hangs
|
|
106
|
+
* off — and `quotaClaudeBin` in quota.mjs. This is the same question at a third
|
|
107
|
+
* site, so nothing here is decided again:
|
|
108
|
+
*
|
|
109
|
+
* - AGENTS_DECK_CLAUDE first, and it is the one thing that skips the list
|
|
110
|
+
* entirely. It is documented in the README as "full path to the `claude`
|
|
111
|
+
* CLI", it is what the failure message above tells people to set, and
|
|
112
|
+
* someone who set it has already been through this once — second-guessing
|
|
113
|
+
* them with a stat would be answering a question they have closed. An empty
|
|
114
|
+
* value reads as unset, the way `AGENTS_DECK_CSWAP` does in cswapBin.
|
|
115
|
+
* - Then the candidate list's own order, unchanged: PATH first on POSIX, the
|
|
116
|
+
* two known install directories first on Windows. Preferring a different
|
|
117
|
+
* copy would silently change which binary signs somebody in on every
|
|
118
|
+
* machine that has two, and a `claude auth login` that suddenly runs a
|
|
119
|
+
* different binary is a credential path, not a detail.
|
|
120
|
+
* - The bare name is only answered with when PATH actually holds it, and
|
|
121
|
+
* `pathLookup` is a yes/no gate rather than the path it found, so spawn's
|
|
122
|
+
* own resolution — and, on Windows, exec.mjs's PATHEXT walk, since `claude`
|
|
123
|
+
* there is `claude.exe` or `claude.cmd` and never the bare word — stays in
|
|
124
|
+
* charge of the PATH case exactly as before.
|
|
125
|
+
* - The absolute candidates are stat'ed only once PATH has come up empty, so
|
|
126
|
+
* the common case costs one stat rather than a directory walk. Against what
|
|
127
|
+
* follows it — a whole Claude Code process, and a browser sign-in a human
|
|
128
|
+
* is walking through — that is not a cost worth naming.
|
|
129
|
+
*
|
|
130
|
+
* Pure, with the platform, environment, home directory and existence check all
|
|
131
|
+
* parameters, so the Windows branch is checkable from the platforms this repo
|
|
132
|
+
* is actually developed on. Exported for that test rather than for a caller
|
|
133
|
+
* (#383): `claudeBin` below is the only one, and it hands back the real
|
|
134
|
+
* machine's answer.
|
|
135
|
+
*/
|
|
136
|
+
export function adminClaudeBin(platform = process.platform, env = process.env,
|
|
137
|
+
home = homedir(), exists = existsSync) {
|
|
138
|
+
if (env.AGENTS_DECK_CLAUDE) return env.AGENTS_DECK_CLAUDE;
|
|
139
|
+
const sep = platform === "win32" ? "\\" : "/";
|
|
140
|
+
// process.env is case-insensitive on Windows; an injected plain object in a
|
|
141
|
+
// test is not, and %Path% is how the variable is actually spelled there.
|
|
142
|
+
const pathEnv = env.PATH ?? env.Path ?? env.path ?? "";
|
|
143
|
+
for (const c of claudeCliCandidates(platform, env, home)) {
|
|
144
|
+
if (c.includes(sep)) { if (exists(c)) return c; }
|
|
145
|
+
else if (pathLookup(c, platform, { pathEnv, exists })) return c;
|
|
146
|
+
}
|
|
147
|
+
// Nothing on PATH and nothing at any known install directory. The bare name
|
|
148
|
+
// is still the right last resort — POSIX `execvp` and cmd.exe's own search
|
|
149
|
+
// both deserve their turn at a layout no list here knows — and the ENOENT it
|
|
150
|
+
// produces is what failureText turns into the AGENTS_DECK_CLAUDE sentence.
|
|
151
|
+
return "claude";
|
|
152
|
+
}
|
|
153
|
+
|
|
80
154
|
async function claudeBin() {
|
|
81
|
-
return
|
|
155
|
+
return adminClaudeBin();
|
|
82
156
|
}
|
|
83
157
|
|
|
84
158
|
/** Slot → email for everything currently in the store, plus the active slot. */
|
|
@@ -212,7 +286,71 @@ export function loginState() {
|
|
|
212
286
|
return { state, url: url ?? null, error: error ?? null, account: account ?? null, expiresAt: expiresAt ?? null };
|
|
213
287
|
}
|
|
214
288
|
|
|
289
|
+
/**
|
|
290
|
+
* What an address may be made of before it becomes an argv element.
|
|
291
|
+
*
|
|
292
|
+
* NOT AN RFC 5322 PARSER, and it should not be read as one. RFC 5322 permits
|
|
293
|
+
* quoted local parts, spaces inside them, comments in parentheses and bracketed
|
|
294
|
+
* address literals; a regex that accepted all of that would accept precisely the
|
|
295
|
+
* shapes this exists to keep out. The job here is narrower and worth stating
|
|
296
|
+
* plainly: keep a FLAG-SHAPED or WHITESPACE-BEARING string out of a spawn's
|
|
297
|
+
* argument vector. Anything it wrongly refuses is an address nobody has ever
|
|
298
|
+
* typed into this dialog; anything it wrongly accepts is inert as an argument,
|
|
299
|
+
* which is the only property being defended. Whether the address exists is
|
|
300
|
+
* Anthropic's question, asked a moment later by the CLI itself.
|
|
301
|
+
*
|
|
302
|
+
* `--email` was the field the alias allowlist below missed. `email.includes("@")`
|
|
303
|
+
* was the whole of its validation and the value came straight off the request
|
|
304
|
+
* body, so the two residuals exec.mjs documents were both reachable through it
|
|
305
|
+
* on Windows, where `claude` is a `.cmd` shim and the vector goes through
|
|
306
|
+
* `cmd.exe /d /s /c`: an interior newline is a command separator inside that one
|
|
307
|
+
* quoted line, and `%USERPROFILE%` expands inside quotes with no escape
|
|
308
|
+
* available. `"a@b\ncalc.exe"` and `"%USERPROFILE%@x"` both satisfy
|
|
309
|
+
* `includes("@")`, and both are payloads alias-charset.test.ts already pins as
|
|
310
|
+
* refused for the other field.
|
|
311
|
+
*
|
|
312
|
+
* The leading-character rule is the same argv-position rule ALIAS_OK now carries.
|
|
313
|
+
* `-x@y.z` starts with a dash, so a child parser reads it as an option rather
|
|
314
|
+
* than as the value of `--email`, and what happens next depends entirely on
|
|
315
|
+
* which options that CLI happens to define.
|
|
316
|
+
*/
|
|
317
|
+
const EMAIL_OK =
|
|
318
|
+
/^[A-Za-z0-9][A-Za-z0-9._%+-]{0,63}@[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)+$/;
|
|
319
|
+
|
|
320
|
+
// The SMTP forward-path limit. The pattern above bounds each PIECE — 64 for the
|
|
321
|
+
// local part, 63 per label — and a domain may carry any number of labels, so
|
|
322
|
+
// without this the whole is unbounded. A bound belongs here for the reason
|
|
323
|
+
// ALIAS_OK has one: on Windows the value ends up inside a single cmd.exe command
|
|
324
|
+
// line, which has a hard length limit of its own.
|
|
325
|
+
const EMAIL_MAX_LENGTH = 254;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The `--email` value `claude auth login` should be given, or a refusal.
|
|
329
|
+
*
|
|
330
|
+
* Three answers rather than two, and the third is the one that matters: `null`
|
|
331
|
+
* means NO ADDRESS WAS OFFERED, which is the only shape the deck's own dialog
|
|
332
|
+
* sends (`AddAccountDialog` posts a bare `{action:"login"}`) and which must stay
|
|
333
|
+
* an ordinary sign-in with no flag appended. A value that is present and
|
|
334
|
+
* unusable is refused outright instead of being quietly dropped: dropping it
|
|
335
|
+
* would run a DIFFERENT sign-in from the one that was asked for and call it a
|
|
336
|
+
* success, and this route is reachable by anything holding the deck token.
|
|
337
|
+
*/
|
|
338
|
+
function loginEmailArg(email) {
|
|
339
|
+
if (email == null) return { ok: true, email: null };
|
|
340
|
+
if (typeof email !== "string") return { ok: false };
|
|
341
|
+
const clean = email.trim();
|
|
342
|
+
if (!clean) return { ok: true, email: null };
|
|
343
|
+
if (clean.length > EMAIL_MAX_LENGTH || !EMAIL_OK.test(clean)) return { ok: false };
|
|
344
|
+
return { ok: true, email: clean };
|
|
345
|
+
}
|
|
346
|
+
|
|
215
347
|
export async function startLogin({ email } = {}) {
|
|
348
|
+
// Argv position is settled first, before any state moves. A refusal here must
|
|
349
|
+
// not cancel a sign-in that is already running — the caller asked for
|
|
350
|
+
// something the deck will not do, and the flow already in flight is not part
|
|
351
|
+
// of that bargain.
|
|
352
|
+
const wanted = loginEmailArg(email);
|
|
353
|
+
if (!wanted.ok) return { ok: false, reason: "bad_email", ...loginState() };
|
|
216
354
|
// Registering is the half that writes to the store; interrupting it would
|
|
217
355
|
// leave an account half-recorded, so that one is refused. A flow merely
|
|
218
356
|
// waiting for a code is not precious — it is most often the one abandoned by
|
|
@@ -225,7 +363,7 @@ export async function startLogin({ email } = {}) {
|
|
|
225
363
|
await cancelLogin();
|
|
226
364
|
}
|
|
227
365
|
if (!_starting) {
|
|
228
|
-
_starting = spawnLogin(email).finally(() => { _starting = null; });
|
|
366
|
+
_starting = spawnLogin(wanted.email).finally(() => { _starting = null; });
|
|
229
367
|
}
|
|
230
368
|
const flow = await _starting;
|
|
231
369
|
|
|
@@ -236,7 +374,14 @@ export async function startLogin({ email } = {}) {
|
|
|
236
374
|
// waiting only for a url meant this POST sat open for the whole fifteen
|
|
237
375
|
// seconds afterwards: a spinner in front of a user whose answer was ready
|
|
238
376
|
// almost immediately.
|
|
239
|
-
await waitFor(() => flow.url || flow.state === "failed", 15_000);
|
|
377
|
+
await waitFor(() => flow.url || flow.state === "failed" || flow.state === "done", 15_000);
|
|
378
|
+
// A sign-in that got somewhere without a link this could read is still a
|
|
379
|
+
// sign-in, and since #708 the done handler can carry one all the way to
|
|
380
|
+
// `done` on its own. Saying "no url" over that would throw a completed login
|
|
381
|
+
// away at the last step, which is the whole of the bug being fixed.
|
|
382
|
+
if (!flow.url && (flow.state === "registering" || flow.state === "done")) {
|
|
383
|
+
return { ok: true, ...loginState() };
|
|
384
|
+
}
|
|
240
385
|
if (!flow.url) {
|
|
241
386
|
flow.child.kill();
|
|
242
387
|
// Same identity check as the done handler below, and for a sharper reason:
|
|
@@ -277,7 +422,10 @@ async function spawnLogin(email) {
|
|
|
277
422
|
const identity = await currentIdentity();
|
|
278
423
|
|
|
279
424
|
const args = ["auth", "login"];
|
|
280
|
-
|
|
425
|
+
// Already through loginEmailArg, which is the only caller's boundary: this is
|
|
426
|
+
// either an address that cannot be read as a flag or null, and null is the
|
|
427
|
+
// ordinary case.
|
|
428
|
+
if (email) args.push("--email", email);
|
|
281
429
|
|
|
282
430
|
const child = runInteractive(await claudeBin(), args, { timeout: LOGIN_TIMEOUT_MS });
|
|
283
431
|
// A sign-in outlives the request that started it, so it can also outlive the
|
|
@@ -326,18 +474,136 @@ async function spawnLogin(email) {
|
|
|
326
474
|
// A newline ended that line; whatever comes next is a new one.
|
|
327
475
|
if (!partial) countedOnLine = 0;
|
|
328
476
|
});
|
|
329
|
-
// The child
|
|
330
|
-
//
|
|
331
|
-
|
|
477
|
+
// The child ending before a code was pasted USED to be read as a failure, and
|
|
478
|
+
// on this CLI that is the ordinary end of a sign-in that worked (#708).
|
|
479
|
+
//
|
|
480
|
+
// `claude auth login` 2.1.246 does two things at once: it prints
|
|
481
|
+
// "Paste code here if prompted > " and blocks on stdin, AND it listens on a
|
|
482
|
+
// loopback port for the OAuth callback. Which half finishes the exchange is
|
|
483
|
+
// not decided by the CLI's version — it is decided by whether the browser
|
|
484
|
+
// that opened can reach this machine's loopback. On the deck's own machine it
|
|
485
|
+
// can, so the CLI takes the code itself, prints "Login successful." and exits
|
|
486
|
+
// 0 while the deck is still sitting in `awaiting_code`; the page that
|
|
487
|
+
// authorised says "You're all set up" and never shows a code to paste. On a
|
|
488
|
+
// deck reached from another machine it cannot, the page shows the code, and
|
|
489
|
+
// the paste path below is the one that runs.
|
|
490
|
+
//
|
|
491
|
+
// So the exit alone is not the verdict. The verdict is the identity, and
|
|
492
|
+
// `claude auth status --json` is the oracle this module already trusts for
|
|
493
|
+
// it — asked here against the identity recorded before the flow started.
|
|
494
|
+
child.done.then(async (r) => {
|
|
332
495
|
if (flow !== _login) return;
|
|
333
|
-
if (flow.state
|
|
496
|
+
if (flow.state !== "awaiting_url" && flow.state !== "awaiting_code") return;
|
|
497
|
+
// Answered before any await, so that a `claude` which cannot be run at all
|
|
498
|
+
// is still reported within milliseconds: startLogin waits on this state to
|
|
499
|
+
// decide whether to keep holding its POST open, and the sentence naming
|
|
500
|
+
// AGENTS_DECK_CLAUDE is the only one in the flow that names a fix. Nothing
|
|
501
|
+
// was signed in either way, so there is nothing to ask about.
|
|
502
|
+
if (r.timedOut || cannotRun(r)) {
|
|
334
503
|
flow.state = "failed";
|
|
335
|
-
flow.error =
|
|
504
|
+
flow.error = loginFailureText(r);
|
|
505
|
+
return;
|
|
336
506
|
}
|
|
507
|
+
// Claimed before asking, for the same reason submitLoginCode claims it: a
|
|
508
|
+
// code posted while the question is out would otherwise register the same
|
|
509
|
+
// login a second time, with a second `cswap add` racing this one.
|
|
510
|
+
flow.state = "registering";
|
|
511
|
+
const identity = await currentIdentity();
|
|
512
|
+
if (flow !== _login) return;
|
|
513
|
+
// A clean exit with somebody logged in is a completed sign-in, including
|
|
514
|
+
// the re-sign-in of an account the deck already holds — an identity that
|
|
515
|
+
// did not CHANGE is not an identity that did not arrive. A dirty exit
|
|
516
|
+
// counts only when the identity moved, which is a login that landed in
|
|
517
|
+
// spite of whatever the CLI complained about on its way out.
|
|
518
|
+
if (identity && (r.ok || identity.email !== flow.previousEmail)) {
|
|
519
|
+
await registerSignedIn(flow, identity);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
flow.state = "failed";
|
|
523
|
+
flow.error = loginFailureText(r);
|
|
524
|
+
}).catch((err) => {
|
|
525
|
+
// This handler answers nobody's request — its promise is dropped — so a
|
|
526
|
+
// throw anywhere in it would be an unhandled rejection AND a dialog left
|
|
527
|
+
// spinning on `registering` until the poll gave up. It was three lines of
|
|
528
|
+
// synchronous assignment before; it now shells out twice.
|
|
529
|
+
console.error(`${PRODUCT} sign-in: the sign-in could not be finished:`, err?.message ?? err);
|
|
530
|
+
if (flow !== _login) return;
|
|
531
|
+
flow.state = "failed";
|
|
532
|
+
flow.error = "the sign-in could not be finished — see the deck's log";
|
|
337
533
|
});
|
|
338
534
|
return flow;
|
|
339
535
|
}
|
|
340
536
|
|
|
537
|
+
/** A run that never reached the CLI at all — nothing can have been signed in. */
|
|
538
|
+
function cannotRun(r) {
|
|
539
|
+
return r?.code === "ENOENT" || looksMissing(`${r?.stderr ?? ""}\n${r?.stdout ?? ""}`, "", r?.code);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Why a sign-in failed, in words meant for the person who pressed the button.
|
|
544
|
+
*
|
|
545
|
+
* The child's own output is offered only when it is a diagnosis — see
|
|
546
|
+
* failureText, which since #708 refuses a line that announces success or is
|
|
547
|
+
* merely the prompt the CLI was still sitting on. What it printed is not thrown
|
|
548
|
+
* away; it goes to the deck's log, where an operator can read it, rather than
|
|
549
|
+
* onto a dialog as the reason.
|
|
550
|
+
*/
|
|
551
|
+
function loginFailureText(r) {
|
|
552
|
+
// One line, the way ccusage's `note` says everything it says: an operator
|
|
553
|
+
// watching the terminal is reading it beside the deck's own repainted status
|
|
554
|
+
// rows, and the escapes in it are a login link's OSC-8 wrapper.
|
|
555
|
+
//
|
|
556
|
+
// stderr goes LAST because the line is cut from the front. What should be
|
|
557
|
+
// lost to the bound is the CLI's chatter — the greeting and a sign-in link
|
|
558
|
+
// that is 400 characters by itself — never its complaint.
|
|
559
|
+
const tail = stripTerminalEscapes(`${r?.stdout ?? ""}\n${r?.stderr ?? ""}`).replace(/\s+/g, " ").trim();
|
|
560
|
+
if (tail) console.error(`${PRODUCT} sign-in: claude auth login did not complete:`, tail.slice(-300));
|
|
561
|
+
if (r?.timedOut) return "the sign-in window expired";
|
|
562
|
+
return failureText(r, "claude auth login", "the sign-in did not complete — nothing new was signed in");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Everything after the sign-in itself: confirm who we are now, record it with
|
|
567
|
+
* claude-swap, and put the previously-active account back in front.
|
|
568
|
+
*
|
|
569
|
+
* Shared by the two ways a sign-in can end (#708) — a code pasted into the
|
|
570
|
+
* prompt, and the CLI finishing the exchange through its own loopback callback
|
|
571
|
+
* — because the steps after it are identical and have to stay identical. An
|
|
572
|
+
* account the deck skipped `cswap add` for is signed in at the CLI level and
|
|
573
|
+
* invisible to the panel, with the account the user was on left switched away
|
|
574
|
+
* from.
|
|
575
|
+
*
|
|
576
|
+
* Returns the same `{ok, ...loginState()}` both callers answer their request
|
|
577
|
+
* with; the done handler simply drops it.
|
|
578
|
+
*/
|
|
579
|
+
async function registerSignedIn(flow, identity) {
|
|
580
|
+
return withStoreLock(async () => {
|
|
581
|
+
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
582
|
+
if (!add.ok) {
|
|
583
|
+
flow.state = "failed";
|
|
584
|
+
flow.error = addFailureText(add);
|
|
585
|
+
await restoreActive(flow.previousActive);
|
|
586
|
+
return { ok: false, reason: "add_failed", ...loginState() };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const after = await readStore();
|
|
590
|
+
const slot = newSlot(flow.before, after);
|
|
591
|
+
// No new slot means the account was already managed and cswap refreshed its
|
|
592
|
+
// credentials in place. That is a success with a different sentence.
|
|
593
|
+
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
594
|
+
|
|
595
|
+
await restoreActive(flow.previousActive);
|
|
596
|
+
invalidateClaudeAccountsCache();
|
|
597
|
+
// Collect straight away, so the new row shows numbers instead of "never
|
|
598
|
+
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
599
|
+
runDetached(await cswapBin(), ["list"]);
|
|
600
|
+
|
|
601
|
+
flow.state = "done";
|
|
602
|
+
flow.account = { num, email: identity.email, added: slot != null };
|
|
603
|
+
return { ok: true, ...loginState() };
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
|
|
341
607
|
/**
|
|
342
608
|
* Hand the code back, then register whatever it signed us in as.
|
|
343
609
|
*
|
|
@@ -374,7 +640,11 @@ export async function submitLoginCode(code) {
|
|
|
374
640
|
}
|
|
375
641
|
if (!r.ok) {
|
|
376
642
|
flow.state = "failed";
|
|
377
|
-
|
|
643
|
+
// The `|| "the code was not accepted"` this used to end with could never
|
|
644
|
+
// run: failureText always answered with something, if only an exit status.
|
|
645
|
+
// The sentence is where it can be reached now — as the fallback failureText
|
|
646
|
+
// reaches for when the CLI printed nothing worth repeating.
|
|
647
|
+
flow.error = r.timedOut ? "the sign-in window expired" : failureText(r, "claude auth login", "the code was not accepted");
|
|
378
648
|
return { ok: false, reason: "login_failed", ...loginState() };
|
|
379
649
|
}
|
|
380
650
|
|
|
@@ -385,31 +655,7 @@ export async function submitLoginCode(code) {
|
|
|
385
655
|
return { ok: false, reason: "no_identity", ...loginState() };
|
|
386
656
|
}
|
|
387
657
|
|
|
388
|
-
return
|
|
389
|
-
const add = await run(await cswapBin(), ["add"], { timeout: CSWAP_TIMEOUT_MS });
|
|
390
|
-
if (!add.ok) {
|
|
391
|
-
flow.state = "failed";
|
|
392
|
-
flow.error = addFailureText(add);
|
|
393
|
-
await restoreActive(flow.previousActive);
|
|
394
|
-
return { ok: false, reason: "add_failed", ...loginState() };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const after = await readStore();
|
|
398
|
-
const slot = newSlot(flow.before, after);
|
|
399
|
-
// No new slot means the account was already managed and cswap refreshed its
|
|
400
|
-
// credentials in place. That is a success with a different sentence.
|
|
401
|
-
const num = slot ?? Object.keys(after.emails).find(k => after.emails[k] === identity.email) ?? null;
|
|
402
|
-
|
|
403
|
-
await restoreActive(flow.previousActive);
|
|
404
|
-
invalidateClaudeAccountsCache();
|
|
405
|
-
// Collect straight away, so the new row shows numbers instead of "never
|
|
406
|
-
// collected" until the next poll — the same nudge seedFirstAccount uses.
|
|
407
|
-
runDetached(await cswapBin(), ["list"]);
|
|
408
|
-
|
|
409
|
-
flow.state = "done";
|
|
410
|
-
flow.account = { num, email: identity.email, added: slot != null };
|
|
411
|
-
return { ok: true, ...loginState() };
|
|
412
|
-
});
|
|
658
|
+
return registerSignedIn(flow, identity);
|
|
413
659
|
}
|
|
414
660
|
|
|
415
661
|
export async function cancelLogin() {
|
|
@@ -600,8 +846,34 @@ export async function removeAccount(num) {
|
|
|
600
846
|
* list, and it closes the unbounded-length half too: an alias is a short name
|
|
601
847
|
* shown instead of an email, so 64 characters is not a constraint anyone meets
|
|
602
848
|
* by accident.
|
|
849
|
+
*
|
|
850
|
+
* The leading `(?!-)` is the half that allowlist missed, and it is not about
|
|
851
|
+
* quoting at all — it is about ARGV POSITION, which no amount of quoting fixes
|
|
852
|
+
* because the value arrives intact and is then read as syntax by the CHILD.
|
|
853
|
+
* `-` is in the character class, so `--unset` matched, and
|
|
854
|
+
* `setAlias(3, "--unset")` built ["alias", "3", "--unset"] — character for
|
|
855
|
+
* character claude-swap's own command for CLEARING an alias. Its `_alias_command`
|
|
856
|
+
* hands that vector to argparse, which sets `unset=True` and leaves `alias_name`
|
|
857
|
+
* as None; the store dropped the name, cswap printed "Removed alias for
|
|
858
|
+
* Account 3", exited 0, and the deck reported the rename as a success. Any other
|
|
859
|
+
* `-x` spelling is consumed the same way — `-h` prints help and exits 0, which
|
|
860
|
+
* also arrives here as a rename that worked.
|
|
861
|
+
*
|
|
862
|
+
* argparse does honour `--` as an end-of-options separator, so
|
|
863
|
+
* ["alias", "3", "--", "--unset"] would reach `set_alias` as data. It is
|
|
864
|
+
* deliberately not used: the separator only helps for the one child whose parser
|
|
865
|
+
* we can read, `claude auth login` is the other spawn on this route and its
|
|
866
|
+
* parser is not ours to verify, and a value the deck refuses outright cannot be
|
|
867
|
+
* mangled by a CLI that changes its mind later. The validator is the guard.
|
|
868
|
+
*
|
|
869
|
+
* Refusing a leading dash rather than requiring a leading alphanumeric is the
|
|
870
|
+
* narrower rule, and it is the one the hazard actually describes: `.env` and
|
|
871
|
+
* `_work` are ordinary positional arguments to every parser involved, while
|
|
872
|
+
* `acme-corp` — the one dash-bearing name in alias-charset.test.ts's list of
|
|
873
|
+
* names people use — keeps working because only the FIRST character is
|
|
874
|
+
* constrained.
|
|
603
875
|
*/
|
|
604
|
-
const ALIAS_OK = /^[A-Za-z0-9 ._-]{1,64}$/;
|
|
876
|
+
const ALIAS_OK = /^(?!-)[A-Za-z0-9 ._-]{1,64}$/;
|
|
605
877
|
|
|
606
878
|
export async function setAlias(num, alias) {
|
|
607
879
|
const n = Number(num);
|
|
@@ -675,11 +947,18 @@ export async function moveAccount(num, slot) {
|
|
|
675
947
|
* Windows it is cmd.exe's two-line "is not recognized …/operable program or
|
|
676
948
|
* batch file.", and `firstUseful` — which takes the LAST line, correctly for
|
|
677
949
|
* every other CLI — leaves the second half on screen by itself.
|
|
950
|
+
*
|
|
951
|
+
* The exit status goes to looksMissing beside the text (#552). This is the one
|
|
952
|
+
* caller with no candidate spelling to compare against, so the shape rules alone
|
|
953
|
+
* are all the TEXT can offer it — and on a non-English Windows the text says
|
|
954
|
+
* nothing this recognises. The status does: 9009 is cmd.exe's "no such command"
|
|
955
|
+
* in every language. Without it, a German user pressing "share…" got the last
|
|
956
|
+
* line of a translated sentence instead of the sentence about PATH.
|
|
678
957
|
*/
|
|
679
|
-
export function failureText(r, what = "cswap") {
|
|
958
|
+
export function failureText(r, what = "cswap", fallback = "") {
|
|
680
959
|
const out = `${r?.stderr ?? ""}\n${r?.stdout ?? ""}`;
|
|
681
960
|
const tool = String(what).split(" ")[0];
|
|
682
|
-
if (r?.code === "ENOENT" || looksMissing(out)) {
|
|
961
|
+
if (r?.code === "ENOENT" || looksMissing(out, "", r?.code)) {
|
|
683
962
|
return tool === "claude"
|
|
684
963
|
? "the claude CLI could not be run: not on PATH. Set AGENTS_DECK_CLAUDE to its full path."
|
|
685
964
|
: "cswap could not be run: not on PATH, and not in the places uv and pipx install to. Set AGENTS_DECK_CSWAP to its full path.";
|
|
@@ -689,7 +968,34 @@ export function failureText(r, what = "cswap") {
|
|
|
689
968
|
// Asked for one anyway, this said "cswap export exited 0", a success code for
|
|
690
969
|
// a command that never completed.
|
|
691
970
|
if (r?.timedOut || r?.code === "ETIMEDOUT") return `${what} took too long and was stopped`;
|
|
692
|
-
return
|
|
971
|
+
return diagnosis(r?.stderr) || diagnosis(r?.stdout) || fallback || `${what} exited ${r?.code}`;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* A line that is NOT a diagnosis, however it reached us.
|
|
976
|
+
*
|
|
977
|
+
* Two shapes were being shown to users as the reason something failed (#708),
|
|
978
|
+
* both out of `claude auth login`: the CLI's own "Login successful." — a
|
|
979
|
+
* failure reason containing the word "successful" is not a diagnosis, it is a
|
|
980
|
+
* dump — and "Paste code here if prompted >", the unterminated prompt it was
|
|
981
|
+
* still sitting on, which says that something was ASKED and nothing about
|
|
982
|
+
* anything going wrong.
|
|
983
|
+
*/
|
|
984
|
+
const NOT_A_DIAGNOSIS = /\bsuccess(?:ful|fully)?\b|[>?]\s*$/i;
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* One stream's last useful line, if it is worth showing a person.
|
|
988
|
+
*
|
|
989
|
+
* Split per stream because the two are not equal: a CLI's diagnosis goes to
|
|
990
|
+
* stderr and its ordinary progress chatter goes to stdout. Reading the LAST
|
|
991
|
+
* line of the two concatenated — which is what this module did — handed stdout
|
|
992
|
+
* the answer whenever it had written anything at all, so `claude auth login`
|
|
993
|
+
* explained itself with the prompt it had printed rather than with the "Login
|
|
994
|
+
* failed: …" it had put on stderr.
|
|
995
|
+
*/
|
|
996
|
+
function diagnosis(text) {
|
|
997
|
+
const line = firstUseful(text);
|
|
998
|
+
return line && !NOT_A_DIAGNOSIS.test(line) ? line : "";
|
|
693
999
|
}
|
|
694
1000
|
|
|
695
1001
|
/** The line worth showing a user out of a CLI's output. */
|