@timo972/cc-router 0.13.0-rc.0 → 0.13.0-rc.2

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/CHANGELOG.md CHANGED
@@ -38,6 +38,14 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
38
38
 
39
39
  ### Fixed
40
40
 
41
+ - Escape or Ctrl-C at any prompt of the dashboard's `n` / `l` flows returns
42
+ to the dashboard with a quiet "Cancelled." instead of a red failure line.
43
+ - Pressing `n` (and now `l`) in the dashboard no longer kills the process
44
+ before the sign-in prompt can take input. Ink unrefs stdin when it
45
+ unmounts and nothing ref'd it again, so with no other work pending the
46
+ process exited while inquirer was still waiting — reported as
47
+ `User force closed the prompt with 0 null`. The dashboard now refs stdin
48
+ for the duration of the interactive flow.
41
49
  - Re-authenticating an account replaces its credentials only. Previously the
42
50
  replacement was built from the freshly collected record, so a disabled
43
51
  account came back enabled and custom session/weekly caps reset to 100 —
@@ -59,7 +59,7 @@ export async function collectClaudeAccount(options) {
59
59
  const method = options.method ?? await select({
60
60
  message: "How do you want to add the account?",
61
61
  choices: claudeMethodChoices(options.offer ?? "all"),
62
- });
62
+ }, { signal: options.signal });
63
63
  const attempt = createSetupAttempt({ provider: "anthropic", method: CLAUDE_METHOD_TELEMETRY[method] });
64
64
  attempt.stageCompleted("credential_source_selection");
65
65
  // Shared by reference: the file-extraction fallback swaps in a manual-token attempt.
@@ -106,12 +106,12 @@ async function collectAnthropicAccount(options, method, current, reached) {
106
106
  message: "Paste the token (sk-ant-oat01-...):",
107
107
  mask: "•",
108
108
  validate: v => v.startsWith("sk-ant-oat01-") || "Must start with sk-ant-oat01-",
109
- });
109
+ }, { signal: options.signal });
110
110
  }
111
- const useDefaultExpiry = await confirm({ message: "Token valid for 1 year (default)?", default: true });
111
+ const useDefaultExpiry = await confirm({ message: "Token valid for 1 year (default)?", default: true }, { signal: options.signal });
112
112
  const expiresAt = useDefaultExpiry
113
113
  ? Date.now() + LONG_LIVED_TOKEN_TTL_MS
114
- : await promptExpiry();
114
+ : await promptExpiry(options.signal);
115
115
  tokens = { accessToken, refreshToken: undefined, expiresAt, scopes: ["user:inference"] };
116
116
  console.log(chalk.gray(" This token has no refresh token and the inference scope only: usage and identity metadata are unavailable for it."));
117
117
  }
@@ -129,7 +129,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
129
129
  console.log(chalk.yellow(" Could not find credentials in Keychain."));
130
130
  console.log(chalk.gray(" Make sure Claude Code is logged in: run `claude login` first."));
131
131
  printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
132
- const retry = await confirm({ message: "Try another extraction method?", default: true });
132
+ const retry = await confirm({ message: "Try another extraction method?", default: true }, { signal: options.signal });
133
133
  attempt.cancelled();
134
134
  if (!retry)
135
135
  return { account: null, attempt };
@@ -147,7 +147,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
147
147
  else {
148
148
  console.log(chalk.red(" ✗ ~/.claude/.credentials.json not found or unreadable."));
149
149
  console.log(chalk.gray(" Make sure Claude Code is installed and you've run `claude login`."));
150
- const retry = await confirm({ message: "Paste tokens manually instead?", default: true });
150
+ const retry = await confirm({ message: "Paste tokens manually instead?", default: true }, { signal: options.signal });
151
151
  if (!retry) {
152
152
  printDiagnosticId(attempt.stageFailed(extraction.error, "credential_read"));
153
153
  attempt.cancelled();
@@ -158,11 +158,11 @@ async function collectAnthropicAccount(options, method, current, reached) {
158
158
  attempt = createSetupAttempt({ provider: "anthropic", method: "manual_token" });
159
159
  current.attempt = attempt;
160
160
  attempt.stageCompleted("credential_source_selection");
161
- tokens = await promptManualTokens();
161
+ tokens = await promptManualTokens(options.signal);
162
162
  }
163
163
  }
164
164
  if (method === "manual") {
165
- tokens = await promptManualTokens();
165
+ tokens = await promptManualTokens(options.signal);
166
166
  }
167
167
  if (!tokens) {
168
168
  attempt.cancelled();
@@ -175,7 +175,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
175
175
  message: "Account ID (press Enter to accept default):",
176
176
  default: `max-account-${options.index}`,
177
177
  validate: v => /^[a-zA-Z0-9_-]+$/.test(v) || "Only letters, numbers, _ and - allowed",
178
- });
178
+ }, { signal: options.signal });
179
179
  process.stdout.write(chalk.gray(" Validating tokens against Anthropic... "));
