@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 +8 -0
- package/dist/cli/account-flows.js +18 -17
- package/dist/cli/cmd-status.js +23 -4
- package/dist/cli/prompt-cancel.js +21 -0
- package/dist/telemetry/setup-diagnostics.js +4 -1
- package/package.json +1 -1
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
|
/**
|
package/dist/cli/cmd-status.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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