@tailglow/cli 0.9.5 → 0.9.15
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/package.json +2 -2
- package/src/commands.generated.js +144 -128
- package/src/config-command.js +7 -0
- package/src/config.js +33 -0
- package/src/docs-command.js +6 -0
- package/src/docs.generated.js +21 -21
- package/src/flags.js +15 -0
- package/src/help.js +4 -0
- package/src/import-command.js +111 -10
- package/src/import-file.js +47 -0
- package/src/import-worker.generated.js +5 -1
- package/src/import.generated.js +5 -1
- package/src/index.js +34 -0
- package/src/login-command.js +118 -6
- package/src/output.js +13 -2
- package/src/request.js +18 -0
- package/src/update-check.js +22 -0
package/src/login-command.js
CHANGED
|
@@ -62,13 +62,16 @@ const WITHDRAWN_MESSAGES = {
|
|
|
62
62
|
function openBrowser(url) {
|
|
63
63
|
let command = "xdg-open";
|
|
64
64
|
let args = [url];
|
|
65
|
+
|
|
65
66
|
if (process.platform === "darwin") command = "open";
|
|
66
67
|
if (process.platform === "win32") {
|
|
67
68
|
command = "rundll32";
|
|
68
69
|
args = ["url.dll,FileProtocolHandler", url];
|
|
69
70
|
}
|
|
71
|
+
|
|
70
72
|
try {
|
|
71
73
|
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
74
|
+
|
|
72
75
|
child.on("error", () => {});
|
|
73
76
|
child.unref();
|
|
74
77
|
} catch {
|
|
@@ -87,18 +90,22 @@ function openBrowser(url) {
|
|
|
87
90
|
function readVerificationUrl(value) {
|
|
88
91
|
/** @type {URL | null} */
|
|
89
92
|
let url = null;
|
|
93
|
+
|
|
90
94
|
try {
|
|
91
95
|
url = new URL(String(value));
|
|
92
96
|
} catch {
|
|
93
97
|
url = null;
|
|
94
98
|
}
|
|
99
|
+
|
|
95
100
|
const is_secure = url?.protocol === "https:";
|
|
96
101
|
const is_local = url?.protocol === "http:" && LOCAL_HOSTNAMES.has(url.hostname);
|
|
102
|
+
|
|
97
103
|
if (!url || !(is_secure || is_local)) {
|
|
98
104
|
throw new Error(
|
|
99
105
|
"The API answered with a sign-in link that is not an https address. Check --api-url."
|
|
100
106
|
);
|
|
101
107
|
}
|
|
108
|
+
|
|
102
109
|
return url.href;
|
|
103
110
|
}
|
|
104
111
|
|
|
@@ -108,6 +115,7 @@ export function createLoginContext() {
|
|
|
108
115
|
let indicator = null;
|
|
109
116
|
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
110
117
|
let countdown;
|
|
118
|
+
|
|
111
119
|
return {
|
|
112
120
|
config_path: CLI_CONFIGS.CONFIG_PATH,
|
|
113
121
|
fetch_impl: fetch,
|
|
@@ -115,6 +123,7 @@ export function createLoginContext() {
|
|
|
115
123
|
emits_json: (flags) => shouldEmitJson(flags),
|
|
116
124
|
choose_method: async () => {
|
|
117
125
|
log.info("Use `tglow help` to browse commands without signing in.");
|
|
126
|
+
|
|
118
127
|
const answer = await select({
|
|
119
128
|
message: "How do you want to sign in?",
|
|
120
129
|
options: [
|
|
@@ -129,7 +138,9 @@ export function createLoginContext() {
|
|
|
129
138
|
{ value: "cancel", label: "Cancel" }
|
|
130
139
|
]
|
|
131
140
|
});
|
|
141
|
+
|
|
132
142
|
if (isCancel(answer) || answer === "cancel") return null;
|
|
143
|
+
|
|
133
144
|
return answer === "api_key" ? "api_key" : "account";
|
|
134
145
|
},
|
|
135
146
|
open_browser: openBrowser,
|
|
@@ -139,17 +150,22 @@ export function createLoginContext() {
|
|
|
139
150
|
progress: {
|
|
140
151
|
start: (text, expires_at) => {
|
|
141
152
|
indicator ??= spinner();
|
|
153
|
+
|
|
142
154
|
const message = () => {
|
|
143
155
|
if (expires_at === undefined) return text;
|
|
156
|
+
|
|
144
157
|
const seconds = Math.max(0, Math.ceil((expires_at - Date.now()) / 1_000));
|
|
158
|
+
|
|
145
159
|
return `${text} (code expires in ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")})`;
|
|
146
160
|
};
|
|
161
|
+
|
|
147
162
|
indicator.start(message());
|
|
148
163
|
clearInterval(countdown);
|
|
149
164
|
countdown = setInterval(() => indicator?.message(message()), 1_000);
|
|
150
165
|
},
|
|
151
166
|
stop: (text, outcome = "success") => {
|
|
152
167
|
clearInterval(countdown);
|
|
168
|
+
|
|
153
169
|
if (outcome === "cancel") indicator?.cancel(text);
|
|
154
170
|
else if (outcome === "error") indicator?.error(text);
|
|
155
171
|
else indicator?.stop(text);
|
|
@@ -167,9 +183,13 @@ export function createLoginContext() {
|
|
|
167
183
|
*/
|
|
168
184
|
export async function shouldOfferLogin(flags, context) {
|
|
169
185
|
if (flags.help || context.emits_json(flags) || !context.is_interactive()) return false;
|
|
186
|
+
|
|
170
187
|
const config = await loadConfig(context.config_path);
|
|
188
|
+
|
|
171
189
|
if (resolveSetting("api_key", { flags, config })) return false;
|
|
190
|
+
|
|
172
191
|
const login = readStoredLogin(config);
|
|
192
|
+
|
|
173
193
|
return !login || !isSameApiUrl(login.api_url, resolveApiUrl({ flags, config }));
|
|
174
194
|
}
|
|
175
195
|
|
|
@@ -179,8 +199,10 @@ export async function shouldOfferLogin(flags, context) {
|
|
|
179
199
|
*/
|
|
180
200
|
function readSwitch(flags, name) {
|
|
181
201
|
const value = flags[name];
|
|
202
|
+
|
|
182
203
|
if (value === undefined || value === false || value === "false") return false;
|
|
183
204
|
if (value === true || value === "true") return true;
|
|
205
|
+
|
|
184
206
|
throw new Error(`--${name} takes no value, or =true / =false. Received "${String(value)}".`);
|
|
185
207
|
}
|
|
186
208
|
|
|
@@ -194,12 +216,15 @@ function readSwitch(flags, name) {
|
|
|
194
216
|
*/
|
|
195
217
|
function refuseUnexpected(words, flags, allowed) {
|
|
196
218
|
const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
|
|
219
|
+
|
|
197
220
|
if (unknown.length > 0) {
|
|
198
221
|
throw new Error(
|
|
199
222
|
`Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((flag) => `--${flag}`).join(", ")}.`
|
|
200
223
|
);
|
|
201
224
|
}
|
|
225
|
+
|
|
202
226
|
const extra = words.filter((word) => word !== undefined);
|
|
227
|
+
|
|
203
228
|
if (extra.length > 0) {
|
|
204
229
|
throw new Error(`Unexpected argument${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}.`);
|
|
205
230
|
}
|
|
@@ -216,6 +241,7 @@ function respond(view, { text, fields, code = 0 }) {
|
|
|
216
241
|
/** @param {import("./types.d.ts").ApiResponse | null} payload @param {number} status */
|
|
217
242
|
function describeRefusal(payload, status) {
|
|
218
243
|
const message = payload?.error?.message || payload?.message || `HTTP ${status}`;
|
|
244
|
+
|
|
219
245
|
return `${status} ${sanitizeText(String(message))}`;
|
|
220
246
|
}
|
|
221
247
|
|
|
@@ -234,9 +260,11 @@ async function startLogin(method, context, api_url) {
|
|
|
234
260
|
fetch_impl: context.fetch_impl,
|
|
235
261
|
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
236
262
|
});
|
|
263
|
+
|
|
237
264
|
if (!ok) throw new Error(`Could not start signing in: ${describeRefusal(payload, status)}`);
|
|
238
265
|
|
|
239
266
|
const data = /** @type {Record<string, unknown>} */ (payload?.data ?? {});
|
|
267
|
+
|
|
240
268
|
for (const name of ["id", "user_code", "verification_url", "poll_secret"]) {
|
|
241
269
|
if (typeof data[name] !== "string") {
|
|
242
270
|
throw new Error(`The API answered without a ${name}. Check --api-url.`);
|
|
@@ -244,9 +272,11 @@ async function startLogin(method, context, api_url) {
|
|
|
244
272
|
}
|
|
245
273
|
|
|
246
274
|
const expires_at = Date.parse(String(data.expires_at));
|
|
275
|
+
|
|
247
276
|
if (!Number.isFinite(expires_at)) {
|
|
248
277
|
throw new Error("The API answered without a valid sign-in expiry. Check --api-url.");
|
|
249
278
|
}
|
|
279
|
+
|
|
250
280
|
/** @type {import("./types.d.ts").PendingLogin} */
|
|
251
281
|
const pending = {
|
|
252
282
|
id: String(data.id),
|
|
@@ -258,6 +288,7 @@ async function startLogin(method, context, api_url) {
|
|
|
258
288
|
started_at: context.now(),
|
|
259
289
|
expires_at
|
|
260
290
|
};
|
|
291
|
+
|
|
261
292
|
await updateConfig((current) => ({ ...current, pending_login: pending }), context.config_path);
|
|
262
293
|
return pending;
|
|
263
294
|
}
|
|
@@ -286,6 +317,7 @@ async function pollLogin(pending, context) {
|
|
|
286
317
|
|
|
287
318
|
/** @type {Awaited<ReturnType<typeof sendRequest>>} */
|
|
288
319
|
let response;
|
|
320
|
+
|
|
289
321
|
try {
|
|
290
322
|
response = await sendRequest({
|
|
291
323
|
method: "POST",
|
|
@@ -312,6 +344,7 @@ async function pollLogin(pending, context) {
|
|
|
312
344
|
data.credential && typeof data.credential === "object"
|
|
313
345
|
? /** @type {Record<string, unknown>} */ (data.credential)
|
|
314
346
|
: null;
|
|
347
|
+
|
|
315
348
|
return {
|
|
316
349
|
status: typeof data.status === "string" ? data.status : "retry",
|
|
317
350
|
status_reason: typeof data.status_reason === "string" ? data.status_reason : null,
|
|
@@ -340,25 +373,35 @@ async function pollUntilAnswered(
|
|
|
340
373
|
let interval_ms = LOGIN_CONFIGS.DEFAULT_INTERVAL_MS;
|
|
341
374
|
let wait_ms = poll_first ? 0 : interval_ms;
|
|
342
375
|
let last_status = null;
|
|
376
|
+
|
|
343
377
|
while (true) {
|
|
344
378
|
if (context.now() + wait_ms >= deadline_ms) {
|
|
345
379
|
if (poll_at_deadline) {
|
|
346
380
|
// Keep the server's polling interval even when the expiry falls between two polls.
|
|
347
381
|
if (wait_ms > 0) await context.sleep(wait_ms);
|
|
382
|
+
|
|
348
383
|
const result = await pollLogin(pending, context);
|
|
384
|
+
|
|
349
385
|
if (!["pending", "slow_down", "retry"].includes(result.status)) return result;
|
|
386
|
+
|
|
350
387
|
last_status = result.status;
|
|
351
388
|
}
|
|
389
|
+
|
|
352
390
|
const status = last_status === "retry" ? "unreachable" : "pending";
|
|
391
|
+
|
|
353
392
|
return { status, status_reason: null, interval_ms, credential: null };
|
|
354
393
|
}
|
|
394
|
+
|
|
355
395
|
if (wait_ms > 0) await context.sleep(wait_ms);
|
|
356
396
|
|
|
357
397
|
const result = await pollLogin(pending, context);
|
|
398
|
+
|
|
358
399
|
last_status = result.status;
|
|
359
400
|
interval_ms = Math.max(interval_ms, result.interval_ms);
|
|
401
|
+
|
|
360
402
|
if (result.status === "slow_down") interval_ms += LOGIN_CONFIGS.SLOW_DOWN_STEP_MS;
|
|
361
403
|
if (!["pending", "slow_down", "retry"].includes(result.status)) return result;
|
|
404
|
+
|
|
362
405
|
wait_ms = interval_ms;
|
|
363
406
|
}
|
|
364
407
|
}
|
|
@@ -373,6 +416,7 @@ async function pollUntilAnswered(
|
|
|
373
416
|
*/
|
|
374
417
|
async function removeIssuedCredential(login, context) {
|
|
375
418
|
const is_session = login.type === "session";
|
|
419
|
+
|
|
376
420
|
try {
|
|
377
421
|
const { ok, status, payload } = await sendRequest({
|
|
378
422
|
method: is_session ? "POST" : "DELETE",
|
|
@@ -385,9 +429,11 @@ async function removeIssuedCredential(login, context) {
|
|
|
385
429
|
fetch_impl: context.fetch_impl,
|
|
386
430
|
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
387
431
|
});
|
|
432
|
+
|
|
388
433
|
if (ok) return "removed";
|
|
389
434
|
if (payload?.error?.code === "auth_invalid_credentials") return "already_gone";
|
|
390
435
|
if (status >= 500) return "unreachable";
|
|
436
|
+
|
|
391
437
|
return "refused";
|
|
392
438
|
} catch {
|
|
393
439
|
return "unreachable";
|
|
@@ -434,13 +480,17 @@ async function finishLogin(pending, credential, view, context) {
|
|
|
434
480
|
// narrowing of a local would lose it.
|
|
435
481
|
/** @type {{ previous: import("./types.d.ts").StoredLogin | null; is_current: boolean }} */
|
|
436
482
|
const swap = { previous: null, is_current: false };
|
|
483
|
+
|
|
437
484
|
try {
|
|
438
485
|
await updateConfig((current) => {
|
|
439
486
|
// `tglow logout` or `tglow login --new` discarded this sign-in while it waited.
|
|
440
487
|
if (readPendingLogin(current)?.id !== pending.id) return current;
|
|
488
|
+
|
|
441
489
|
swap.is_current = true;
|
|
442
490
|
swap.previous = readStoredLogin(current);
|
|
491
|
+
|
|
443
492
|
const { pending_login: _collected, ...rest } = current;
|
|
493
|
+
|
|
444
494
|
return { ...rest, login };
|
|
445
495
|
}, context.config_path);
|
|
446
496
|
} catch (err) {
|
|
@@ -450,6 +500,7 @@ async function finishLogin(pending, credential, view, context) {
|
|
|
450
500
|
outcome === "removed" || outcome === "already_gone"
|
|
451
501
|
? "so it was ended"
|
|
452
502
|
: `and it is still active: remove it in ${describeCleanupPlace(login)}`;
|
|
503
|
+
|
|
453
504
|
throw new Error(
|
|
454
505
|
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
455
506
|
`The sign-in was approved, but its credential could not be stored here, ${fate}. Run \`tglow login --new\` to start again.`
|
|
@@ -462,6 +513,7 @@ async function finishLogin(pending, credential, view, context) {
|
|
|
462
513
|
const text = is_removed
|
|
463
514
|
? "This sign-in was cancelled here while it waited, so its credential was ended instead of stored."
|
|
464
515
|
: `This sign-in was cancelled here while it waited, so its credential was not stored. Remove it in ${describeCleanupPlace(login)}.`;
|
|
516
|
+
|
|
465
517
|
return respond(view, {
|
|
466
518
|
text,
|
|
467
519
|
fields: { status: "cancelled", credential_id: login.id, removed: outcome, error: text },
|
|
@@ -471,6 +523,7 @@ async function finishLogin(pending, credential, view, context) {
|
|
|
471
523
|
|
|
472
524
|
const replaced = swap.previous;
|
|
473
525
|
let cleanup = "none";
|
|
526
|
+
|
|
474
527
|
if (replaced && replaced.secret !== login.secret) {
|
|
475
528
|
cleanup = await removeIssuedCredential(replaced, context);
|
|
476
529
|
}
|
|
@@ -479,11 +532,13 @@ async function finishLogin(pending, credential, view, context) {
|
|
|
479
532
|
const lines = [
|
|
480
533
|
`Signed in as ${sanitizeText(login.email)} (team ${sanitizeText(login.team_id)}, ${kind}).`
|
|
481
534
|
];
|
|
535
|
+
|
|
482
536
|
if (replaced && (cleanup === "refused" || cleanup === "unreachable")) {
|
|
483
537
|
lines.push(
|
|
484
538
|
`Could not remove the credential this replaced. Remove it in ${describeCleanupPlace(replaced)}.`
|
|
485
539
|
);
|
|
486
540
|
}
|
|
541
|
+
|
|
487
542
|
lines.push(`Stored in ${context.config_path}, readable only by you.`);
|
|
488
543
|
|
|
489
544
|
return respond(view, {
|
|
@@ -509,7 +564,9 @@ async function finishLogin(pending, credential, view, context) {
|
|
|
509
564
|
async function forgetPendingLogin(pending_id, context) {
|
|
510
565
|
await updateConfig((current) => {
|
|
511
566
|
if (readPendingLogin(current)?.id !== pending_id) return current;
|
|
567
|
+
|
|
512
568
|
const { pending_login: _forgotten, ...rest } = current;
|
|
569
|
+
|
|
513
570
|
return rest;
|
|
514
571
|
}, context.config_path);
|
|
515
572
|
}
|
|
@@ -525,10 +582,13 @@ function describeEnded(result) {
|
|
|
525
582
|
"If this terminal never received it, remove the unused one in the Tailglow app."
|
|
526
583
|
);
|
|
527
584
|
}
|
|
585
|
+
|
|
528
586
|
if (result.status === "revoked") {
|
|
529
587
|
const why = WITHDRAWN_MESSAGES[result.status_reason ?? ""] ?? "its approval no longer holds";
|
|
588
|
+
|
|
530
589
|
return `The approval was withdrawn: ${why}.`;
|
|
531
590
|
}
|
|
591
|
+
|
|
532
592
|
return `The sign-in ended (${result.status}).`;
|
|
533
593
|
}
|
|
534
594
|
|
|
@@ -540,6 +600,7 @@ function respondEnded(view, result) {
|
|
|
540
600
|
// A collected sign-in stays pending for the process that collected it, so only --new replaces it.
|
|
541
601
|
const next = result.status === "consumed" ? "tglow login --new" : "tglow login";
|
|
542
602
|
const message = `${describeEnded(result)} Run \`${next}\` to start again.`;
|
|
603
|
+
|
|
543
604
|
return respond(view, {
|
|
544
605
|
text: message,
|
|
545
606
|
fields: { status: result.status, status_reason: result.status_reason, error: message },
|
|
@@ -553,6 +614,7 @@ function respondEnded(view, result) {
|
|
|
553
614
|
*/
|
|
554
615
|
function respondUnreachable(view, pending) {
|
|
555
616
|
const text = `Could not reach ${sanitizeText(pending.api_url)}. The sign-in is kept: check the connection, then run \`tglow login --wait\`.`;
|
|
617
|
+
|
|
556
618
|
return respond(view, {
|
|
557
619
|
text,
|
|
558
620
|
fields: { status: "unreachable", api_url: pending.api_url, error: text },
|
|
@@ -569,6 +631,7 @@ function respondPending(view, pending, { code = 0, is_waiting = false } = {}) {
|
|
|
569
631
|
const next = "tglow login --wait";
|
|
570
632
|
const opening = is_waiting ? "Still waiting for approval." : "To sign in, open this link:";
|
|
571
633
|
const link = sanitizeText(pending.verification_url);
|
|
634
|
+
|
|
572
635
|
return respond(view, {
|
|
573
636
|
text: [
|
|
574
637
|
opening,
|
|
@@ -659,24 +722,30 @@ const LOGIN_FLAGS = new Set(["json", "help", "api-url", "method", "wait", "new"]
|
|
|
659
722
|
*/
|
|
660
723
|
export async function runLoginCommand(words, flags, context = createLoginContext()) {
|
|
661
724
|
refuseUnexpected(words, flags, LOGIN_FLAGS);
|
|
725
|
+
|
|
662
726
|
const view = { json: context.emits_json(flags) };
|
|
727
|
+
|
|
663
728
|
if (readSwitch(flags, "help")) {
|
|
664
729
|
return respond(view, { text: renderLoginHelp(), fields: describeLoginForMachines() });
|
|
665
730
|
}
|
|
666
731
|
|
|
667
732
|
const is_waiting = readSwitch(flags, "wait");
|
|
668
733
|
const is_new = readSwitch(flags, "new");
|
|
734
|
+
|
|
669
735
|
if (is_waiting && (is_new || flags.method !== undefined)) {
|
|
670
736
|
throw new Error("--wait finishes the pending sign-in, so it takes no --new or --method.");
|
|
671
737
|
}
|
|
672
738
|
|
|
673
739
|
/** @type {"account" | "api_key" | null} */
|
|
674
740
|
let requested_method = null;
|
|
741
|
+
|
|
675
742
|
if (flags.method !== undefined) {
|
|
676
743
|
const method = METHOD_FLAGS[/** @type {keyof typeof METHOD_FLAGS} */ (String(flags.method))];
|
|
744
|
+
|
|
677
745
|
if (!method) {
|
|
678
746
|
throw new Error(`--method must be account or api-key, received "${String(flags.method)}".`);
|
|
679
747
|
}
|
|
748
|
+
|
|
680
749
|
requested_method = method === "api_key" ? "api_key" : "account";
|
|
681
750
|
}
|
|
682
751
|
|
|
@@ -684,6 +753,7 @@ export async function runLoginCommand(words, flags, context = createLoginContext
|
|
|
684
753
|
const api_url = resolveApiUrl({ flags, config });
|
|
685
754
|
const interactive = !view.json && context.is_interactive();
|
|
686
755
|
let pending = readPendingLogin(config);
|
|
756
|
+
|
|
687
757
|
if (pending && context.now() - pending.started_at > LOGIN_CONFIGS.PENDING_FORGET_MS) {
|
|
688
758
|
await forgetPendingLogin(pending.id, context);
|
|
689
759
|
pending = null;
|
|
@@ -697,19 +767,24 @@ export async function runLoginCommand(words, flags, context = createLoginContext
|
|
|
697
767
|
`The waiting sign-in is for ${pending.api_url}. Pass --api-url ${pending.api_url}.`
|
|
698
768
|
);
|
|
699
769
|
}
|
|
770
|
+
|
|
700
771
|
const result = await pollUntilAnswered(pending, context, {
|
|
701
772
|
deadline_ms: context.now() + LOGIN_CONFIGS.WAIT_BUDGET_MS,
|
|
702
773
|
poll_first: true
|
|
703
774
|
});
|
|
775
|
+
|
|
704
776
|
if (result.status === "pending") {
|
|
705
777
|
return respondPending(view, pending, { code: LOGIN_CONFIGS.EXIT_PENDING, is_waiting: true });
|
|
706
778
|
}
|
|
779
|
+
|
|
707
780
|
if (result.status === "unreachable") return respondUnreachable(view, pending);
|
|
708
781
|
if (result.status === "approved" && result.credential) {
|
|
709
782
|
return finishLogin(pending, result.credential, view, context);
|
|
710
783
|
}
|
|
784
|
+
|
|
711
785
|
// Another process holding this sign-in collected it, and that process clears it.
|
|
712
786
|
if (result.status !== "consumed") await forgetPendingLogin(pending.id, context);
|
|
787
|
+
|
|
713
788
|
return respondEnded(view, result);
|
|
714
789
|
}
|
|
715
790
|
|
|
@@ -720,16 +795,20 @@ export async function runLoginCommand(words, flags, context = createLoginContext
|
|
|
720
795
|
!is_new &&
|
|
721
796
|
isSameApiUrl(pending.api_url, api_url) &&
|
|
722
797
|
(requested_method === null || requested_method === pending.method);
|
|
798
|
+
|
|
723
799
|
if (pending && reusable) {
|
|
724
800
|
let result = await pollLogin(pending, context);
|
|
801
|
+
|
|
725
802
|
// A poll inside the server's interval answers slow_down without reading the sign-in.
|
|
726
803
|
if (result.status === "slow_down") {
|
|
727
804
|
await context.sleep(result.interval_ms);
|
|
728
805
|
result = await pollLogin(pending, context);
|
|
729
806
|
}
|
|
807
|
+
|
|
730
808
|
if (result.status === "approved" && result.credential) {
|
|
731
809
|
return finishLogin(pending, result.credential, view, context);
|
|
732
810
|
}
|
|
811
|
+
|
|
733
812
|
if (["pending", "slow_down", "retry"].includes(result.status)) {
|
|
734
813
|
if (!interactive) return respondPending(view, pending);
|
|
735
814
|
} else if (result.status === "consumed") {
|
|
@@ -744,21 +823,29 @@ export async function runLoginCommand(words, flags, context = createLoginContext
|
|
|
744
823
|
|
|
745
824
|
if (!pending) {
|
|
746
825
|
let method = requested_method;
|
|
826
|
+
|
|
747
827
|
if (!method) method = interactive ? await context.choose_method() : "account";
|
|
748
828
|
if (!method) return { output: "Cancelled.", code: 1 };
|
|
829
|
+
|
|
749
830
|
pending = await startLogin(method, context, api_url);
|
|
831
|
+
|
|
750
832
|
if (!interactive) return respondPending(view, pending);
|
|
751
833
|
}
|
|
752
834
|
|
|
753
835
|
const verification_url = readVerificationUrl(pending.verification_url);
|
|
836
|
+
|
|
754
837
|
context.say(`Opening browser\n${verification_url}`);
|
|
755
838
|
context.say(`Check the page shows the code ${sanitizeText(formatUserCode(pending.user_code))}.`);
|
|
756
839
|
context.open_browser(verification_url);
|
|
840
|
+
|
|
757
841
|
const expires_at = Number.isFinite(pending.expires_at)
|
|
758
842
|
? Number(pending.expires_at)
|
|
759
843
|
: pending.started_at + LOGIN_CONFIGS.INTERACTIVE_BUDGET_MS;
|
|
844
|
+
|
|
760
845
|
context.progress.start("Waiting for approval in the browser", expires_at);
|
|
846
|
+
|
|
761
847
|
let result;
|
|
848
|
+
|
|
762
849
|
try {
|
|
763
850
|
result = await pollUntilAnswered(pending, context, {
|
|
764
851
|
deadline_ms: expires_at,
|
|
@@ -767,28 +854,32 @@ export async function runLoginCommand(words, flags, context = createLoginContext
|
|
|
767
854
|
});
|
|
768
855
|
} finally {
|
|
769
856
|
let message = "Stopped waiting";
|
|
857
|
+
|
|
770
858
|
if (result?.status === "approved") message = "Approved";
|
|
771
859
|
else if (result?.status === "pending")
|
|
772
860
|
message = "Stopped waiting. Run `tglow login --wait` to resume.";
|
|
773
861
|
else if (result?.status === "expired")
|
|
774
862
|
message = "Sign-in code expired. Run `tglow login` to try again.";
|
|
775
863
|
else if (result?.status === "unreachable") message = "Could not reach the API";
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
864
|
+
|
|
865
|
+
/** @type {"success" | "cancel" | "error"} */
|
|
866
|
+
let outcome = "error";
|
|
867
|
+
|
|
868
|
+
if (result?.status === "approved") outcome = "success";
|
|
869
|
+
else if (["pending", "expired"].includes(result?.status ?? "")) outcome = "cancel";
|
|
870
|
+
|
|
782
871
|
context.progress.stop(message, outcome);
|
|
783
872
|
}
|
|
784
873
|
|
|
785
874
|
if (result.status === "approved" && result.credential) {
|
|
786
875
|
return finishLogin(pending, result.credential, view, context);
|
|
787
876
|
}
|
|
877
|
+
|
|
788
878
|
if (result.status === "pending") return { output: "", code: 0 };
|
|
789
879
|
if (result.status === "unreachable") return respondUnreachable(view, pending);
|
|
790
880
|
if (result.status !== "consumed") await forgetPendingLogin(pending.id, context);
|
|
791
881
|
if (result.status === "expired") return { output: "", code: 0 };
|
|
882
|
+
|
|
792
883
|
return respondEnded(view, result);
|
|
793
884
|
}
|
|
794
885
|
|
|
@@ -800,7 +891,9 @@ export async function runLoginCommand(words, flags, context = createLoginContext
|
|
|
800
891
|
*/
|
|
801
892
|
export async function runLogoutCommand(words, flags, context = createLoginContext()) {
|
|
802
893
|
refuseUnexpected(words, flags, new Set(["json", "help"]));
|
|
894
|
+
|
|
803
895
|
const view = { json: context.emits_json(flags) };
|
|
896
|
+
|
|
804
897
|
if (readSwitch(flags, "help")) {
|
|
805
898
|
return respond(view, { text: renderLoginHelp(), fields: describeLoginForMachines() });
|
|
806
899
|
}
|
|
@@ -809,8 +902,11 @@ export async function runLogoutCommand(words, flags, context = createLoginContex
|
|
|
809
902
|
// finishing at the same moment is either stored before this read or cancelled by it.
|
|
810
903
|
const config = await withConfigLock(async () => {
|
|
811
904
|
const stored = await loadConfig(context.config_path);
|
|
905
|
+
|
|
812
906
|
if (!readPendingLogin(stored)) return stored;
|
|
907
|
+
|
|
813
908
|
const { pending_login: _discarded, ...rest } = stored;
|
|
909
|
+
|
|
814
910
|
await saveConfig(rest, context.config_path);
|
|
815
911
|
return rest;
|
|
816
912
|
}, context.config_path);
|
|
@@ -822,6 +918,7 @@ export async function runLogoutCommand(words, flags, context = createLoginContex
|
|
|
822
918
|
typeof config.api_key === "string" || process.env.TAILGLOW_API_KEY
|
|
823
919
|
? " An API key from TAILGLOW_API_KEY or `tglow config` is still used; logout removes only what `tglow login` stored."
|
|
824
920
|
: "";
|
|
921
|
+
|
|
825
922
|
return respond(view, {
|
|
826
923
|
text: `Not signed in with \`tglow login\`.${note}`,
|
|
827
924
|
fields: { status: "signed_out", removed: "none" }
|
|
@@ -829,6 +926,7 @@ export async function runLogoutCommand(words, flags, context = createLoginContex
|
|
|
829
926
|
}
|
|
830
927
|
|
|
831
928
|
const outcome = await removeIssuedCredential(login, context);
|
|
929
|
+
|
|
832
930
|
if (outcome === "unreachable") {
|
|
833
931
|
return respond(view, {
|
|
834
932
|
text: `Could not reach ${login.api_url} to end the credential, so it is still stored. Run \`tglow logout\` again.`,
|
|
@@ -839,7 +937,9 @@ export async function runLogoutCommand(words, flags, context = createLoginContex
|
|
|
839
937
|
|
|
840
938
|
await updateConfig((current) => {
|
|
841
939
|
if (readStoredLogin(current)?.secret !== login.secret) return current;
|
|
940
|
+
|
|
842
941
|
const { login: _removed, ...rest } = current;
|
|
942
|
+
|
|
843
943
|
return rest;
|
|
844
944
|
}, context.config_path);
|
|
845
945
|
|
|
@@ -850,11 +950,14 @@ export async function runLogoutCommand(words, flags, context = createLoginContex
|
|
|
850
950
|
code: 1
|
|
851
951
|
});
|
|
852
952
|
}
|
|
953
|
+
|
|
853
954
|
const kind = login.type === "session" ? "CLI session" : "API key";
|
|
854
955
|
let text = `Signed out. The ${kind} has been ended.`;
|
|
956
|
+
|
|
855
957
|
if (outcome === "already_gone") {
|
|
856
958
|
text = `Signed out. The API no longer accepted this ${kind}, so it was removed here only.`;
|
|
857
959
|
}
|
|
960
|
+
|
|
858
961
|
return respond(view, { text, fields: { status: "signed_out", removed: outcome } });
|
|
859
962
|
}
|
|
860
963
|
|
|
@@ -866,7 +969,9 @@ export async function runLogoutCommand(words, flags, context = createLoginContex
|
|
|
866
969
|
*/
|
|
867
970
|
export async function runWhoamiCommand(words, flags, context = createLoginContext()) {
|
|
868
971
|
refuseUnexpected(words, flags, new Set(["json", "help", "api-key", "api-url", "team"]));
|
|
972
|
+
|
|
869
973
|
const view = { json: context.emits_json(flags) };
|
|
974
|
+
|
|
870
975
|
if (readSwitch(flags, "help")) {
|
|
871
976
|
return respond(view, { text: renderLoginHelp(), fields: describeLoginForMachines() });
|
|
872
977
|
}
|
|
@@ -892,19 +997,23 @@ export async function runWhoamiCommand(words, flags, context = createLoginContex
|
|
|
892
997
|
// A CLI session removed from its team still identifies the person, which is what they need to
|
|
893
998
|
// know before signing in again.
|
|
894
999
|
const is_refused = response.status === 401 || response.status === 403;
|
|
1000
|
+
|
|
895
1001
|
if (is_refused && credential.login?.type === "session" && team_id) {
|
|
896
1002
|
const identity = await readAuth(null);
|
|
1003
|
+
|
|
897
1004
|
if (identity.ok) {
|
|
898
1005
|
response = identity;
|
|
899
1006
|
team_refused = true;
|
|
900
1007
|
}
|
|
901
1008
|
}
|
|
1009
|
+
|
|
902
1010
|
if (!response.ok) throw new Error(describeRefusal(response.payload, response.status));
|
|
903
1011
|
|
|
904
1012
|
const auth = /** @type {Record<string, unknown>} */ (response.payload?.data ?? {});
|
|
905
1013
|
const auth_team_id = typeof auth.team_id === "string" ? auth.team_id : null;
|
|
906
1014
|
|
|
907
1015
|
let team_name = null;
|
|
1016
|
+
|
|
908
1017
|
if (auth_team_id) {
|
|
909
1018
|
const team = await sendRequest({
|
|
910
1019
|
method: "GET",
|
|
@@ -915,11 +1024,13 @@ export async function runWhoamiCommand(words, flags, context = createLoginContex
|
|
|
915
1024
|
}).catch(() => null);
|
|
916
1025
|
|
|
917
1026
|
const team_data = /** @type {Record<string, unknown> | undefined} */ (team?.payload?.data);
|
|
1027
|
+
|
|
918
1028
|
if (team?.ok && typeof team_data?.name === "string") team_name = team_data.name;
|
|
919
1029
|
}
|
|
920
1030
|
|
|
921
1031
|
const is_user = auth.type === "user";
|
|
922
1032
|
let credential_label = "API key";
|
|
1033
|
+
|
|
923
1034
|
if (credential.login?.type === "session") credential_label = "CLI session (tglow login)";
|
|
924
1035
|
if (credential.login?.type === "api_key") credential_label = "API key (tglow login)";
|
|
925
1036
|
if (credential.source === "env") credential_label = "API key (TAILGLOW_API_KEY)";
|
|
@@ -930,6 +1041,7 @@ export async function runWhoamiCommand(words, flags, context = createLoginContex
|
|
|
930
1041
|
: `API key ${sanitizeText(String(auth.name ?? auth.key_id ?? ""))}`;
|
|
931
1042
|
|
|
932
1043
|
let team_line = "No team selected.";
|
|
1044
|
+
|
|
933
1045
|
if (auth_team_id)
|
|
934
1046
|
team_line = `Team: ${team_name ? `${sanitizeText(team_name)} ` : ""}(${sanitizeText(auth_team_id)})`;
|
|
935
1047
|
if (team_refused)
|
package/src/output.js
CHANGED
|
@@ -35,6 +35,7 @@ export function sanitizeText(value) {
|
|
|
35
35
|
|
|
36
36
|
return value.replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
37
37
|
if (escapes[character]) return escapes[character];
|
|
38
|
+
|
|
38
39
|
return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
39
40
|
});
|
|
40
41
|
}
|
|
@@ -45,6 +46,7 @@ function toCell(value) {
|
|
|
45
46
|
if (typeof value === "string") return sanitizeText(value);
|
|
46
47
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
47
48
|
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
|
49
|
+
|
|
48
50
|
return "{...}";
|
|
49
51
|
}
|
|
50
52
|
|
|
@@ -72,6 +74,7 @@ function selectColumns(rows) {
|
|
|
72
74
|
*/
|
|
73
75
|
export function formatTable(data) {
|
|
74
76
|
const rows = Array.isArray(data) ? data : [data];
|
|
77
|
+
|
|
75
78
|
if (rows.length === 0) return "No results.";
|
|
76
79
|
if (rows.some((row) => typeof row !== "object" || row === null)) {
|
|
77
80
|
return rows.map((row) => sanitizeText(String(row))).join("\n");
|
|
@@ -113,6 +116,7 @@ export function renderResponse(payload, flags, stream = process.stdout) {
|
|
|
113
116
|
if (shouldEmitJson(flags, stream)) return JSON.stringify(payload, null, 2);
|
|
114
117
|
|
|
115
118
|
const data = payload && "data" in payload ? payload.data : payload;
|
|
119
|
+
|
|
116
120
|
if (data === null || data === undefined) {
|
|
117
121
|
return typeof payload?.message === "string" ? sanitizeText(payload.message) : "Done.";
|
|
118
122
|
}
|
|
@@ -146,6 +150,7 @@ export function renderPagedResult(
|
|
|
146
150
|
}
|
|
147
151
|
|
|
148
152
|
const table = formatTable(data);
|
|
153
|
+
|
|
149
154
|
if (!truncated) return table;
|
|
150
155
|
|
|
151
156
|
return `${table}\n\nStopped at the result cap. Continue with:\n ${sanitizeText(resume_command)}`;
|
|
@@ -165,13 +170,19 @@ export function writeCommandResult(
|
|
|
165
170
|
{ stdout = process.stdout, stderr = process.stderr } = {}
|
|
166
171
|
) {
|
|
167
172
|
if (!output) return;
|
|
173
|
+
|
|
168
174
|
const stream = code === 0 ? stdout : stderr;
|
|
175
|
+
|
|
169
176
|
if (!json && stream.isTTY && (code !== 0 || message)) {
|
|
170
177
|
const text = output.split("\n").map(sanitizeText).join("\n");
|
|
171
|
-
|
|
172
|
-
|
|
178
|
+
let write = log.error;
|
|
179
|
+
|
|
180
|
+
if (code === LOGIN_CONFIGS.EXIT_PENDING) write = log.warn;
|
|
181
|
+
else if (code === 0) write = log.success;
|
|
182
|
+
|
|
173
183
|
write(text, { output: stream });
|
|
174
184
|
return;
|
|
175
185
|
}
|
|
186
|
+
|
|
176
187
|
stream.write(`${output}\n`);
|
|
177
188
|
}
|