@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.
package/src/flags.js CHANGED
@@ -61,9 +61,12 @@ export function buildParserOptions(commands) {
61
61
 
62
62
  for (const command of commands) {
63
63
  const fields = [...command.path_params, ...command.query_params, ...command.body_fields];
64
+
64
65
  for (const field of fields) {
65
66
  const flag = toFlagName(field.name);
67
+
66
68
  if (options[flag]) continue;
69
+
67
70
  options[flag] = { type: field.type === "boolean" ? "boolean" : "string" };
68
71
  }
69
72
  }
@@ -86,6 +89,7 @@ export function normalizeSwitches(flags) {
86
89
  if (type !== "boolean") continue;
87
90
 
88
91
  const raw = flags[flag];
92
+
89
93
  if (raw === undefined || typeof raw === "boolean") continue;
90
94
  if (raw === "true" || raw === "false") {
91
95
  flags[flag] = raw === "true";
@@ -159,6 +163,7 @@ export function describeFlagSyntax(flag, type) {
159
163
  */
160
164
  export function isIntegerText(raw) {
161
165
  const trimmed = raw.trim();
166
+
162
167
  return /^[+-]?\d+$/.test(trimmed) && Number.isSafeInteger(Number(trimmed));
163
168
  }
164
169
 
@@ -176,6 +181,7 @@ export function isIntegerText(raw) {
176
181
  function parseStructuredValue(raw, type, named = "") {
177
182
  /** @type {unknown} */
178
183
  let value;
184
+
179
185
  try {
180
186
  value = JSON.parse(raw);
181
187
  } catch {
@@ -185,6 +191,7 @@ function parseStructuredValue(raw, type, named = "") {
185
191
  if (type.startsWith("object[]") && !Array.isArray(value)) {
186
192
  throw new Error(`${named}expected a JSON array for a ${type} value`);
187
193
  }
194
+
188
195
  if (!type.startsWith("object[]") && Array.isArray(value)) {
189
196
  throw new Error(`${named}expected a JSON object for a ${type} value`);
190
197
  }
@@ -214,6 +221,7 @@ export function coerceValue(raw, type, flag = "") {
214
221
  // A flag written bare parses as `true`. On a boolean field that IS the value; anywhere else the
215
222
  // value was left off, and sending `true` would make the API reject a request that reads as fine.
216
223
  if (type.includes("boolean")) return raw;
224
+
217
225
  throw new Error(`${named}expected a ${type} value after the flag, received none`);
218
226
  }
219
227
 
@@ -223,25 +231,30 @@ export function coerceValue(raw, type, flag = "") {
223
231
 
224
232
  if (type.includes("integer")) {
225
233
  if (!isIntegerText(raw)) throw new Error(`${named}expected a whole number, received "${raw}"`);
234
+
226
235
  return Number(raw);
227
236
  }
228
237
 
229
238
  if (type.includes("number")) {
230
239
  const value = Number(raw);
240
+
231
241
  if (raw.trim() === "" || !Number.isFinite(value)) {
232
242
  throw new Error(`${named}expected a number, received "${raw}"`);
233
243
  }
244
+
234
245
  return value;
235
246
  }
236
247
 
237
248
  if (type.includes("boolean")) {
238
249
  if (raw === "true") return true;
239
250
  if (raw === "false") return false;
251
+
240
252
  throw new Error(`${named}expected true or false, received "${raw}"`);
241
253
  }
242
254
 
243
255
  if (type.endsWith("[]")) {
244
256
  if (raw.trimStart().startsWith("[")) return parseStructuredValue(raw, "object[]");
257
+
245
258
  return raw
246
259
  .split(",")
247
260
  .map((entry) => entry.trim())
@@ -282,12 +295,14 @@ export function buildRequestParts(command, flags, { consumed_path_fields = new S
282
295
  if (RESERVED_FLAGS.has(flag) || consumed_flags.has(flag)) continue;
283
296
 
284
297
  const query_field = query_by_flag.get(flag);
298
+
285
299
  if (query_field) {
286
300
  query[query_field.name] = coerceValue(/** @type {string} */ (raw), query_field.type, flag);
287
301
  continue;
288
302
  }
289
303
 
290
304
  const body_field = body_by_flag.get(flag);
305
+
291
306
  if (body_field) {
292
307
  body[body_field.name] = coerceValue(/** @type {string} */ (raw), body_field.type, flag);
293
308
  continue;
package/src/help.js CHANGED
@@ -107,6 +107,7 @@ function listResources(commands) {
107
107
  /** @param {(typeof GLOBAL_FLAGS)[number]} entry */
108
108
  function renderGlobalFlag(entry) {
109
109
  const left = entry.argument ? `${entry.flag} ${entry.argument}` : entry.flag;
110
+
110
111
  return {
111
112
  left,
112
113
  description: entry.env ? `${entry.description} (${entry.env})` : entry.description
@@ -276,6 +277,7 @@ export function renderRootHelp() {
276
277
  /** @param {string} resource */
277
278
  export function renderResourceHelp(resource) {
278
279
  const commands = CLI_COMMANDS.filter((command) => command.resource === resource);
280
+
279
281
  if (commands.length === 0) return null;
280
282
 
281
283
  const width = Math.max(...commands.map((command) => command.command.length));
@@ -327,10 +329,12 @@ function renderFields({ fields, title }) {
327
329
  */
328
330
  function renderNestedFields(fields, depth) {
329
331
  const indent = " ".repeat(depth + 1);
332
+
330
333
  return fields.flatMap((field) => {
331
334
  const required = field.required ? " (required)" : "";
332
335
  const allowed = field.allowed_values.length > 0 ? ` [${field.allowed_values.join(", ")}]` : "";
333
336
  const description = field.description ? ` ${field.description}` : "";
337
+
334
338
  return [
335
339
  `${indent}${field.name} <${field.type}>${required}${allowed}${description}`,
336
340
  ...renderNestedFields(field.fields ?? [], depth + 1)
@@ -61,10 +61,13 @@ export function describeImportForMachines() {
61
61
  /** @param {unknown} error @param {Record<string, unknown>} flags */
62
62
  function safeError(error, flags) {
63
63
  let message = error instanceof Error ? error.message : "Import failed.";
64
+
64
65
  for (const name of ["api-key", "ingest-url"]) {
65
66
  const value = flags[name];
67
+
66
68
  if (typeof value === "string" && value) message = message.split(value).join("[redacted]");
67
69
  }
70
+
68
71
  message = message.replace(/https?:\/\/[^\s"'<>]+/gi, "[redacted URL]");
69
72
  return sanitizeText(message).slice(0, 1200);
70
73
  }
@@ -74,6 +77,7 @@ function parseWorkers(value) {
74
77
  if (value === undefined || value === "auto") return "auto";
75
78
  if (typeof value === "string" && /^\d{1,2}$/.test(value) && Number(value) <= IMPORT_MAX_WORKERS)
76
79
  return Number(value);
80
+
77
81
  throw new Error(`--workers takes auto or a whole number from 0 to ${IMPORT_MAX_WORKERS}.`);
78
82
  }
79
83
 
@@ -85,11 +89,13 @@ function checkAbort(signal) {
85
89
  /** @param {string} address @param {boolean} [is_ingest] */
86
90
  function safeUrl(address, is_ingest = false) {
87
91
  let url;
92
+
88
93
  try {
89
94
  url = new URL(address);
90
95
  } catch {
91
96
  throw new Error("Invalid destination address.");
92
97
  }
98
+
93
99
  if (
94
100
  url.username ||
95
101
  url.password ||
@@ -100,8 +106,10 @@ function safeUrl(address, is_ingest = false) {
100
106
  "Use an HTTPS address, or HTTP on localhost, without URL credentials or fragments."
101
107
  );
102
108
  }
109
+
103
110
  if (is_ingest && !url.searchParams.get("key"))
104
111
  throw new Error("The ingest address must contain its key.");
112
+
105
113
  return url;
106
114
  }
107
115
 
@@ -113,9 +121,12 @@ function safeUrl(address, is_ingest = false) {
113
121
  function requestSignal(parent, milliseconds) {
114
122
  const controller = new AbortController();
115
123
  const abort = () => controller.abort();
124
+
116
125
  if (parent.aborted) abort();
117
126
  else parent.addEventListener("abort", abort, { once: true });
127
+
118
128
  const timer = setTimeout(abort, milliseconds);
129
+
119
130
  return {
120
131
  signal: controller.signal,
121
132
  dispose() {
@@ -128,18 +139,25 @@ function requestSignal(parent, milliseconds) {
128
139
  /** @param {Response} response */
129
140
  async function smallJson(response) {
130
141
  if (!response.body) throw new Error("Missing response.");
142
+
131
143
  const reader = response.body.getReader();
132
144
  const decoder = new TextDecoder("utf-8", { fatal: true });
133
145
  let text = "";
134
146
  let bytes = 0;
147
+
135
148
  try {
136
149
  for (;;) {
137
150
  const { value, done } = await reader.read();
151
+
138
152
  if (done) break;
153
+
139
154
  bytes += value.byteLength;
155
+
140
156
  if (bytes > 64 * 1024) throw new Error("Oversized response.");
157
+
141
158
  text += decoder.decode(value, { stream: true });
142
159
  }
160
+
143
161
  return JSON.parse(text + decoder.decode());
144
162
  } finally {
145
163
  // Stream cleanup must not hold up the result or cancellation of the command.
@@ -154,6 +172,7 @@ async function smallJson(response) {
154
172
  */
155
173
  async function getJson(url, options, signal, request) {
156
174
  const linked = requestSignal(signal, 8000);
175
+
157
176
  try {
158
177
  const response = await request(url, {
159
178
  ...options,
@@ -163,10 +182,12 @@ async function getJson(url, options, signal, request) {
163
182
  cache: "no-store",
164
183
  credentials: "omit"
165
184
  });
185
+
166
186
  if (!response.ok) {
167
187
  void response.body?.cancel().catch(() => {});
168
188
  throw new Error("Refused response.");
169
189
  }
190
+
170
191
  return await smallJson(response);
171
192
  } catch {
172
193
  checkAbort(signal);
@@ -190,26 +211,35 @@ async function resolveDestination(flags, signal, request, read_config) {
190
211
  typeof flags["project-id"] === "string"
191
212
  ? flags["project-id"]
192
213
  : resolveSetting("project", { flags, config });
214
+
193
215
  if (flags.project && flags["project-id"] && flags.project !== flags["project-id"]) {
194
216
  throw new Error("--project and --project-id must name the same project.");
195
217
  }
218
+
196
219
  if (!project_id || project_id === "." || project_id === "..")
197
220
  throw new Error("Upload requires --project (or a configured project).");
221
+
198
222
  let url;
223
+
199
224
  if (typeof flags["ingest-url"] === "string") {
200
225
  if (flags["source-id"] || flags["ingest-key-id"])
201
226
  throw new Error("Choose --ingest-url or explicit --source-id/--ingest-key-id, not both.");
227
+
202
228
  url = safeUrl(flags["ingest-url"], true);
203
229
  } else {
204
230
  const source_id = flags["source-id"];
205
231
  const ingest_key_id = flags["ingest-key-id"];
232
+
206
233
  if (typeof source_id !== "string" || typeof ingest_key_id !== "string") {
207
234
  throw new Error("Upload requires --ingest-url, or both --source-id and --ingest-key-id.");
208
235
  }
236
+
209
237
  if ([source_id, ingest_key_id].some((id) => id === "." || id === ".."))
210
238
  throw new Error("Destination IDs cannot be dot segments.");
239
+
211
240
  const api_url = safeUrl(resolveApiUrl({ flags, config }));
212
241
  let credential;
242
+
213
243
  try {
214
244
  credential = resolveCredential({ flags, config });
215
245
  } catch {
@@ -217,6 +247,7 @@ async function resolveDestination(flags, signal, request, read_config) {
217
247
  "Sign in to the configured API or supply an API key to resolve this ingest key."
218
248
  );
219
249
  }
250
+
220
251
  const team_id = resolveSetting("team", { flags, config }) ?? credential.login?.team_id ?? null;
221
252
  const path =
222
253
  "/v1/projects/" +
@@ -236,6 +267,7 @@ async function resolveDestination(flags, signal, request, read_config) {
236
267
  );
237
268
 
238
269
  const key = payload?.data;
270
+
239
271
  if (
240
272
  key?.id !== ingest_key_id ||
241
273
  key.source_id !== source_id ||
@@ -246,13 +278,16 @@ async function resolveDestination(flags, signal, request, read_config) {
246
278
  "The selected ingest key is unavailable, inactive, or belongs to another source."
247
279
  );
248
280
  }
281
+
249
282
  url = safeUrl(key.url, true);
283
+
250
284
  if (LOCAL_HOSTS.has(api_url.hostname) && !LOCAL_HOSTS.has(url.hostname)) {
251
285
  throw new Error(
252
286
  "A local API returned a remote ingest address. Pass an explicit --ingest-url for the intended destination."
253
287
  );
254
288
  }
255
289
  }
290
+
256
291
  url.searchParams.set("collection", String(flags.collection));
257
292
  return { url, project_id };
258
293
  }
@@ -266,10 +301,13 @@ function renderHumanReport(report) {
266
301
  ? "Full validation complete."
267
302
  : "Sample or discovery only; upload validation is still required."
268
303
  ];
304
+
269
305
  lines.push(
270
306
  "File size: " + report.total_bytes + " bytes; scanned " + report.bytes_read + " bytes."
271
307
  );
308
+
272
309
  const xml = /** @type {any} */ (report.xml);
310
+
273
311
  if (xml?.record_paths?.length)
274
312
  lines.push(
275
313
  "XML record paths: " +
@@ -277,9 +315,11 @@ function renderHumanReport(report) {
277
315
  .map((/** @type {{ path: string }} */ entry) => sanitizeText(entry.path))
278
316
  .join(", ")
279
317
  );
318
+
280
319
  const fields = /** @type {import("./import.generated.js").FieldProfile[]} */ (
281
320
  report.fields ?? []
282
321
  );
322
+
283
323
  if (fields.length) {
284
324
  lines.push("Fields:");
285
325
  for (const field of fields.slice(0, 20))
@@ -293,20 +333,26 @@ function renderHumanReport(report) {
293
333
  ", missing " +
294
334
  field.missing
295
335
  );
336
+
296
337
  if (fields.length > 20) lines.push(" More fields are available with --json.");
297
338
  }
339
+
298
340
  const candidates = /** @type {import("./import.generated.js").TimestampCandidate[]} */ (
299
341
  report.timestamp_candidates ?? []
300
342
  );
343
+
301
344
  if (candidates.length)
302
345
  lines.push(
303
346
  "Timestamp candidates: " + candidates.map((entry) => sanitizeText(entry.path)).join(", ")
304
347
  );
348
+
305
349
  for (const warning of /** @type {string[]} */ (report.warnings ?? []))
306
350
  lines.push(sanitizeText(warning));
307
351
  return lines.join("\n");
308
352
  }
353
+
309
354
  const affected = /** @type {any} */ (report.affected_batch);
355
+
310
356
  return [
311
357
  report.status === "complete"
312
358
  ? "Import complete."
@@ -369,14 +415,19 @@ export async function runImportCommand(argv, context = {}) {
369
415
  const signal = controller.signal;
370
416
  const abort = () => controller.abort();
371
417
  const external_signal = context.signal;
418
+
372
419
  if (external_signal?.aborted) abort();
373
420
  else external_signal?.addEventListener("abort", abort, { once: true });
421
+
374
422
  process.on("SIGINT", abort);
423
+
375
424
  const stderr = context.stderr ?? process.stderr;
376
425
  const request = context.request ?? fetch;
377
426
  let result;
427
+
378
428
  try {
379
429
  let positionals;
430
+
380
431
  try {
381
432
  ({ values: flags, positionals } = parseArgs({
382
433
  args: argv,
@@ -387,8 +438,10 @@ export async function runImportCommand(argv, context = {}) {
387
438
  } catch {
388
439
  throw new Error("Invalid import options. Run tglow import --help.");
389
440
  }
441
+
390
442
  for (const [name, value] of Object.entries(flags)) {
391
443
  const option = IMPORT_PARSER_OPTIONS[name];
444
+
392
445
  if (!option) throw new Error("Unknown import option. Run tglow import --help.");
393
446
  if (option.type === "boolean") {
394
447
  if (value === "true" || value === "false") flags[name] = value === "true";
@@ -398,18 +451,23 @@ export async function runImportCommand(argv, context = {}) {
398
451
  throw new Error("--" + name + " requires a value.");
399
452
  }
400
453
  }
454
+
401
455
  if (flags.help || positionals.length === 1) {
402
456
  return {
403
457
  output: shouldEmitJson(flags) ? JSON.stringify(describeImportForMachines(), null, 2) : HELP,
404
458
  code: 0
405
459
  };
406
460
  }
461
+
407
462
  if (positionals[0] !== "import") throw new Error("Expected the import command.");
463
+
408
464
  const words = positionals.slice(1);
465
+
409
466
  if (words[0] === "scan" || words[0] === "validate") {
410
467
  action = words[0];
411
468
  words.shift();
412
469
  }
470
+
413
471
  if (words.length !== 1)
414
472
  throw new Error("Specify exactly one input file. Run tglow import --help.");
415
473
  if (action !== "scan" && flags.full !== undefined)
@@ -418,7 +476,9 @@ export async function runImportCommand(argv, context = {}) {
418
476
  throw new Error("--validate-first only applies to uploading.");
419
477
  if (!FORMATS.includes(String(flags.format ?? "auto")))
420
478
  throw new Error("Unsupported --format. Use auto, xml, jsonl, json, csv, or tsv.");
479
+
421
480
  const workers = parseWorkers(flags.workers);
481
+
422
482
  if (
423
483
  action !== "import" &&
424
484
  ["ingest-url", "collection", "source-id", "ingest-key-id"].some(
@@ -427,13 +487,17 @@ export async function runImportCommand(argv, context = {}) {
427
487
  ) {
428
488
  throw new Error("Destination options apply only to uploading, not scan or validate.");
429
489
  }
490
+
430
491
  if (action === "import" && (typeof flags.collection !== "string" || !flags.collection.trim()))
431
492
  throw new Error("Upload requires an explicit --collection.");
493
+
432
494
  checkAbort(signal);
495
+
433
496
  let last_progress = 0;
434
497
  /** @param {import("./import.generated.js").ImportProgress} progress */
435
498
  const onprogress = ({ bytes_read, total_bytes, rows }) => {
436
499
  if (Date.now() - last_progress < 1000) return;
500
+
437
501
  last_progress = Date.now();
438
502
  stderr.write("Reading: " + bytes_read + "/" + total_bytes + " bytes, " + rows + " rows.\n");
439
503
  };
@@ -443,6 +507,7 @@ export async function runImportCommand(argv, context = {}) {
443
507
  /** @param {import("./import.generated.js").ImportWorkerStatus} status */
444
508
  const onworkers = (status) => {
445
509
  let line;
510
+
446
511
  if (status.fallback)
447
512
  line = `XML workers stopped (${status.fallback}); continuing sequentially.`;
448
513
  else if (status.actual)
@@ -450,10 +515,12 @@ export async function runImportCommand(argv, context = {}) {
450
515
  else if (status.requested || (workers !== "auto" && workers > 0))
451
516
  line = `XML workers: none, reading sequentially (${status.reason}).`;
452
517
  else return;
518
+
453
519
  stderr.write(sanitizeText(line) + "\n");
454
520
  };
455
521
 
456
522
  outcome = "file_error";
523
+
457
524
  if (action === "import" && flags["validate-first"] === true) {
458
525
  stderr.write("Making a private snapshot for a full check before upload.\n");
459
526
  input = await snapshotImportFile(words[0], {
@@ -461,8 +528,11 @@ export async function runImportCommand(argv, context = {}) {
461
528
  temporary_root: context.temporary_root
462
529
  });
463
530
  } else input = await openImportFile(words[0], { signal });
531
+
464
532
  const source = input.source;
533
+
465
534
  outcome = "validation_failed";
535
+
466
536
  const detection = await detectImport(source, {
467
537
  format: /** @type {any} */ (flags.format ?? "auto"),
468
538
  signal
@@ -482,9 +552,11 @@ export async function runImportCommand(argv, context = {}) {
482
552
  ...options,
483
553
  is_full: action === "validate" || flags.full === true || flags["validate-first"] === true
484
554
  });
555
+
485
556
  if (action !== "import") {
486
557
  if (action === "validate" && (!report.is_complete || report.is_sampled))
487
558
  throw new Error("Validation did not cover the complete selected input.");
559
+
488
560
  return {
489
561
  output: shouldEmitJson(flags)
490
562
  ? JSON.stringify({ action, ...report }, null, 2)
@@ -492,10 +564,13 @@ export async function runImportCommand(argv, context = {}) {
492
564
  code: 0
493
565
  };
494
566
  }
567
+
495
568
  if (flags["validate-first"] === true && (!report.is_complete || report.is_sampled))
496
569
  throw new Error("The full file check did not finish.");
570
+
497
571
  total_rows = report.is_complete && !report.is_sampled ? report.row_count : null;
498
572
  outcome = "destination_failed";
573
+
499
574
  const destination = await resolveDestination(
500
575
  flags,
501
576
  signal,
@@ -504,8 +579,10 @@ export async function runImportCommand(argv, context = {}) {
504
579
  );
505
580
 
506
581
  const readiness = await getJson(destination.url, {}, signal, request);
582
+
507
583
  if (readiness?.status !== "ready" || readiness.project_id !== destination.project_id)
508
584
  throw new Error("The ingest endpoint is not ready for the expected project.");
585
+
509
586
  outcome = "validation_failed";
510
587
  for await (const batch of createImportBatches(readImportRows(source, options))) {
511
588
  checkAbort(signal);
@@ -516,8 +593,11 @@ export async function runImportCommand(argv, context = {}) {
516
593
  last_row: batch.rows[batch.rows.length - 1].index,
517
594
  status: "uncertain"
518
595
  };
596
+
519
597
  outcome = "uncertain_delivery";
598
+
520
599
  const linked = requestSignal(signal, 30_000);
600
+
521
601
  try {
522
602
  const response = await request(destination.url, {
523
603
  method: "POST",
@@ -527,10 +607,12 @@ export async function runImportCommand(argv, context = {}) {
527
607
  redirect: "error",
528
608
  credentials: "omit"
529
609
  });
610
+
530
611
  if (response.status !== 202) {
531
612
  affected_batch.status = IMPORT_REJECTED_STATUSES.includes(response.status)
532
613
  ? "rejected"
533
614
  : "uncertain";
615
+
534
616
  outcome = affected_batch.status === "rejected" ? "rejected" : "uncertain_delivery";
535
617
  affected_batch.http_status = response.status;
536
618
  void response.body?.cancel().catch(() => {});
@@ -538,9 +620,11 @@ export async function runImportCommand(argv, context = {}) {
538
620
  "The server did not acknowledge the batch. Upload stopped without retrying."
539
621
  );
540
622
  }
623
+
541
624
  accepted_count += batch.rows.length;
542
625
  affected_batch = null;
543
626
  void response.body?.cancel().catch(() => {});
627
+
544
628
  if (Date.now() - last_progress >= 1000 || accepted_count === total_rows) {
545
629
  last_progress = Date.now();
546
630
  stderr.write(
@@ -553,22 +637,27 @@ export async function runImportCommand(argv, context = {}) {
553
637
  } catch {
554
638
  if (affected_batch?.status === "rejected")
555
639
  throw new Error("The server rejected the batch. Upload stopped without retrying.");
640
+
556
641
  throw new Error(
557
642
  "No acknowledgement from the server. The affected batch may have arrived; upload stopped without retrying."
558
643
  );
559
644
  } finally {
560
645
  linked.dispose();
561
646
  }
647
+
562
648
  outcome = "validation_failed";
563
649
  }
650
+
564
651
  checkAbort(signal);
565
652
  await input.assertUnchanged();
653
+
566
654
  if (!accepted_count)
567
655
  throw new Error(
568
656
  "This selection has no data rows. Check the XML record path or file contents."
569
657
  );
570
658
  if (total_rows !== null && accepted_count !== total_rows)
571
659
  throw new Error("The imported row count differed from the full file check.");
660
+
572
661
  total_rows = accepted_count;
573
662
  result = {
574
663
  action,
@@ -592,18 +681,22 @@ export async function runImportCommand(argv, context = {}) {
592
681
  error = source_error;
593
682
  }
594
683
  }
684
+
685
+ let failure_outcome = outcome;
686
+
687
+ if (
688
+ error &&
689
+ typeof error === "object" &&
690
+ "code" in error &&
691
+ error.code === "IMPORT_SOURCE_CHANGED"
692
+ )
693
+ failure_outcome = "file_changed";
694
+ else if (signal.aborted && !affected_batch) failure_outcome = "cancelled";
695
+
595
696
  result = {
596
697
  action,
597
698
  status: signal.aborted ? "cancelled" : "failed",
598
- outcome:
599
- error &&
600
- typeof error === "object" &&
601
- "code" in error &&
602
- error.code === "IMPORT_SOURCE_CHANGED"
603
- ? "file_changed"
604
- : signal.aborted && !affected_batch
605
- ? "cancelled"
606
- : outcome,
699
+ outcome: failure_outcome,
607
700
  ...(error &&
608
701
  typeof error === "object" &&
609
702
  "index" in error &&
@@ -622,15 +715,23 @@ export async function runImportCommand(argv, context = {}) {
622
715
  } catch {
623
716
  const cleanup_error =
624
717
  "Could not close the input or remove its private temporary snapshot. Check the tailglow-import directory in your temporary folder.";
718
+
625
719
  result = { ...result, cleanup_error };
626
720
  stderr.write("Warning: " + cleanup_error + "\n");
627
721
  }
628
722
  }
723
+
629
724
  external_signal?.removeEventListener("abort", abort);
630
725
  process.removeListener("SIGINT", abort);
631
726
  }
727
+
728
+ let exit_code = 1;
729
+
730
+ if (result.status === "complete") exit_code = 0;
731
+ else if (signal.aborted) exit_code = 130;
732
+
632
733
  return {
633
734
  output: shouldEmitJson(flags) ? JSON.stringify(result, null, 2) : renderHumanReport(result),
634
- code: result.status === "complete" ? 0 : signal.aborted ? 130 : 1
735
+ code: exit_code
635
736
  };
636
737
  }