@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/README.md +85 -5
- package/package.json +3 -3
- package/src/commands.generated.js +180 -17
- package/src/config.js +8 -1
- package/src/docs.generated.js +25 -25
- package/src/help.js +6 -0
- package/src/import-command.js +636 -0
- package/src/import-file.js +181 -0
- package/src/import-options.js +23 -0
- package/src/import-worker.generated.js +1598 -0
- package/src/import.generated.d.ts +150 -0
- package/src/import.generated.js +4705 -0
- package/src/index.js +28 -2
- package/src/request.js +12 -4
- package/src/update-check.js +137 -0
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 = {
|
|
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
|
-
|
|
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
|
+
}
|