180
180
  const validation = await validateToken(tokens.accessToken);
181
181
  if (validation.valid) {
@@ -187,7 +187,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
187
187
  console.log(chalk.yellow(` Reason: ${validation.reason}`));
188
188
  printDiagnosticId(attempt.stageFailed(validation.diagnostic, "token_validation"));
189
189
  console.log(chalk.gray(" The token will be saved but may not work until refreshed."));
190
- const keepAnyway = await confirm({ message: "Save this account anyway?", default: false });
190
+ const keepAnyway = await confirm({ message: "Save this account anyway?", default: false }, { signal: options.signal });
191
191
  if (!keepAnyway) {
192
192
  attempt.cancelled();
193
193
  return { account: null, attempt };
@@ -215,7 +215,7 @@ async function collectAnthropicAccount(options, method, current, reached) {
215
215
  * The refresh token stays required here: a pasted long-lived `setup-token`
216
216
  * credential has none, and it has its own method rather than this one.
217
217
  */
218
- async function promptManualTokens() {
218
+ async function promptManualTokens(signal) {
219
219
  console.log(chalk.gray("\n You can find your tokens by running:\n" +
220
220
  " macOS: security find-generic-password -s 'Claude Code-credentials' -w\n" +
221
221
  " Linux/Windows: cat ~/.claude/.credentials.json\n"));
@@ -225,21 +225,21 @@ async function promptManualTokens() {
225
225
  validate: (v) => v.startsWith("sk-ant-oat01-") || v.startsWith("sk-ant-")
226
226
  ? true
227
227
  : "Must start with sk-ant-oat01-",
228
- });
228
+ }, { signal });
229
229
  const refreshToken = await password({
230
230
  message: "Paste refreshToken (sk-ant-ort01-...):",
231
231
  mask: "•",
232
232
  validate: (v) => v.startsWith("sk-ant-ort01-") || v.startsWith("sk-ant-")
233
233
  ? true
234
234
  : "Must start with sk-ant-ort01-",
235
- });
235
+ }, { signal });
236
236
  const useDefaultExpiry = await confirm({
237
237
  message: "Use default expiry (8 hours from now)?",
238
238
  default: true,
239
- });
239
+ }, { signal });
240
240
  const expiresAt = useDefaultExpiry
241
241
  ? Date.now() + 8 * 60 * 60 * 1000
242
- : await promptExpiry();
242
+ : await promptExpiry(signal);
243
243
  return {
244
244
  accessToken,
245
245
  refreshToken,
@@ -383,15 +383,16 @@ export async function collectReauthRecord(target, options = {}) {
383
383
  ...(target.email ? { email: target.email } : {}),
384
384
  offer: "login",
385
385
  ...(options.longLived ? { method: "setup_token" } : {}),
386
+ ...(options.signal ? { signal: options.signal } : {}),
386
387
  });
387
388
  return account ? { record: credentialsOnly(accountToRecord(account)), attempt } : null;
388
389
  }
389
390
  /** An explicit expiry: ISO date or Unix milliseconds, validated as typed. */
390
- async function promptExpiry() {
391
+ async function promptExpiry(signal) {
391
392
  const raw = await input({
392
393
  message: "Paste expiresAt (ISO date or ms timestamp):",
393
394
  validate: v => parseExpiryInput(v) !== null || "Enter an ISO date (2027-01-01) or a Unix timestamp in milliseconds",
394
- });
395
+ }, { signal });
395
396
  return parseExpiryInput(raw);
396
397
  }
