@timetotest/cli 0.3.2 → 0.3.4
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/bin/ttt.js +12 -6
- package/dist/bin/ttt.js.map +1 -1
- package/dist/package.json +1 -1
- package/dist/src/commands/chat/ChatApp.js +133 -69
- package/dist/src/commands/chat/ChatApp.js.map +1 -1
- package/dist/src/commands/chat/OnboardingApp.js +20 -6
- package/dist/src/commands/chat/OnboardingApp.js.map +1 -1
- package/dist/src/commands/chat/components/ChatInput.js +6 -6
- package/dist/src/commands/chat/components/ChatInput.js.map +1 -1
- package/dist/src/commands/chat/components/ModelPicker.js +62 -0
- package/dist/src/commands/chat/components/ModelPicker.js.map +1 -0
- package/dist/src/commands/chat/components/ToolCallDisplay.js +45 -28
- package/dist/src/commands/chat/components/ToolCallDisplay.js.map +1 -1
- package/dist/src/commands/chat/tool-message-matcher.js +20 -0
- package/dist/src/commands/chat/tool-message-matcher.js.map +1 -0
- package/dist/src/commands/chat-ink.js +150 -64
- package/dist/src/commands/chat-ink.js.map +1 -1
- package/dist/src/commands/login.js +466 -104
- package/dist/src/commands/login.js.map +1 -1
- package/dist/src/lib/__tests__/login-auth-flow.test.js +117 -0
- package/dist/src/lib/__tests__/login-auth-flow.test.js.map +1 -0
- package/dist/src/lib/__tests__/model-picker-window.test.js +20 -0
- package/dist/src/lib/__tests__/model-picker-window.test.js.map +1 -0
- package/dist/src/lib/__tests__/tool-message-matcher.test.js +26 -0
- package/dist/src/lib/__tests__/tool-message-matcher.test.js.map +1 -0
- package/dist/src/lib/backend-loop-client.js +15 -2
- package/dist/src/lib/backend-loop-client.js.map +1 -1
- package/dist/src/lib/tui/ink/components/TimetoTestLogo.js +1 -1
- package/dist/src/lib/tui/ink/components/TimetoTestLogo.js.map +1 -1
- package/package.json +1 -1
|
@@ -5,6 +5,9 @@ import ora from "ora";
|
|
|
5
5
|
import http from "http";
|
|
6
6
|
import crypto from "crypto";
|
|
7
7
|
import inquirer from "inquirer";
|
|
8
|
+
import fs from "fs-extra";
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
8
11
|
import { setAuthToken, resolveApiUrl } from "../lib/config.js";
|
|
9
12
|
const HANDSHAKE_PAGE = `<!DOCTYPE html>
|
|
10
13
|
<html lang="en">
|
|
@@ -61,95 +64,410 @@ const HANDSHAKE_PAGE = `<!DOCTYPE html>
|
|
|
61
64
|
</script>
|
|
62
65
|
</body>
|
|
63
66
|
</html>`;
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
message: "Email address:",
|
|
72
|
-
validate: (input) => {
|
|
73
|
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
74
|
-
if (!emailRegex.test(input)) {
|
|
75
|
-
return "Please enter a valid email address";
|
|
76
|
-
}
|
|
77
|
-
return true;
|
|
78
|
-
},
|
|
79
|
-
},
|
|
80
|
-
]);
|
|
81
|
-
// Prompt for password (with confirmation)
|
|
82
|
-
const { password } = await inquirer.prompt([
|
|
83
|
-
{
|
|
84
|
-
type: "password",
|
|
85
|
-
name: "password",
|
|
86
|
-
message: "Password (min 6 characters):",
|
|
87
|
-
mask: "*",
|
|
88
|
-
validate: (input) => {
|
|
89
|
-
if (input.length < 6) {
|
|
90
|
-
return "Password must be at least 6 characters";
|
|
91
|
-
}
|
|
92
|
-
return true;
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
]);
|
|
96
|
-
const { confirmPassword } = await inquirer.prompt([
|
|
97
|
-
{
|
|
98
|
-
type: "password",
|
|
99
|
-
name: "confirmPassword",
|
|
100
|
-
message: "Confirm password:",
|
|
101
|
-
mask: "*",
|
|
102
|
-
validate: (input) => {
|
|
103
|
-
if (input !== password) {
|
|
104
|
-
return "Passwords do not match";
|
|
105
|
-
}
|
|
106
|
-
return true;
|
|
107
|
-
},
|
|
108
|
-
},
|
|
109
|
-
]);
|
|
110
|
-
const spinner = ora("Creating your account...").start();
|
|
67
|
+
const DEFAULT_FRONTEND_URL = "https://timetotest.tech";
|
|
68
|
+
const PASSWORD_RESET_PATH = "/auth/reset-password";
|
|
69
|
+
const AUTH_UX_LOG_PATH = path.join(os.homedir(), ".timetotest", "auth-ux-events.log");
|
|
70
|
+
const SIGNUP_EMAIL_HINT = "Email address: (/menu = auth menu, Ctrl+C = exit)";
|
|
71
|
+
const SIGNUP_PASSWORD_HINT = "Password (min 6 characters): (/back = edit email, /menu = auth menu)";
|
|
72
|
+
const SIGNUP_CONFIRM_HINT = "Confirm password: (/back = re-enter password, /menu = auth menu)";
|
|
73
|
+
function recordAuthUxEvent(event, payload = {}) {
|
|
111
74
|
try {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
75
|
+
fs.ensureDirSync(path.dirname(AUTH_UX_LOG_PATH));
|
|
76
|
+
fs.appendFileSync(AUTH_UX_LOG_PATH, `${JSON.stringify({
|
|
77
|
+
ts: new Date().toISOString(),
|
|
78
|
+
event,
|
|
79
|
+
...payload,
|
|
80
|
+
})}\n`, "utf8");
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Telemetry must never break auth flows.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function frontendBaseUrl() {
|
|
87
|
+
return (process.env.TTT_FRONTEND_URL || DEFAULT_FRONTEND_URL).replace(/\/$/, "");
|
|
88
|
+
}
|
|
89
|
+
export function getPasswordResetUrl(email) {
|
|
90
|
+
const base = `${frontendBaseUrl()}${PASSWORD_RESET_PATH}`;
|
|
91
|
+
const query = new URLSearchParams();
|
|
92
|
+
query.set("source", "cli");
|
|
93
|
+
if (email) {
|
|
94
|
+
query.set("email", email);
|
|
95
|
+
}
|
|
96
|
+
return `${base}?${query.toString()}`;
|
|
97
|
+
}
|
|
98
|
+
function getEmailDomain(email) {
|
|
99
|
+
const at = email.indexOf("@");
|
|
100
|
+
if (at <= 0 || at >= email.length - 1)
|
|
101
|
+
return null;
|
|
102
|
+
return email.slice(at + 1).toLowerCase();
|
|
103
|
+
}
|
|
104
|
+
function isPromptCancelled(error) {
|
|
105
|
+
const message = error && typeof error === "object" && "message" in error
|
|
106
|
+
? String(error.message || "").toLowerCase()
|
|
107
|
+
: "";
|
|
108
|
+
const name = error && typeof error === "object" && "name" in error
|
|
109
|
+
? String(error.name || "").toLowerCase()
|
|
110
|
+
: "";
|
|
111
|
+
return (name.includes("exitprompt") ||
|
|
112
|
+
message.includes("force closed") ||
|
|
113
|
+
message.includes("canceled"));
|
|
114
|
+
}
|
|
115
|
+
function asLowerMessage(value) {
|
|
116
|
+
if (typeof value === "string")
|
|
117
|
+
return value.toLowerCase();
|
|
118
|
+
if (value && typeof value === "object" && "message" in value) {
|
|
119
|
+
return String(value.message || "").toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
return "";
|
|
122
|
+
}
|
|
123
|
+
export function formatActionableNetworkError(error) {
|
|
124
|
+
const status = error && typeof error === "object"
|
|
125
|
+
? error.response?.status
|
|
126
|
+
: undefined;
|
|
127
|
+
const code = error && typeof error === "object" && "code" in error
|
|
128
|
+
? String(error.code || "")
|
|
129
|
+
: "";
|
|
130
|
+
const message = asLowerMessage(error);
|
|
131
|
+
if (status && status >= 500) {
|
|
132
|
+
return `TimetoTest backend is temporarily unavailable (HTTP ${status}). Please retry in 30-60 seconds.`;
|
|
133
|
+
}
|
|
134
|
+
if (status === 429) {
|
|
135
|
+
return "Too many auth attempts right now. Wait a minute, then try again.";
|
|
136
|
+
}
|
|
137
|
+
if (code === "ECONNABORTED" || message.includes("timeout")) {
|
|
138
|
+
return "Authentication request timed out. Check your connection and retry.";
|
|
139
|
+
}
|
|
140
|
+
if (code === "ENOTFOUND" ||
|
|
141
|
+
code === "EAI_AGAIN" ||
|
|
142
|
+
code === "ECONNREFUSED" ||
|
|
143
|
+
code === "ECONNRESET" ||
|
|
144
|
+
message.includes("network error") ||
|
|
145
|
+
message.includes("failed to fetch")) {
|
|
146
|
+
return "Cannot reach TimetoTest servers. Check internet/VPN/firewall and retry.";
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
export function formatAuthFlowError(error) {
|
|
151
|
+
const actionable = formatActionableNetworkError(error);
|
|
152
|
+
if (actionable)
|
|
153
|
+
return actionable;
|
|
154
|
+
const message = typeof error === "string"
|
|
155
|
+
? error
|
|
156
|
+
: error && typeof error === "object" && "message" in error
|
|
157
|
+
? String(error.message || "")
|
|
158
|
+
: "";
|
|
159
|
+
const normalized = message.toLowerCase();
|
|
160
|
+
if (normalized.includes("timed out waiting for browser authentication")) {
|
|
161
|
+
return "Timed out waiting for browser authentication. Keep this terminal open, complete login in the browser, then try again.";
|
|
162
|
+
}
|
|
163
|
+
if (normalized.includes("invalid request")) {
|
|
164
|
+
return "Login callback was invalid or expired. Please retry sign-in from the beginning.";
|
|
165
|
+
}
|
|
166
|
+
return message || "Authentication failed.";
|
|
167
|
+
}
|
|
168
|
+
export function restoreInteractiveStdin(stdin = process.stdin) {
|
|
169
|
+
try {
|
|
170
|
+
stdin.ref?.();
|
|
171
|
+
}
|
|
172
|
+
catch { }
|
|
173
|
+
if (stdin.isPaused()) {
|
|
174
|
+
stdin.resume();
|
|
175
|
+
}
|
|
176
|
+
if (stdin.setRawMode) {
|
|
177
|
+
try {
|
|
178
|
+
stdin.setRawMode(false);
|
|
132
179
|
}
|
|
180
|
+
catch { }
|
|
133
181
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
182
|
+
}
|
|
183
|
+
function extractNestedMessage(value) {
|
|
184
|
+
if (!value)
|
|
185
|
+
return null;
|
|
186
|
+
if (typeof value === "string")
|
|
187
|
+
return value;
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
const items = value
|
|
190
|
+
.map((entry) => {
|
|
191
|
+
if (typeof entry === "string")
|
|
192
|
+
return entry;
|
|
193
|
+
if (entry && typeof entry === "object") {
|
|
194
|
+
const msg = entry.msg;
|
|
195
|
+
if (typeof msg === "string")
|
|
196
|
+
return msg;
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
})
|
|
200
|
+
.filter((entry) => Boolean(entry));
|
|
201
|
+
return items.length > 0 ? items.join("; ") : null;
|
|
202
|
+
}
|
|
203
|
+
if (typeof value === "object") {
|
|
204
|
+
const message = value.message;
|
|
205
|
+
if (message !== undefined) {
|
|
206
|
+
const nestedMessage = extractNestedMessage(message);
|
|
207
|
+
if (nestedMessage)
|
|
208
|
+
return nestedMessage;
|
|
139
209
|
}
|
|
140
|
-
|
|
141
|
-
|
|
210
|
+
const detail = value.detail;
|
|
211
|
+
if (detail !== undefined) {
|
|
212
|
+
const nestedDetail = extractNestedMessage(detail);
|
|
213
|
+
if (nestedDetail)
|
|
214
|
+
return nestedDetail;
|
|
142
215
|
}
|
|
143
|
-
|
|
144
|
-
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
export function extractSignupErrorMessage(err) {
|
|
220
|
+
const responseData = err && typeof err === "object"
|
|
221
|
+
? err.response?.data
|
|
222
|
+
: undefined;
|
|
223
|
+
const fromResponse = extractNestedMessage(responseData);
|
|
224
|
+
if (fromResponse)
|
|
225
|
+
return fromResponse;
|
|
226
|
+
const directMessage = err && typeof err === "object" ? err.message : undefined;
|
|
227
|
+
if (typeof directMessage === "string" && directMessage.trim().length > 0) {
|
|
228
|
+
return directMessage;
|
|
229
|
+
}
|
|
230
|
+
return "Unknown error occurred";
|
|
231
|
+
}
|
|
232
|
+
export function normalizeSignupErrorMessage(errorMessage) {
|
|
233
|
+
const normalized = errorMessage.toLowerCase();
|
|
234
|
+
if (normalized.includes("already exists") ||
|
|
235
|
+
normalized.includes("already registered") ||
|
|
236
|
+
normalized.includes("email_exists")) {
|
|
237
|
+
return "This email is already registered. Please use the 'Sign In' option instead.";
|
|
238
|
+
}
|
|
239
|
+
if (normalized.includes("weak_password") || normalized.includes("weak password")) {
|
|
240
|
+
return "Password is too weak. Please use a stronger password.";
|
|
241
|
+
}
|
|
242
|
+
if (normalized.includes("invalid_email") || normalized.includes("invalid email")) {
|
|
243
|
+
return "Invalid email address. Please check and try again.";
|
|
244
|
+
}
|
|
245
|
+
return errorMessage;
|
|
246
|
+
}
|
|
247
|
+
function classifySignupError(errorMessage) {
|
|
248
|
+
const normalized = errorMessage.toLowerCase();
|
|
249
|
+
if (normalized.includes("already exists") ||
|
|
250
|
+
normalized.includes("already registered") ||
|
|
251
|
+
normalized.includes("email_exists")) {
|
|
252
|
+
return "existing_email";
|
|
253
|
+
}
|
|
254
|
+
if (normalized.includes("weak_password") || normalized.includes("weak password")) {
|
|
255
|
+
return "weak_password";
|
|
256
|
+
}
|
|
257
|
+
if (normalized.includes("invalid_email") || normalized.includes("invalid email")) {
|
|
258
|
+
return "invalid_email";
|
|
259
|
+
}
|
|
260
|
+
if (normalized.includes("cannot reach timetotest servers") ||
|
|
261
|
+
normalized.includes("backend is temporarily unavailable") ||
|
|
262
|
+
normalized.includes("timed out")) {
|
|
263
|
+
return "network";
|
|
264
|
+
}
|
|
265
|
+
return "other";
|
|
266
|
+
}
|
|
267
|
+
async function openPasswordReset(email) {
|
|
268
|
+
const url = getPasswordResetUrl(email);
|
|
269
|
+
console.error(`\nPassword reset: ${url}`);
|
|
270
|
+
try {
|
|
271
|
+
await open(url);
|
|
272
|
+
console.error("Opened password reset in your browser.");
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
console.error("Couldn't open browser automatically. Open the URL above manually.");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
export async function performCliSignup() {
|
|
279
|
+
// Ink onboarding may unref stdin; restore it before running inquirer prompts.
|
|
280
|
+
restoreInteractiveStdin();
|
|
281
|
+
console.error("\nWelcome! Let's create your Time to Test account.\n");
|
|
282
|
+
let step = "email";
|
|
283
|
+
let email = "";
|
|
284
|
+
let password = "";
|
|
285
|
+
while (true) {
|
|
286
|
+
try {
|
|
287
|
+
if (step === "email") {
|
|
288
|
+
const result = await inquirer.prompt([
|
|
289
|
+
{
|
|
290
|
+
type: "input",
|
|
291
|
+
name: "email",
|
|
292
|
+
message: SIGNUP_EMAIL_HINT,
|
|
293
|
+
validate: (input) => {
|
|
294
|
+
const trimmed = input.trim();
|
|
295
|
+
if (trimmed === "/menu")
|
|
296
|
+
return true;
|
|
297
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
298
|
+
if (!emailRegex.test(trimmed)) {
|
|
299
|
+
return "Please enter a valid email address (or /menu)";
|
|
300
|
+
}
|
|
301
|
+
return true;
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
]);
|
|
305
|
+
const candidate = String(result.email || "").trim();
|
|
306
|
+
if (candidate === "/menu") {
|
|
307
|
+
recordAuthUxEvent("signup_user_back_to_auth_menu", { stage: "email" });
|
|
308
|
+
return "back";
|
|
309
|
+
}
|
|
310
|
+
email = candidate;
|
|
311
|
+
step = "password";
|
|
312
|
+
recordAuthUxEvent("signup_email_collected", {
|
|
313
|
+
email_domain: getEmailDomain(email),
|
|
314
|
+
});
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
if (step === "password") {
|
|
318
|
+
const result = await inquirer.prompt([
|
|
319
|
+
{
|
|
320
|
+
type: "password",
|
|
321
|
+
name: "password",
|
|
322
|
+
message: SIGNUP_PASSWORD_HINT,
|
|
323
|
+
mask: "*",
|
|
324
|
+
validate: (input) => {
|
|
325
|
+
if (input === "/back" || input === "/menu")
|
|
326
|
+
return true;
|
|
327
|
+
if (input.length < 6) {
|
|
328
|
+
return "Password must be at least 6 characters (or /back, /menu)";
|
|
329
|
+
}
|
|
330
|
+
return true;
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
]);
|
|
334
|
+
const candidate = String(result.password || "");
|
|
335
|
+
if (candidate === "/menu") {
|
|
336
|
+
recordAuthUxEvent("signup_user_back_to_auth_menu", { stage: "password" });
|
|
337
|
+
return "back";
|
|
338
|
+
}
|
|
339
|
+
if (candidate === "/back") {
|
|
340
|
+
step = "email";
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
password = candidate;
|
|
344
|
+
step = "confirm";
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const confirmResult = await inquirer.prompt([
|
|
348
|
+
{
|
|
349
|
+
type: "password",
|
|
350
|
+
name: "confirmPassword",
|
|
351
|
+
message: SIGNUP_CONFIRM_HINT,
|
|
352
|
+
mask: "*",
|
|
353
|
+
validate: (input) => {
|
|
354
|
+
if (input === "/back" || input === "/menu")
|
|
355
|
+
return true;
|
|
356
|
+
if (input !== password) {
|
|
357
|
+
return "Passwords do not match (or /back, /menu)";
|
|
358
|
+
}
|
|
359
|
+
return true;
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
]);
|
|
363
|
+
const confirmPassword = String(confirmResult.confirmPassword || "");
|
|
364
|
+
if (confirmPassword === "/menu") {
|
|
365
|
+
recordAuthUxEvent("signup_user_back_to_auth_menu", { stage: "confirm" });
|
|
366
|
+
return "back";
|
|
367
|
+
}
|
|
368
|
+
if (confirmPassword === "/back") {
|
|
369
|
+
step = "password";
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const spinner = ora("Creating your account...").start();
|
|
373
|
+
try {
|
|
374
|
+
const apiUrl = resolveApiUrl();
|
|
375
|
+
recordAuthUxEvent("signup_submit_attempt", {
|
|
376
|
+
email_domain: getEmailDomain(email),
|
|
377
|
+
});
|
|
378
|
+
const signUpResp = await axios.post(`${apiUrl}/api/v1/auth/signup`, {
|
|
379
|
+
email,
|
|
380
|
+
password,
|
|
381
|
+
}, {
|
|
382
|
+
headers: {
|
|
383
|
+
"X-CLI-Signup": "true",
|
|
384
|
+
},
|
|
385
|
+
timeout: 20000,
|
|
386
|
+
});
|
|
387
|
+
const cliToken = signUpResp.data.cli_token;
|
|
388
|
+
if (!cliToken) {
|
|
389
|
+
throw new Error("No CLI token returned from signup");
|
|
390
|
+
}
|
|
391
|
+
setAuthToken(cliToken);
|
|
392
|
+
spinner.succeed("Account created successfully!");
|
|
393
|
+
console.error(`\nYou're all set! Logged in as ${email}.`);
|
|
394
|
+
recordAuthUxEvent("signup_success", {
|
|
395
|
+
email_domain: getEmailDomain(email),
|
|
396
|
+
});
|
|
397
|
+
return "authenticated";
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
spinner.fail("Sign up failed");
|
|
401
|
+
const actionableNetworkError = formatActionableNetworkError(err);
|
|
402
|
+
const normalizedMessage = actionableNetworkError
|
|
403
|
+
? actionableNetworkError
|
|
404
|
+
: normalizeSignupErrorMessage(extractSignupErrorMessage(err));
|
|
405
|
+
const kind = classifySignupError(normalizedMessage);
|
|
406
|
+
recordAuthUxEvent("signup_failed", {
|
|
407
|
+
kind,
|
|
408
|
+
email_domain: getEmailDomain(email),
|
|
409
|
+
});
|
|
410
|
+
if (kind === "weak_password") {
|
|
411
|
+
console.error(`\n${normalizedMessage}`);
|
|
412
|
+
step = "password";
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (kind === "invalid_email") {
|
|
416
|
+
console.error(`\n${normalizedMessage}`);
|
|
417
|
+
step = "email";
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
if (kind === "existing_email") {
|
|
421
|
+
const { recoveryChoice } = await inquirer.prompt([
|
|
422
|
+
{
|
|
423
|
+
type: "list",
|
|
424
|
+
name: "recoveryChoice",
|
|
425
|
+
message: `${normalizedMessage}\nChoose next step:`,
|
|
426
|
+
choices: [
|
|
427
|
+
{ name: "Sign In with this account now", value: "signin" },
|
|
428
|
+
{ name: "Try a different email", value: "retry_email" },
|
|
429
|
+
{ name: "Forgot password (open reset page)", value: "forgot_password" },
|
|
430
|
+
{ name: "Back to auth menu", value: "menu" },
|
|
431
|
+
],
|
|
432
|
+
},
|
|
433
|
+
]);
|
|
434
|
+
recordAuthUxEvent("signup_existing_email_recovery_choice", {
|
|
435
|
+
choice: recoveryChoice,
|
|
436
|
+
email_domain: getEmailDomain(email),
|
|
437
|
+
});
|
|
438
|
+
if (recoveryChoice === "signin") {
|
|
439
|
+
await performInteractiveLogin();
|
|
440
|
+
return "authenticated";
|
|
441
|
+
}
|
|
442
|
+
if (recoveryChoice === "forgot_password") {
|
|
443
|
+
await openPasswordReset(email);
|
|
444
|
+
step = "email";
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (recoveryChoice === "retry_email") {
|
|
448
|
+
step = "email";
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
return "back";
|
|
452
|
+
}
|
|
453
|
+
throw new Error(normalizedMessage);
|
|
454
|
+
}
|
|
145
455
|
}
|
|
146
|
-
|
|
147
|
-
|
|
456
|
+
catch (error) {
|
|
457
|
+
if (isPromptCancelled(error)) {
|
|
458
|
+
recordAuthUxEvent("signup_cancelled", {
|
|
459
|
+
email_domain: getEmailDomain(email),
|
|
460
|
+
stage: step,
|
|
461
|
+
});
|
|
462
|
+
return "back";
|
|
463
|
+
}
|
|
464
|
+
throw error;
|
|
148
465
|
}
|
|
149
|
-
process.exitCode = 1;
|
|
150
466
|
}
|
|
151
467
|
}
|
|
152
468
|
export async function performInteractiveLogin() {
|
|
469
|
+
restoreInteractiveStdin();
|
|
470
|
+
recordAuthUxEvent("signin_started");
|
|
153
471
|
const spinner = ora("Starting local callback server").start();
|
|
154
472
|
const state = crypto.randomBytes(16).toString("hex");
|
|
155
473
|
let resolveLogin = null;
|
|
@@ -178,7 +496,7 @@ export async function performInteractiveLogin() {
|
|
|
178
496
|
if (!token || !incomingState || incomingState !== state) {
|
|
179
497
|
res.statusCode = 400;
|
|
180
498
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
181
|
-
res.end("<html><body><h3>Invalid request</h3></body></html>");
|
|
499
|
+
res.end("<html><body><h3>Invalid request</h3><p>The login callback was missing data or expired. Please return to the terminal and retry.</p></body></html>");
|
|
182
500
|
return;
|
|
183
501
|
}
|
|
184
502
|
setAuthToken(token);
|
|
@@ -259,26 +577,33 @@ export async function performInteractiveLogin() {
|
|
|
259
577
|
});
|
|
260
578
|
await loginPromise;
|
|
261
579
|
const callbackUrl = `http://127.0.0.1:${serverPort}/ttt-cli-callback`;
|
|
262
|
-
const frontendBase = (
|
|
580
|
+
const frontendBase = frontendBaseUrl();
|
|
263
581
|
const pageUrl = `${frontendBase}/cli-auth?callback=${encodeURIComponent(callbackUrl)}&state=${encodeURIComponent(state)}`;
|
|
264
582
|
spinner.succeed("Local callback ready");
|
|
265
583
|
console.error("Opening browser to authenticate…");
|
|
266
584
|
try {
|
|
267
|
-
|
|
585
|
+
await open(pageUrl);
|
|
586
|
+
}
|
|
587
|
+
catch (openError) {
|
|
588
|
+
recordAuthUxEvent("signin_browser_open_failed");
|
|
589
|
+
console.error("Could not open your browser automatically.");
|
|
590
|
+
console.error(`Open this URL manually: ${pageUrl}`);
|
|
591
|
+
throw new Error(formatAuthFlowError(openError));
|
|
268
592
|
}
|
|
269
|
-
catch { }
|
|
270
|
-
await open(pageUrl);
|
|
271
593
|
const waitForCallback = new Promise((resolve, reject) => {
|
|
272
594
|
resolveLogin = () => resolve();
|
|
273
|
-
setTimeout(() => reject(new Error("Timed out waiting for browser authentication")), 5 * 60 * 1000);
|
|
595
|
+
setTimeout(() => reject(new Error("Timed out waiting for browser authentication. Complete sign in and callback within 5 minutes.")), 5 * 60 * 1000);
|
|
274
596
|
});
|
|
275
597
|
try {
|
|
276
598
|
await waitForCallback;
|
|
277
599
|
ora().succeed("Login success");
|
|
600
|
+
recordAuthUxEvent("signin_success");
|
|
278
601
|
}
|
|
279
602
|
catch (e) {
|
|
280
|
-
|
|
281
|
-
|
|
603
|
+
const formattedError = formatAuthFlowError(e);
|
|
604
|
+
recordAuthUxEvent("signin_failed", { message: formattedError });
|
|
605
|
+
ora().fail(formattedError);
|
|
606
|
+
throw new Error(formattedError);
|
|
282
607
|
}
|
|
283
608
|
finally {
|
|
284
609
|
server.close();
|
|
@@ -290,26 +615,63 @@ export const login = new Command("login")
|
|
|
290
615
|
.action(async (opts) => {
|
|
291
616
|
if (opts.token) {
|
|
292
617
|
setAuthToken(opts.token);
|
|
618
|
+
recordAuthUxEvent("login_token_success");
|
|
293
619
|
console.error("Login success via token");
|
|
294
620
|
return;
|
|
295
621
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
622
|
+
while (true) {
|
|
623
|
+
restoreInteractiveStdin();
|
|
624
|
+
let authChoice;
|
|
625
|
+
try {
|
|
626
|
+
// Show interactive prompt to choose Sign In or Sign Up
|
|
627
|
+
const promptResult = await inquirer.prompt([
|
|
628
|
+
{
|
|
629
|
+
type: "list",
|
|
630
|
+
name: "authChoice",
|
|
631
|
+
message: "What would you like to do?",
|
|
632
|
+
choices: [
|
|
633
|
+
{ name: "Sign In (existing user)", value: "signin" },
|
|
634
|
+
{ name: "Sign Up (new user)", value: "signup" },
|
|
635
|
+
{ name: "Forgot Password", value: "forgot_password" },
|
|
636
|
+
{ name: "Exit", value: "exit" },
|
|
637
|
+
],
|
|
638
|
+
},
|
|
639
|
+
]);
|
|
640
|
+
authChoice = promptResult.authChoice;
|
|
641
|
+
}
|
|
642
|
+
catch (promptError) {
|
|
643
|
+
if (isPromptCancelled(promptError)) {
|
|
644
|
+
recordAuthUxEvent("login_menu_cancelled");
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
throw promptError;
|
|
648
|
+
}
|
|
649
|
+
recordAuthUxEvent("login_menu_choice", { choice: authChoice });
|
|
650
|
+
if (authChoice === "exit") {
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (authChoice === "forgot_password") {
|
|
654
|
+
await openPasswordReset();
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
if (authChoice === "signup") {
|
|
659
|
+
const result = await performCliSignup();
|
|
660
|
+
if (result === "authenticated") {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
// If signin, use browser-based flow
|
|
666
|
+
await performInteractiveLogin();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
const actionable = formatActionableNetworkError(error);
|
|
671
|
+
const message = actionable || error?.message || "Authentication failed.";
|
|
672
|
+
recordAuthUxEvent("login_flow_error", { message });
|
|
673
|
+
console.error(`\n❌ ${message}\nRetry with Sign In/Sign Up, choose Forgot Password, or Exit.`);
|
|
674
|
+
}
|
|
311
675
|
}
|
|
312
|
-
// If signin, use browser-based flow
|
|
313
|
-
await performInteractiveLogin();
|
|
314
676
|
});
|
|
315
677
|
//# sourceMappingURL=login.js.map
|