@tailglow/cli 0.9.6 → 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/import-file.js
CHANGED
|
@@ -11,6 +11,7 @@ function checkAbort(signal) {
|
|
|
11
11
|
/** @param {string} [message] */
|
|
12
12
|
function changedInput(message = "The input file changed while it was being read.") {
|
|
13
13
|
const error = new Error(message);
|
|
14
|
+
|
|
14
15
|
return Object.assign(error, { code: "IMPORT_SOURCE_CHANGED" });
|
|
15
16
|
}
|
|
16
17
|
|
|
@@ -23,25 +24,37 @@ function changedInput(message = "The input file changed while it was being read.
|
|
|
23
24
|
*/
|
|
24
25
|
export async function openImportFile(file_path, { signal } = {}) {
|
|
25
26
|
checkAbort(signal);
|
|
27
|
+
|
|
26
28
|
const handle = await open(file_path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
29
|
+
|
|
27
30
|
try {
|
|
28
31
|
const initial = await handle.stat({ bigint: true });
|
|
32
|
+
|
|
29
33
|
if (!initial.isFile()) throw new Error("Import input must be a regular file.");
|
|
34
|
+
|
|
30
35
|
const size = Number(initial.size);
|
|
36
|
+
|
|
31
37
|
if (!Number.isSafeInteger(size))
|
|
32
38
|
throw new Error("The input file is too large to address safely.");
|
|
39
|
+
|
|
33
40
|
let is_closed = false;
|
|
41
|
+
|
|
34
42
|
async function assertUnchanged() {
|
|
35
43
|
checkAbort(signal);
|
|
44
|
+
|
|
36
45
|
if (is_closed) throw new Error("The import file reader is closed.");
|
|
46
|
+
|
|
37
47
|
const current = await stat(file_path, { bigint: true }).catch(() => null);
|
|
38
48
|
const opened = await handle.stat({ bigint: true });
|
|
49
|
+
|
|
39
50
|
if (!current || !sameFile(initial, current) || !sameFile(initial, opened))
|
|
40
51
|
throw changedInput();
|
|
41
52
|
}
|
|
53
|
+
|
|
42
54
|
/** @param {number} start @param {number} end */
|
|
43
55
|
async function readRange(start, end) {
|
|
44
56
|
checkAbort(signal);
|
|
57
|
+
|
|
45
58
|
if (
|
|
46
59
|
!Number.isSafeInteger(start) ||
|
|
47
60
|
!Number.isSafeInteger(end) ||
|
|
@@ -50,20 +63,27 @@ export async function openImportFile(file_path, { signal } = {}) {
|
|
|
50
63
|
end > size
|
|
51
64
|
)
|
|
52
65
|
throw new RangeError("The requested file range is outside the input.");
|
|
66
|
+
|
|
53
67
|
const bytes = new Uint8Array(end - start);
|
|
68
|
+
|
|
54
69
|
for (let offset = 0; offset < bytes.length; ) {
|
|
55
70
|
checkAbort(signal);
|
|
71
|
+
|
|
56
72
|
const { bytesRead: count } = await handle.read(
|
|
57
73
|
bytes,
|
|
58
74
|
offset,
|
|
59
75
|
bytes.length - offset,
|
|
60
76
|
start + offset
|
|
61
77
|
);
|
|
78
|
+
|
|
62
79
|
if (!count) throw changedInput();
|
|
80
|
+
|
|
63
81
|
offset += count;
|
|
64
82
|
}
|
|
83
|
+
|
|
65
84
|
return bytes;
|
|
66
85
|
}
|
|
86
|
+
|
|
67
87
|
/** @satisfies {import("./import.generated.js").ImportSource} */
|
|
68
88
|
const source = {
|
|
69
89
|
name: basename(file_path),
|
|
@@ -72,25 +92,31 @@ export async function openImportFile(file_path, { signal } = {}) {
|
|
|
72
92
|
await assertUnchanged();
|
|
73
93
|
for (let offset = 0; offset < size; ) {
|
|
74
94
|
const end = Math.min(size, offset + 64 * 1024);
|
|
95
|
+
|
|
75
96
|
yield await readRange(offset, end);
|
|
76
97
|
offset = end;
|
|
77
98
|
}
|
|
99
|
+
|
|
78
100
|
// Run before the row iterator returns, so its final partial batch cannot precede this check.
|
|
79
101
|
await assertUnchanged();
|
|
80
102
|
},
|
|
81
103
|
async read(start, end) {
|
|
82
104
|
await assertUnchanged();
|
|
105
|
+
|
|
83
106
|
const bytes = await readRange(start, end);
|
|
107
|
+
|
|
84
108
|
await assertUnchanged();
|
|
85
109
|
return bytes;
|
|
86
110
|
}
|
|
87
111
|
};
|
|
112
|
+
|
|
88
113
|
await assertUnchanged();
|
|
89
114
|
return {
|
|
90
115
|
source: Object.freeze(source),
|
|
91
116
|
assertUnchanged,
|
|
92
117
|
async cleanup() {
|
|
93
118
|
if (is_closed) return;
|
|
119
|
+
|
|
94
120
|
is_closed = true;
|
|
95
121
|
await handle.close();
|
|
96
122
|
}
|
|
@@ -121,32 +147,48 @@ function sameFile(first, second) {
|
|
|
121
147
|
*/
|
|
122
148
|
export async function snapshotImportFile(file_path, { signal, temporary_root = tmpdir() } = {}) {
|
|
123
149
|
checkAbort(signal);
|
|
150
|
+
|
|
124
151
|
const directory = await mkdtemp(join(temporary_root, "tailglow-import-"));
|
|
125
152
|
let original;
|
|
126
153
|
let snapshot;
|
|
127
154
|
let is_complete = false;
|
|
155
|
+
|
|
128
156
|
try {
|
|
129
157
|
await chmod(directory, 0o700);
|
|
130
158
|
original = await open(file_path, constants.O_RDONLY | constants.O_NONBLOCK);
|
|
159
|
+
|
|
131
160
|
const before = await original.stat({ bigint: true });
|
|
161
|
+
|
|
132
162
|
if (!before.isFile()) throw new Error("Import input must be a regular file.");
|
|
163
|
+
|
|
133
164
|
const snapshot_path = join(directory, "input");
|
|
165
|
+
|
|
134
166
|
snapshot = await open(snapshot_path, "wx", 0o600);
|
|
135
167
|
await snapshot.chmod(0o600);
|
|
168
|
+
|
|
136
169
|
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
137
170
|
let copied_bytes = 0;
|
|
171
|
+
|
|
138
172
|
for (;;) {
|
|
139
173
|
checkAbort(signal);
|
|
174
|
+
|
|
140
175
|
const { bytesRead: bytes_read } = await original.read(buffer, 0, buffer.length, null);
|
|
176
|
+
|
|
141
177
|
if (!bytes_read) break;
|
|
178
|
+
|
|
142
179
|
copied_bytes += bytes_read;
|
|
180
|
+
|
|
143
181
|
if (BigInt(copied_bytes) > before.size)
|
|
144
182
|
throw changedInput("Input changed while making its private snapshot.");
|
|
183
|
+
|
|
145
184
|
await snapshot.writeFile(buffer.subarray(0, bytes_read));
|
|
146
185
|
}
|
|
186
|
+
|
|
147
187
|
checkAbort(signal);
|
|
188
|
+
|
|
148
189
|
const after = await original.stat({ bigint: true });
|
|
149
190
|
const current = await stat(file_path, { bigint: true });
|
|
191
|
+
|
|
150
192
|
if (
|
|
151
193
|
!sameFile(before, after) ||
|
|
152
194
|
!sameFile(before, current) ||
|
|
@@ -154,13 +196,17 @@ export async function snapshotImportFile(file_path, { signal, temporary_root = t
|
|
|
154
196
|
) {
|
|
155
197
|
throw changedInput("Input changed while making its private snapshot.");
|
|
156
198
|
}
|
|
199
|
+
|
|
157
200
|
if ((await snapshot.stat({ bigint: true })).size !== before.size)
|
|
158
201
|
throw new Error("The private snapshot size differs from the input file.");
|
|
202
|
+
|
|
159
203
|
await snapshot.close();
|
|
160
204
|
snapshot = undefined;
|
|
161
205
|
await original.close();
|
|
162
206
|
original = undefined;
|
|
207
|
+
|
|
163
208
|
const input = await openImportFile(snapshot_path, { signal });
|
|
209
|
+
|
|
164
210
|
is_complete = true;
|
|
165
211
|
return {
|
|
166
212
|
source: Object.freeze({ ...input.source, name: basename(file_path) }),
|
|
@@ -176,6 +222,7 @@ export async function snapshotImportFile(file_path, { signal, temporary_root = t
|
|
|
176
222
|
} finally {
|
|
177
223
|
await snapshot?.close().catch(() => {});
|
|
178
224
|
await original?.close().catch(() => {});
|
|
225
|
+
|
|
179
226
|
if (!is_complete) await rm(directory, { recursive: true, force: true });
|
|
180
227
|
}
|
|
181
228
|
}
|
|
@@ -208,7 +208,11 @@ class XmlStreamParser {
|
|
|
208
208
|
this._error("Parser failed during callback", "STATE");
|
|
209
209
|
}
|
|
210
210
|
_input(chunk) {
|
|
211
|
-
|
|
211
|
+
let mode = "";
|
|
212
|
+
if (typeof chunk === "string")
|
|
213
|
+
mode = "string";
|
|
214
|
+
else if (chunk instanceof Uint8Array)
|
|
215
|
+
mode = "bytes";
|
|
212
216
|
if (!mode)
|
|
213
217
|
throw new TypeError("XML input must be a string or Uint8Array");
|
|
214
218
|
if (!chunk.length)
|
package/src/import.generated.js
CHANGED
|
@@ -1695,7 +1695,11 @@ class XmlStreamParser {
|
|
|
1695
1695
|
this._error("Parser failed during callback", "STATE");
|
|
1696
1696
|
}
|
|
1697
1697
|
_input(chunk) {
|
|
1698
|
-
|
|
1698
|
+
let mode = "";
|
|
1699
|
+
if (typeof chunk === "string")
|
|
1700
|
+
mode = "string";
|
|
1701
|
+
else if (chunk instanceof Uint8Array)
|
|
1702
|
+
mode = "bytes";
|
|
1699
1703
|
if (!mode)
|
|
1700
1704
|
throw new TypeError("XML input must be a string or Uint8Array");
|
|
1701
1705
|
if (!chunk.length)
|
package/src/index.js
CHANGED
|
@@ -81,6 +81,7 @@ export function parseFlags(argv) {
|
|
|
81
81
|
/** @param {string} token */
|
|
82
82
|
function quoteToken(token) {
|
|
83
83
|
const safe = sanitizeText(token);
|
|
84
|
+
|
|
84
85
|
return /^[A-Za-z0-9._:/=-]+$/.test(safe) ? safe : `'${safe.replaceAll("'", `'\\''`)}'`;
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -106,6 +107,7 @@ export function buildResumeCommand(argv, cursor) {
|
|
|
106
107
|
// The key must never be echoed, and the old cursor is about to be replaced.
|
|
107
108
|
if (flag === "api-key" || flag === "after") {
|
|
108
109
|
if (!token.includes("=") && PARSER_OPTIONS[flag]?.type === "string") index += 1;
|
|
110
|
+
|
|
109
111
|
continue;
|
|
110
112
|
}
|
|
111
113
|
|
|
@@ -121,6 +123,7 @@ export function buildResumeCommand(argv, cursor) {
|
|
|
121
123
|
*/
|
|
122
124
|
function readFlag(flags, flag) {
|
|
123
125
|
const value = flags[flag];
|
|
126
|
+
|
|
124
127
|
return typeof value === "string" && value.length > 0 ? value : null;
|
|
125
128
|
}
|
|
126
129
|
|
|
@@ -158,6 +161,7 @@ function resolvePath(command, { flags, config }) {
|
|
|
158
161
|
missing.push(name);
|
|
159
162
|
return current;
|
|
160
163
|
}
|
|
164
|
+
|
|
161
165
|
if (UNSAFE_PATH_VALUES.has(value)) {
|
|
162
166
|
unsafe.push(name);
|
|
163
167
|
return current;
|
|
@@ -212,8 +216,10 @@ async function confirmDestructive(command, flags) {
|
|
|
212
216
|
*/
|
|
213
217
|
export async function run(argv, login_context = createLoginContext()) {
|
|
214
218
|
const { values: flags, positionals } = parseFlags(argv);
|
|
219
|
+
|
|
215
220
|
if (positionals[0] === "import") {
|
|
216
221
|
const { runImportCommand } = await import("./import-command.js");
|
|
222
|
+
|
|
217
223
|
return runImportCommand(argv);
|
|
218
224
|
}
|
|
219
225
|
|
|
@@ -230,20 +236,26 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
230
236
|
|
|
231
237
|
if (flags.version) {
|
|
232
238
|
const version = await readCliVersion();
|
|
239
|
+
|
|
233
240
|
return inform(version, { version });
|
|
234
241
|
}
|
|
235
242
|
|
|
236
243
|
const [resource, name, ...rest] = positionals;
|
|
244
|
+
|
|
237
245
|
if (resource === "help") {
|
|
238
246
|
if (name) return fail("Run `tglow <resource> <command> --help` for command help.", flags);
|
|
247
|
+
|
|
239
248
|
return inform(renderRootHelp(), describeForMachines());
|
|
240
249
|
}
|
|
250
|
+
|
|
241
251
|
if (!resource) {
|
|
242
252
|
if (await shouldOfferLogin(flags, login_context)) {
|
|
243
253
|
return await runLoginCommand([], flags, login_context);
|
|
244
254
|
}
|
|
255
|
+
|
|
245
256
|
return inform(renderRootHelp(), describeForMachines());
|
|
246
257
|
}
|
|
258
|
+
|
|
247
259
|
if (resource === "config") return await runConfigCommand([name, ...rest], flags);
|
|
248
260
|
if (resource === "login") return await runLoginCommand([name, ...rest], flags, login_context);
|
|
249
261
|
if (resource === "logout") return await runLogoutCommand([name, ...rest], flags);
|
|
@@ -251,10 +263,12 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
251
263
|
if (resource === "docs") {
|
|
252
264
|
// Loaded only here: the bundled docs are about 600 KB that every other command would parse.
|
|
253
265
|
const { runDocsCommand } = await import("./docs-command.js");
|
|
266
|
+
|
|
254
267
|
return runDocsCommand([name, ...rest], flags);
|
|
255
268
|
}
|
|
256
269
|
|
|
257
270
|
const commands = CLI_COMMANDS.filter((entry) => entry.resource === resource);
|
|
271
|
+
|
|
258
272
|
if (commands.length === 0) {
|
|
259
273
|
return fail(`Unknown resource "${resource}". Run \`tglow help\` for the list.`, flags);
|
|
260
274
|
}
|
|
@@ -264,6 +278,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
264
278
|
}
|
|
265
279
|
|
|
266
280
|
const command = commands.find((entry) => entry.command === name);
|
|
281
|
+
|
|
267
282
|
if (!command) {
|
|
268
283
|
return fail(
|
|
269
284
|
`Unknown command "${resource} ${name}". ` +
|
|
@@ -271,6 +286,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
271
286
|
flags
|
|
272
287
|
);
|
|
273
288
|
}
|
|
289
|
+
|
|
274
290
|
if (flags.help) return inform(renderCommandHelp(command), describeForMachines({ command }));
|
|
275
291
|
|
|
276
292
|
const config = await loadConfig();
|
|
@@ -279,11 +295,13 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
279
295
|
|
|
280
296
|
if (unsafe.length > 0) {
|
|
281
297
|
const named = unsafe.map((entry) => `--${toFlagName(entry)}`).join(", ");
|
|
298
|
+
|
|
282
299
|
return fail(
|
|
283
300
|
`${named} cannot be "." or "..". Those resolve to a different endpoint than this command calls.`,
|
|
284
301
|
flags
|
|
285
302
|
);
|
|
286
303
|
}
|
|
304
|
+
|
|
287
305
|
if (missing.length > 0) {
|
|
288
306
|
// Only project and team have a stored default, so only they can be answered with `config set`.
|
|
289
307
|
// Suggesting it for a missing source or metric id sends the reader after a setting that does
|
|
@@ -303,6 +321,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
303
321
|
const { query, body, unknown } = buildRequestParts(command, flags, {
|
|
304
322
|
consumed_path_fields: consumed
|
|
305
323
|
});
|
|
324
|
+
|
|
306
325
|
if (unknown.length > 0) {
|
|
307
326
|
return fail(
|
|
308
327
|
`Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((entry) => `--${entry}`).join(", ")}.\n` +
|
|
@@ -330,6 +349,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
330
349
|
flags
|
|
331
350
|
);
|
|
332
351
|
}
|
|
352
|
+
|
|
333
353
|
// `--all` owns paging: it walks forwards from `after` and sizes each page itself. `--before`
|
|
334
354
|
// asks the API to page the other way at the same time, and `--limit` states a page size that
|
|
335
355
|
// the cap then has to override. Refusing both beats silently reinterpreting either.
|
|
@@ -339,6 +359,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
339
359
|
flags
|
|
340
360
|
);
|
|
341
361
|
}
|
|
362
|
+
|
|
342
363
|
if (query.limit !== undefined) {
|
|
343
364
|
return fail("--limit cannot be combined with --all. Use --max to cap the total.", flags);
|
|
344
365
|
}
|
|
@@ -346,6 +367,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
346
367
|
// `--max` written bare parses as `true`, and checking `Number(raw)` rather than the text lets
|
|
347
368
|
// `1e-400` and `1.0000000000000001` through as whole numbers.
|
|
348
369
|
const raw_max = flags.max === undefined ? String(CLI_CONFIGS.DEFAULT_MAX_RECORDS) : flags.max;
|
|
370
|
+
|
|
349
371
|
if (typeof raw_max !== "string" || !isIntegerText(raw_max) || Number(raw_max) < 1) {
|
|
350
372
|
return fail(`--max must be a whole number of at least 1, received "${raw_max}".`, flags);
|
|
351
373
|
}
|
|
@@ -389,9 +411,11 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
389
411
|
config,
|
|
390
412
|
path_team_id
|
|
391
413
|
});
|
|
414
|
+
|
|
392
415
|
return { output: renderResponse(payload, flags), code: 0 };
|
|
393
416
|
} catch (err) {
|
|
394
417
|
const message = err instanceof Error ? err.message : String(err);
|
|
418
|
+
|
|
395
419
|
// `tglow docs` answers in text unless --json was given, including for a flag the shared checks
|
|
396
420
|
// above rejected before it was dispatched.
|
|
397
421
|
if (positionals[0] === "docs") {
|
|
@@ -400,6 +424,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
400
424
|
code: 1
|
|
401
425
|
};
|
|
402
426
|
}
|
|
427
|
+
|
|
403
428
|
return fail(message, flags);
|
|
404
429
|
}
|
|
405
430
|
}
|
|
@@ -413,6 +438,7 @@ export async function run(argv, login_context = createLoginContext()) {
|
|
|
413
438
|
*/
|
|
414
439
|
function isEntrypoint() {
|
|
415
440
|
if (!process.argv[1]) return false;
|
|
441
|
+
|
|
416
442
|
try {
|
|
417
443
|
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
418
444
|
} catch {
|
|
@@ -428,24 +454,31 @@ if (isEntrypoint()) {
|
|
|
428
454
|
// `tglow records list --all --json | jq` would receive a JSON document cut off mid-value, which
|
|
429
455
|
// is exactly the invariant this CLI sells. Set the code and let the write drain instead.
|
|
430
456
|
let json = shouldEmitJson({});
|
|
457
|
+
|
|
431
458
|
try {
|
|
432
459
|
const argv = process.argv.slice(2);
|
|
433
460
|
const { values: flags, positionals } = parseFlags(argv);
|
|
461
|
+
|
|
434
462
|
json = shouldEmitJson({ json: flags.json === true || flags.json === "true" });
|
|
463
|
+
|
|
435
464
|
const result = await run(argv);
|
|
436
465
|
const message =
|
|
437
466
|
!flags.help &&
|
|
438
467
|
((!positionals[0] && result.output !== renderRootHelp()) ||
|
|
439
468
|
["login", "logout", "whoami"].includes(positionals[0]) ||
|
|
440
469
|
(positionals[0] === "config" && ["set", "unset"].includes(positionals[1])));
|
|
470
|
+
|
|
441
471
|
process.exitCode = result.code;
|
|
472
|
+
|
|
442
473
|
if (positionals[0] === "import" && json) {
|
|
443
474
|
// Import reports include partial delivery state, so scripts always receive one stdout JSON document.
|
|
444
475
|
process.stdout.write(result.output + "\n");
|
|
445
476
|
} else writeCommandResult(result, { json, message });
|
|
446
477
|
} catch (err) {
|
|
447
478
|
process.exitCode = 1;
|
|
479
|
+
|
|
448
480
|
const message = err instanceof Error ? err.message : String(err);
|
|
481
|
+
|
|
449
482
|
writeCommandResult(
|
|
450
483
|
{ output: json ? JSON.stringify({ error: message }) : message, code: 1 },
|
|
451
484
|
{ json }
|
|
@@ -455,6 +488,7 @@ if (isEntrypoint()) {
|
|
|
455
488
|
// A failed command in JSON mode answers with one JSON document on stderr, so it gets no notice.
|
|
456
489
|
// Nothing about the notice may fail a finished command, a closed stderr included.
|
|
457
490
|
const notice = await update_notice;
|
|
491
|
+
|
|
458
492
|
if (notice && !(json && process.exitCode !== 0)) {
|
|
459
493
|
process.stderr.on("error", () => {});
|
|
460
494
|
try {
|