@tailglow/cli 0.7.1 → 0.9.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/src/index.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  renderResourceHelp,
21
21
  renderRootHelp
22
22
  } from "./help.js";
23
+ import { IMPORT_PARSER_OPTIONS } from "./import-options.js";
23
24
  import {
24
25
  createLoginContext,
25
26
  runLoginCommand,
@@ -35,6 +36,7 @@ import {
35
36
  writeCommandResult
36
37
  } from "./output.js";
37
38
  import { requestAllPages, requestApi } from "./request.js";
39
+ import { readUpdateNotice } from "./update-check.js";
38
40
 
39
41
  /** Path parameters people refer to by the shorter name they actually type. */
40
42
  const PATH_ALIASES = { project_id: "project", team_id: "team" };
@@ -60,7 +62,11 @@ const LOGIN_PARSER_OPTIONS = {
60
62
  new: { type: "boolean" }
61
63
  };
62
64
 
63
- const PARSER_OPTIONS = { ...LOGIN_PARSER_OPTIONS, ...buildParserOptions(CLI_COMMANDS) };
65
+ const PARSER_OPTIONS = {
66
+ ...LOGIN_PARSER_OPTIONS,
67
+ ...IMPORT_PARSER_OPTIONS,
68
+ ...buildParserOptions(CLI_COMMANDS)
69
+ };
64
70
 
65
71
  /** @param {string[]} argv */
66
72
  export function parseFlags(argv) {
@@ -206,6 +212,10 @@ async function confirmDestructive(command, flags) {
206
212
  */
207
213
  export async function run(argv, login_context = createLoginContext()) {
208
214
  const { values: flags, positionals } = parseFlags(argv);
215
+ if (positionals[0] === "import") {
216
+ const { runImportCommand } = await import("./import-command.js");
217
+ return runImportCommand(argv);
218
+ }
209
219
 
210
220
  try {
211
221
  normalizeSwitches(flags);
@@ -411,6 +421,9 @@ function isEntrypoint() {
411
421
  }
412
422
 
413
423
  if (isEntrypoint()) {
424
+ // Started first so it runs alongside the command, and printed to stderr after the result.
425
+ const update_notice = readUpdateNotice();
426
+
414
427
  // `process.exit` discards whatever is still buffered for a pipe, and a pipe's buffer is 64 KB.
415
428
  // `tglow records list --all --json | jq` would receive a JSON document cut off mid-value, which
416
429
  // is exactly the invariant this CLI sells. Set the code and let the write drain instead.
@@ -426,7 +439,10 @@ if (isEntrypoint()) {
426
439
  ["login", "logout", "whoami"].includes(positionals[0]) ||
427
440
  (positionals[0] === "config" && ["set", "unset"].includes(positionals[1])));
428
441
  process.exitCode = result.code;
429
- writeCommandResult(result, { json, message });
442
+ if (positionals[0] === "import" && json) {
443
+ // Import reports include partial delivery state, so scripts always receive one stdout JSON document.
444
+ process.stdout.write(result.output + "\n");
445
+ } else writeCommandResult(result, { json, message });
430
446
  } catch (err) {
431
447
  process.exitCode = 1;
432
448
  const message = err instanceof Error ? err.message : String(err);
@@ -435,4 +451,14 @@ if (isEntrypoint()) {
435
451
  { json }
436
452
  );
437
453
  }
454
+
455
+ // A failed command in JSON mode answers with one JSON document on stderr, so it gets no notice.
456
+ // Nothing about the notice may fail a finished command, a closed stderr included.
457
+ const notice = await update_notice;
458
+ if (notice && !(json && process.exitCode !== 0)) {
459
+ process.stderr.on("error", () => {});
460
+ try {
461
+ process.stderr.write(`${notice}\n`);
462
+ } catch {}
463
+ }
438
464
  }
package/src/request.js CHANGED
@@ -25,17 +25,25 @@ const LOGIN_REFUSAL_ADVICE = {
25
25
 
26
26
  /**
27
27
  * Headers every request carries. The team header picks which of a person's teams a CLI session acts
28
- * on; an API key belongs to one team and ignores it.
28
+ * on; an API key belongs to one team and ignores it. The terminal and platform name the device in
29
+ * the team's authentication log, since the CLI has no browser user agent to read it from.
29
30
  *
30
- * @param {{ secret: string | null; team_id: string | null }} options
31
+ * @param {{ secret: string | null; team_id: string | null; env?: NodeJS.ProcessEnv }} options
31
32
  */
32
- export async function buildRequestHeaders({ secret, team_id }) {
33
+ export async function buildRequestHeaders({ secret, team_id, env = process.env }) {
33
34
  /** @type {Record<string, string>} */
34
35
  const headers = {
35
36
  "Content-Type": "application/json",
36
37
  "x-tailglow-client": "cli",
37
- "x-tailglow-client-version": await readCliVersion()
38
+ "x-tailglow-client-version": await readCliVersion(),
39
+ "x-tailglow-client-platform": process.platform
38
40
  };
41
+
42
+ // fetch throws on some header characters, such as a line break, so only a plain name is sent.
43
+ const terminal = env.TERM_PROGRAM?.trim();
44
+ if (terminal && /^[\w .+-]{1,32}$/.test(terminal)) {
45
+ headers["x-tailglow-client-terminal"] = terminal;
46
+ }
39
47
  if (secret) headers.Authorization = `Bearer ${secret}`;
40
48
  if (team_id) headers["x-tailglow-team-id"] = team_id;
41
49
  return headers;
@@ -0,0 +1,137 @@
1
+ import { constants } from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import https from "node:https";
4
+ import { CLI_CONFIGS, readCliVersion, saveConfig } from "./config.js";
5
+
6
+ /** @typedef {{ checked_at: number; latest_version: string | null }} UpdateCheck */
7
+
8
+ /** @param {string} version */
9
+ function parseVersion(version) {
10
+ return /^(\d+)\.(\d+)\.(\d+)$/.exec(version)?.slice(1).map(Number) ?? null;
11
+ }
12
+
13
+ /**
14
+ * Whether `candidate` is a later release than `installed`. Only plain `x.y.z` versions compare;
15
+ * anything else never counts as newer.
16
+ *
17
+ * @param {string} candidate
18
+ * @param {string} installed
19
+ */
20
+ export function isNewerVersion(candidate, installed) {
21
+ const next = parseVersion(candidate);
22
+ const current = parseVersion(installed);
23
+ if (!next || !current) return false;
24
+ for (let index = 0; index < 3; index += 1) {
25
+ if (next[index] !== current[index]) return next[index] > current[index];
26
+ }
27
+ return false;
28
+ }
29
+
30
+ /**
31
+ * The stored check, when the path holds a small regular file. Opened without blocking, so a pipe
32
+ * left at the path cannot hang the command.
33
+ *
34
+ * @param {string} path
35
+ * @returns {Promise<UpdateCheck | null>}
36
+ */
37
+ async function readUpdateCheck(path) {
38
+ /** @type {import("node:fs/promises").FileHandle | null} */
39
+ let handle = null;
40
+ try {
41
+ handle = await fs.open(path, constants.O_RDONLY | constants.O_NONBLOCK);
42
+ const stats = await handle.stat();
43
+ if (!stats.isFile() || stats.size > CLI_CONFIGS.UPDATE_CHECK_MAX_BYTES) return null;
44
+ const stored = JSON.parse(await handle.readFile("utf-8"));
45
+ if (typeof stored?.checked_at !== "number") return null;
46
+ const latest_version = typeof stored.latest_version === "string" ? stored.latest_version : null;
47
+ return { checked_at: stored.checked_at, latest_version };
48
+ } catch {
49
+ return null;
50
+ } finally {
51
+ await handle?.close().catch(() => {});
52
+ }
53
+ }
54
+
55
+ /** @param {string} body */
56
+ function readManifestVersion(body) {
57
+ try {
58
+ const manifest = JSON.parse(body);
59
+ return typeof manifest?.version === "string" ? manifest.version : null;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The version npm tags `latest`, or null. `node:https` rather than fetch: an aborted Node fetch
67
+ * keeps a stalled connection, and with it the process, alive for about ten more seconds, while an
68
+ * aborted request here closes its socket.
69
+ *
70
+ * @returns {Promise<string | null>}
71
+ */
72
+ function fetchLatestVersion() {
73
+ return new Promise((resolve) => {
74
+ const request = https.get(
75
+ CLI_CONFIGS.UPDATE_CHECK_URL,
76
+ { signal: AbortSignal.timeout(CLI_CONFIGS.UPDATE_CHECK_TIMEOUT_MS) },
77
+ (response) => {
78
+ let body = "";
79
+ response.setEncoding("utf-8");
80
+ response.on("data", (chunk) => {
81
+ body += chunk;
82
+ if (body.length > CLI_CONFIGS.UPDATE_CHECK_MAX_BYTES) request.destroy();
83
+ });
84
+ response.on("end", () =>
85
+ resolve(response.statusCode === 200 ? readManifestVersion(body) : null)
86
+ );
87
+ // Settles an aborted or destroyed response; after `end` the promise has already resolved.
88
+ response.on("close", () => resolve(null));
89
+ }
90
+ );
91
+ request.on("error", () => resolve(null));
92
+ // A request can also end with no response and no error, such as an upgrade nothing accepts.
93
+ request.on("close", () => resolve(null));
94
+ });
95
+ }
96
+
97
+ /**
98
+ * The line to print when a newer release is published, or null. npm is asked at most once a day,
99
+ * failed attempts included, so an offline machine is not slowed down on every command. Never
100
+ * throws: a missing answer only means no notice.
101
+ *
102
+ * @param {{
103
+ * env?: NodeJS.ProcessEnv;
104
+ * fetch_latest_version?: () => Promise<string | null>;
105
+ * now?: number;
106
+ * path?: string;
107
+ * }} [options]
108
+ * @returns {Promise<string | null>}
109
+ */
110
+ export async function readUpdateNotice({
111
+ env = process.env,
112
+ fetch_latest_version = fetchLatestVersion,
113
+ now = Date.now(),
114
+ path = CLI_CONFIGS.UPDATE_CHECK_PATH
115
+ } = {}) {
116
+ if (env.CI || env.TAILGLOW_NO_UPDATE_CHECK) return null;
117
+ try {
118
+ let check = await readUpdateCheck(path);
119
+ if (!check || now - check.checked_at >= CLI_CONFIGS.UPDATE_CHECK_INTERVAL_MS) {
120
+ const previous = check?.latest_version ?? null;
121
+ // Recorded before npm is asked: an attempt that cannot be recorded is not made, so a machine
122
+ // that cannot write the file is never slowed down on every command.
123
+ await saveConfig({ checked_at: now, latest_version: previous }, path);
124
+ check = { checked_at: now, latest_version: (await fetch_latest_version()) ?? previous };
125
+ if (check.latest_version !== previous) await saveConfig(check, path);
126
+ }
127
+
128
+ const installed = await readCliVersion();
129
+ if (!check.latest_version || !isNewerVersion(check.latest_version, installed)) return null;
130
+ return (
131
+ `tglow ${check.latest_version} is available (you have ${installed}). ` +
132
+ "Update with `npm install -g @tailglow/cli@latest`, or run `npx @tailglow/cli@latest`."
133
+ );
134
+ } catch {
135
+ return null;
136
+ }
137
+ }