@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.
@@ -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
- const mode = typeof chunk === "string" ? "string" : chunk instanceof Uint8Array ? "bytes" : "";
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)
@@ -1695,7 +1695,11 @@ class XmlStreamParser {
1695
1695
  this._error("Parser failed during callback", "STATE");
1696
1696
  }
1697
1697
  _input(chunk) {
1698
- const mode = typeof chunk === "string" ? "string" : chunk instanceof Uint8Array ? "bytes" : "";
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 {