@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.
@@ -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
- const outcome =
777
- result?.status === "approved"
778
- ? "success"
779
- : ["pending", "expired"].includes(result?.status ?? "")
780
- ? "cancel"
781
- : "error";
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
- const write =
172
- code === LOGIN_CONFIGS.EXIT_PENDING ? log.warn : code === 0 ? log.success : log.error;
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
  }