397
398
  /**
@@ -1,7 +1,8 @@
1
1
  import chalk from "chalk";
2
2
  import { PROXY_PORT } from "../config/paths.js";
3
3
  import { readConfig } from "../config/manager.js";
4
- import { classifyHttpSetupFailure, failAttemptFromError, } from "../telemetry/setup-diagnostics.js";
4
+ import { classifyHttpSetupFailure, failAttemptFromError, isPromptCancellation, } from "../telemetry/setup-diagnostics.js";
5
+ import { withEscapeCancel } from "./prompt-cancel.js";
5
6
  export function resolveStatusTarget(port) {
6
7
  const cfg = readConfig();
7
8
  if (cfg.client) {
@@ -126,6 +127,14 @@ async function dashboardLoop(port) {
126
127
  process.stdin.resume();
127
128
  if (pendingIntent.kind === "quit")
128
129
  return;
130
+ // Ink unref()s stdin when it unmounts, and neither resume() nor
131
+ // readline ref it again. With nothing else keeping the event loop
132
+ // alive, the process exits while inquirer is still waiting for a key —
133
+ // inquirer reports it as "User force closed the prompt with 0 null" and
134
+ // the sign-in never runs. Ref it for the interactive flow; the next
135
+ // dashboard mount takes ownership again, and the quit path above never
136
+ // reaches this line, so a plain quit still exits.
137
+ process.stdin.ref();
129
138
  if (pendingIntent.kind === "addAccount") {
130
139
  // Intent: addAccount — run the OAuth flow, then POST the resulting
131
140
  // tokens to the server we're connected to (local or remote).
@@ -161,11 +170,12 @@ async function runReauthFlow(target, intent) {
161
170
  let attempt;
162
171
  try {
163
172
  const { collectReauthRecord } = await import("./account-flows.js");
164
- const result = await collectReauthRecord({
173
+ console.log(chalk.gray(" Esc or Ctrl-C returns to the dashboard.\n"));
174
+ const result = await withEscapeCancel(signal => collectReauthRecord({
165
175
  id: intent.id,
166
176
  provider: intent.provider,
167
177
  ...(intent.email ? { email: intent.email } : {}),
168
- });
178
+ }, { signal }));
169
179
  if (!result)
170
180
  return null;
171
181
  attempt = result.attempt;
@@ -193,6 +203,10 @@ async function runReauthFlow(target, intent) {
193
203
  return result.record.id;
194
204
  }
195
205
  catch (err) {
206
+ if (isPromptCancellation(err)) {
207
+ console.log(chalk.gray("\n Cancelled."));
208
+ return null;
209
+ }
196
210
  console.error(chalk.red(`\n✗ Re-authentication failed: ${err.message}`));
197
211
  if (attempt) {
198
212
  const outcome = failAttemptFromError(attempt, err, "persistence");
@@ -211,8 +225,9 @@ async function runAddAccountFlow(target) {
211
225
  let attempt;
212
226
  try {
213
227
  const { collectClaudeAccount, accountToRecord } = await import("./account-flows.js");
228
+ console.log(chalk.gray(" Esc or Ctrl-C returns to the dashboard.\n"));
214
229
  // The index shown in the flow is just for display, pick something neutral.
215
- const setup = await collectClaudeAccount({ index: 1 });
230
+ const setup = await withEscapeCancel(signal => collectClaudeAccount({ index: 1, signal }));
216
231
  attempt = setup.attempt;
217
232
  const account = setup.account;
218
233
  if (!account)
@@ -242,6 +257,10 @@ async function runAddAccountFlow(target) {
242
257
  return account.id;
243
258
  }
244
259
  catch (err) {
260
+ if (isPromptCancellation(err)) {
261
+ console.log(chalk.gray("\n Cancelled."));
262
+ return null;
263
+ }
245
264
  console.error(chalk.red(`\n✗ Failed to add account: ${err.message}`));
246
265
  if (attempt) {
247
266
  const outcome = failAttemptFromError(attempt, err, "persistence");
@@ -0,0 +1,21 @@
1
+ import * as readline from "node:readline";
2
+ /** The Escape key itself — not an escape sequence and not an Alt-chord. */
3
+ export function isEscapeKey(key) {
4
+ return key?.name === "escape" && key.sequence === "\x1b";
5
+ }
6
+ export async function withEscapeCancel(flow, stdin = process.stdin) {
7
+ // Idempotent: the prompt's own readline interface installs the same decoder.
8
+ readline.emitKeypressEvents(stdin);
9
+ const controller = new AbortController();
10
+ const onKeypress = (_input, key) => {
11
+ if (isEscapeKey(key))
12
+ controller.abort();
13
+ };
14
+ stdin.on("keypress", onKeypress);
15
+ try {
16
+ return await flow(controller.signal);
17
+ }
18
+ finally {
19
+ stdin.off("keypress", onKeypress);
20
+ }
21
+ }
@@ -224,7 +224,10 @@ export async function withSetupTelemetryFlush(operation) {
224
224
  }
225
225
  }
226
226
  export function isPromptCancellation(error) {
227
- return ownStringProperty(error, "name") === "ExitPromptError";
227
+ const name = ownStringProperty(error, "name");
228
+ // ExitPromptError: Ctrl-C or the process leaving. AbortPromptError: the
229
+ // prompt's AbortSignal fired — the dashboard's Escape key.
230
+ return name === "ExitPromptError" || name === "AbortPromptError";
228
231
  }
229
232
  /**
230
233
  * Close an attempt that ended in a thrown error: a cancelled prompt is a user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timo972/cc-router",
3
- "version": "0.13.0-rc.0",
3
+ "version": "0.13.0-rc.2",
4
4
  "description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
5
5
  "type": "module",
6
6
  "bin": {