@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/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)
|
package/src/import-command.js
CHANGED
|
@@ -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:
|
|
735
|
+
code: exit_code
|
|
635
736
|
};
|
|
636
737
|
}
|