@headband/cli 0.2.0 → 0.3.0
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 +334 -67
- package/dist/index.js +2136 -401
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -31,11 +31,18 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
31
31
|
// src/index.ts
|
|
32
32
|
var index_exports = {};
|
|
33
33
|
__export(index_exports, {
|
|
34
|
+
CLI_VERSION: () => CLI_VERSION,
|
|
35
|
+
main: () => main,
|
|
34
36
|
parseArgv: () => parseArgv,
|
|
35
|
-
|
|
37
|
+
parseExpires: () => parseExpires,
|
|
38
|
+
parsePermissions: () => parsePermissions,
|
|
39
|
+
run: () => run,
|
|
40
|
+
stdinIsInteractive: () => stdinIsInteractive
|
|
36
41
|
});
|
|
37
42
|
module.exports = __toCommonJS(index_exports);
|
|
38
|
-
var
|
|
43
|
+
var import_fs10 = require("fs");
|
|
44
|
+
var import_fs11 = require("fs");
|
|
45
|
+
var import_path9 = require("path");
|
|
39
46
|
var import_prompts2 = __toESM(require("prompts"));
|
|
40
47
|
var import_picocolors3 = __toESM(require("picocolors"));
|
|
41
48
|
|
|
@@ -235,20 +242,79 @@ var import_os2 = require("os");
|
|
|
235
242
|
var import_picocolors2 = __toESM(require("picocolors"));
|
|
236
243
|
|
|
237
244
|
// src/errors.ts
|
|
245
|
+
var EXIT_CODES = {
|
|
246
|
+
ok: 0,
|
|
247
|
+
generic: 1,
|
|
248
|
+
usage: 2,
|
|
249
|
+
auth: 3,
|
|
250
|
+
permission: 4,
|
|
251
|
+
notFound: 5,
|
|
252
|
+
conflict: 6,
|
|
253
|
+
rateLimited: 7,
|
|
254
|
+
server: 8,
|
|
255
|
+
interrupted: 130
|
|
256
|
+
};
|
|
257
|
+
function exitCodeForStatus(status) {
|
|
258
|
+
if (status === 400 || status === 413 || status === 422) return EXIT_CODES.usage;
|
|
259
|
+
if (status === 401) return EXIT_CODES.auth;
|
|
260
|
+
if (status === 402 || status === 403) return EXIT_CODES.permission;
|
|
261
|
+
if (status === 404 || status === 410) return EXIT_CODES.notFound;
|
|
262
|
+
if (status === 409) return EXIT_CODES.conflict;
|
|
263
|
+
if (status === 429) return EXIT_CODES.rateLimited;
|
|
264
|
+
if (status >= 500) return EXIT_CODES.server;
|
|
265
|
+
return EXIT_CODES.generic;
|
|
266
|
+
}
|
|
238
267
|
var CliError = class extends Error {
|
|
239
|
-
constructor(message) {
|
|
268
|
+
constructor(message, options = {}) {
|
|
240
269
|
super(message);
|
|
241
270
|
this.name = "CliError";
|
|
271
|
+
this.exitCode = options.exitCode ?? EXIT_CODES.generic;
|
|
272
|
+
this.code = options.code;
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
var UsageError = class extends CliError {
|
|
276
|
+
constructor(message, code = "usage_error") {
|
|
277
|
+
super(message, { exitCode: EXIT_CODES.usage, code });
|
|
278
|
+
this.name = "UsageError";
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
var AuthError = class extends CliError {
|
|
282
|
+
constructor(message) {
|
|
283
|
+
super(message, { exitCode: EXIT_CODES.auth, code: "not_authenticated" });
|
|
284
|
+
this.name = "AuthError";
|
|
242
285
|
}
|
|
243
286
|
};
|
|
244
287
|
var ApiError = class extends CliError {
|
|
245
|
-
constructor(message, status, code) {
|
|
246
|
-
super(message);
|
|
288
|
+
constructor(message, status, code, details) {
|
|
289
|
+
super(message, { exitCode: exitCodeForStatus(status), code });
|
|
247
290
|
this.name = "ApiError";
|
|
248
291
|
this.status = status;
|
|
249
|
-
this.
|
|
292
|
+
this.details = details;
|
|
250
293
|
}
|
|
251
294
|
};
|
|
295
|
+
var DEFAULT_CODES = {
|
|
296
|
+
[EXIT_CODES.generic]: "error",
|
|
297
|
+
[EXIT_CODES.usage]: "usage_error",
|
|
298
|
+
[EXIT_CODES.auth]: "not_authenticated",
|
|
299
|
+
[EXIT_CODES.permission]: "permission_denied",
|
|
300
|
+
[EXIT_CODES.notFound]: "not_found",
|
|
301
|
+
[EXIT_CODES.conflict]: "conflict",
|
|
302
|
+
[EXIT_CODES.rateLimited]: "rate_limited",
|
|
303
|
+
[EXIT_CODES.server]: "server_error",
|
|
304
|
+
[EXIT_CODES.interrupted]: "interrupted"
|
|
305
|
+
};
|
|
306
|
+
function errorExitCode(error) {
|
|
307
|
+
return error instanceof CliError ? error.exitCode : EXIT_CODES.generic;
|
|
308
|
+
}
|
|
309
|
+
function errorPayload(error) {
|
|
310
|
+
const exitCode = errorExitCode(error);
|
|
311
|
+
return {
|
|
312
|
+
error: error instanceof Error ? error.message : String(error),
|
|
313
|
+
code: error instanceof CliError && error.code || DEFAULT_CODES[exitCode] || "error",
|
|
314
|
+
status: error instanceof ApiError ? error.status : null,
|
|
315
|
+
exitCode
|
|
316
|
+
};
|
|
317
|
+
}
|
|
252
318
|
function partialFailureSummary(failure) {
|
|
253
319
|
const first = failure.errors[0];
|
|
254
320
|
if (!first) return failure.message;
|
|
@@ -261,6 +327,7 @@ var import_fs2 = require("fs");
|
|
|
261
327
|
var import_path3 = require("path");
|
|
262
328
|
var import_os = require("os");
|
|
263
329
|
var DEFAULT_URL = "https://headband.dev";
|
|
330
|
+
var DEFAULT_PROFILE = "default";
|
|
264
331
|
function normalizeUrl(url) {
|
|
265
332
|
return url.replace(/\/+$/, "");
|
|
266
333
|
}
|
|
@@ -270,66 +337,168 @@ function getConfigDir() {
|
|
|
270
337
|
function getCredentialsPath() {
|
|
271
338
|
return (0, import_path3.join)(getConfigDir(), "credentials.json");
|
|
272
339
|
}
|
|
340
|
+
var PROFILE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
341
|
+
function validateProfileName(name) {
|
|
342
|
+
if (!PROFILE_NAME.test(name)) {
|
|
343
|
+
throw new UsageError(
|
|
344
|
+
"Profile names use letters, digits, dot, dash and underscore (max 64 characters)."
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return name;
|
|
348
|
+
}
|
|
349
|
+
function profileNameForUrl(url) {
|
|
350
|
+
const normalized = normalizeUrl(url);
|
|
351
|
+
if (normalized === DEFAULT_URL) return DEFAULT_PROFILE;
|
|
352
|
+
try {
|
|
353
|
+
const { host } = new URL(normalized);
|
|
354
|
+
const slug = host.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[^A-Za-z0-9]+/, "");
|
|
355
|
+
return slug || DEFAULT_PROFILE;
|
|
356
|
+
} catch {
|
|
357
|
+
return DEFAULT_PROFILE;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function isRecord(value) {
|
|
361
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
362
|
+
}
|
|
363
|
+
function migrateCredentials(raw) {
|
|
364
|
+
const parsed = isRecord(raw) ? raw : {};
|
|
365
|
+
const rawProfiles = isRecord(parsed.profiles) ? parsed.profiles : {};
|
|
366
|
+
const profiles = {};
|
|
367
|
+
let migratedDefault;
|
|
368
|
+
let migratedCount = 0;
|
|
369
|
+
for (const [key, value] of Object.entries(rawProfiles)) {
|
|
370
|
+
if (!isRecord(value)) continue;
|
|
371
|
+
if (typeof value.url === "string") {
|
|
372
|
+
profiles[key] = value;
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const url = normalizeUrl(key);
|
|
376
|
+
const base = profileNameForUrl(url);
|
|
377
|
+
let name = base;
|
|
378
|
+
for (let n = 2; profiles[name] || rawProfiles[name]; n += 1) name = `${base}-${n}`;
|
|
379
|
+
profiles[name] = { ...value, url };
|
|
380
|
+
migratedCount += 1;
|
|
381
|
+
if (url === DEFAULT_URL) migratedDefault = name;
|
|
382
|
+
}
|
|
383
|
+
let current = typeof parsed.current === "string" ? parsed.current : void 0;
|
|
384
|
+
if (current && !profiles[current]) current = void 0;
|
|
385
|
+
if (!current && migratedCount > 0) {
|
|
386
|
+
const names = Object.keys(profiles);
|
|
387
|
+
current = names.length === 1 ? names[0] : migratedDefault;
|
|
388
|
+
}
|
|
389
|
+
return { version: 2, ...current ? { current } : {}, profiles };
|
|
390
|
+
}
|
|
273
391
|
function readCredentials() {
|
|
274
|
-
const
|
|
275
|
-
if (!(0, import_fs2.existsSync)(
|
|
276
|
-
return { profiles: {} };
|
|
392
|
+
const path2 = getCredentialsPath();
|
|
393
|
+
if (!(0, import_fs2.existsSync)(path2)) {
|
|
394
|
+
return { version: 2, profiles: {} };
|
|
277
395
|
}
|
|
278
396
|
try {
|
|
279
|
-
|
|
280
|
-
return {
|
|
281
|
-
profiles: parsed.profiles && typeof parsed.profiles === "object" ? parsed.profiles : {}
|
|
282
|
-
};
|
|
397
|
+
return migrateCredentials(JSON.parse((0, import_fs2.readFileSync)(path2, "utf-8")));
|
|
283
398
|
} catch {
|
|
284
|
-
throw new CliError(`Could not read credentials at ${
|
|
399
|
+
throw new CliError(`Could not read credentials at ${path2}`);
|
|
285
400
|
}
|
|
286
401
|
}
|
|
287
402
|
function writeCredentials(credentials) {
|
|
288
|
-
const
|
|
289
|
-
(
|
|
290
|
-
|
|
403
|
+
const path2 = getCredentialsPath();
|
|
404
|
+
if (Object.keys(credentials.profiles).length === 0) {
|
|
405
|
+
if ((0, import_fs2.existsSync)(path2)) (0, import_fs2.rmSync)(path2);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
(0, import_fs2.mkdirSync)((0, import_path3.dirname)(path2), { recursive: true, mode: 448 });
|
|
409
|
+
(0, import_fs2.writeFileSync)(path2, `${JSON.stringify(credentials, null, 2)}
|
|
291
410
|
`, {
|
|
292
411
|
encoding: "utf-8",
|
|
293
412
|
mode: 384
|
|
294
413
|
});
|
|
295
|
-
(0, import_fs2.chmodSync)(
|
|
414
|
+
(0, import_fs2.chmodSync)(path2, 384);
|
|
296
415
|
}
|
|
297
|
-
function saveProfile(
|
|
416
|
+
function saveProfile(name, profile) {
|
|
417
|
+
validateProfileName(name);
|
|
298
418
|
const credentials = readCredentials();
|
|
299
|
-
credentials.profiles[normalizeUrl(url)
|
|
419
|
+
credentials.profiles[name] = { ...profile, url: normalizeUrl(profile.url) };
|
|
420
|
+
credentials.current = name;
|
|
300
421
|
writeCredentials(credentials);
|
|
301
422
|
}
|
|
302
|
-
function removeProfile(
|
|
423
|
+
function removeProfile(name) {
|
|
303
424
|
const credentials = readCredentials();
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
delete credentials.
|
|
307
|
-
if (Object.keys(credentials.profiles).length === 0) {
|
|
308
|
-
const path = getCredentialsPath();
|
|
309
|
-
if ((0, import_fs2.existsSync)(path)) {
|
|
310
|
-
(0, import_fs2.rmSync)(path);
|
|
311
|
-
}
|
|
312
|
-
return existing;
|
|
313
|
-
}
|
|
425
|
+
const existing = credentials.profiles[name];
|
|
426
|
+
delete credentials.profiles[name];
|
|
427
|
+
if (credentials.current === name) delete credentials.current;
|
|
314
428
|
writeCredentials(credentials);
|
|
315
429
|
return existing;
|
|
316
430
|
}
|
|
317
|
-
function
|
|
318
|
-
if (explicitUrl) return normalizeUrl(explicitUrl);
|
|
431
|
+
function setCurrentProfile(name) {
|
|
319
432
|
const credentials = readCredentials();
|
|
320
|
-
const
|
|
321
|
-
if (urls.length === 1) return urls[0];
|
|
322
|
-
return DEFAULT_URL;
|
|
323
|
-
}
|
|
324
|
-
function getProfile(url) {
|
|
325
|
-
const resolvedUrl = resolveUrl(url);
|
|
326
|
-
const profile = readCredentials().profiles[resolvedUrl];
|
|
433
|
+
const profile = credentials.profiles[name];
|
|
327
434
|
if (!profile) {
|
|
328
|
-
throw new CliError(
|
|
329
|
-
|
|
435
|
+
throw new CliError(`No profile named ${name}. Run: headband profiles list`, {
|
|
436
|
+
exitCode: 5,
|
|
437
|
+
code: "profile_not_found"
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
credentials.current = name;
|
|
441
|
+
writeCredentials(credentials);
|
|
442
|
+
return profile;
|
|
443
|
+
}
|
|
444
|
+
function selectProfile(selectors = {}) {
|
|
445
|
+
const env = selectors.env ?? process.env;
|
|
446
|
+
const credentials = readCredentials();
|
|
447
|
+
const urlOverride = selectors.url || env.HEADBAND_URL;
|
|
448
|
+
const wantedUrl = urlOverride ? normalizeUrl(urlOverride) : void 0;
|
|
449
|
+
const explicit = selectors.profile || env.HEADBAND_PROFILE;
|
|
450
|
+
if (explicit) {
|
|
451
|
+
const profile = credentials.profiles[explicit];
|
|
452
|
+
if (!profile) {
|
|
453
|
+
throw new AuthError(`No profile named ${explicit}. Run: headband profiles list`);
|
|
454
|
+
}
|
|
455
|
+
if (wantedUrl && normalizeUrl(profile.url) !== wantedUrl) {
|
|
456
|
+
throw new UsageError(
|
|
457
|
+
`Profile ${explicit} is for ${profile.url}, not ${wantedUrl}. Drop --url, or log in to that URL with: headband login --url ${wantedUrl} --profile <name>`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
return { name: explicit, profile };
|
|
461
|
+
}
|
|
462
|
+
const entries = Object.entries(credentials.profiles);
|
|
463
|
+
const matches = (p) => !wantedUrl || normalizeUrl(p.url) === wantedUrl;
|
|
464
|
+
const current = credentials.current ? credentials.profiles[credentials.current] : void 0;
|
|
465
|
+
if (current && credentials.current && matches(current)) {
|
|
466
|
+
return { name: credentials.current, profile: current };
|
|
467
|
+
}
|
|
468
|
+
if (wantedUrl) {
|
|
469
|
+
const found = entries.find(([, p]) => matches(p));
|
|
470
|
+
return found ? { name: found[0], profile: found[1] } : void 0;
|
|
471
|
+
}
|
|
472
|
+
if (entries.length === 1) return { name: entries[0][0], profile: entries[0][1] };
|
|
473
|
+
return void 0;
|
|
474
|
+
}
|
|
475
|
+
function resolveAuth(selectors = {}) {
|
|
476
|
+
const env = selectors.env ?? process.env;
|
|
477
|
+
const urlOverride = selectors.url || env.HEADBAND_URL;
|
|
478
|
+
const token = selectors.token || env.HEADBAND_API_KEY;
|
|
479
|
+
if (token) {
|
|
480
|
+
const explicitProfile = selectors.profile || env.HEADBAND_PROFILE;
|
|
481
|
+
const fromProfile = explicitProfile && !urlOverride ? selectProfile(selectors) : void 0;
|
|
482
|
+
return {
|
|
483
|
+
url: normalizeUrl(urlOverride || fromProfile?.profile.url || DEFAULT_URL),
|
|
484
|
+
apiKey: token.trim(),
|
|
485
|
+
source: selectors.token ? "flag" : "env"
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
const selected = selectProfile(selectors);
|
|
489
|
+
if (!selected) {
|
|
490
|
+
const target = urlOverride ? normalizeUrl(urlOverride) : DEFAULT_URL;
|
|
491
|
+
throw new AuthError(
|
|
492
|
+
`Not logged in for ${target}. Run: headband login --url ${target}, or set HEADBAND_API_KEY.`
|
|
330
493
|
);
|
|
331
494
|
}
|
|
332
|
-
return {
|
|
495
|
+
return {
|
|
496
|
+
url: normalizeUrl(selected.profile.url),
|
|
497
|
+
apiKey: selected.profile.apiKey,
|
|
498
|
+
source: "profile",
|
|
499
|
+
profileName: selected.name,
|
|
500
|
+
profile: selected.profile
|
|
501
|
+
};
|
|
333
502
|
}
|
|
334
503
|
|
|
335
504
|
// src/api.ts
|
|
@@ -342,7 +511,7 @@ async function readJson(response) {
|
|
|
342
511
|
return text;
|
|
343
512
|
}
|
|
344
513
|
}
|
|
345
|
-
async function apiRequest(baseUrl,
|
|
514
|
+
async function apiRequest(baseUrl, path2, init2 = {}) {
|
|
346
515
|
const headers = new Headers(init2.headers);
|
|
347
516
|
headers.set("accept", "application/json");
|
|
348
517
|
if (init2.body !== void 0 && !headers.has("content-type")) {
|
|
@@ -351,30 +520,42 @@ async function apiRequest(baseUrl, path, init2 = {}) {
|
|
|
351
520
|
if (init2.apiKey) {
|
|
352
521
|
headers.set("authorization", `Bearer ${init2.apiKey}`);
|
|
353
522
|
}
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
523
|
+
const base = normalizeUrl(baseUrl);
|
|
524
|
+
let response;
|
|
525
|
+
try {
|
|
526
|
+
response = await fetch(`${base}${path2}`, {
|
|
527
|
+
...init2,
|
|
528
|
+
headers,
|
|
529
|
+
body: init2.body === void 0 || typeof init2.body === "string" ? init2.body : JSON.stringify(init2.body)
|
|
530
|
+
});
|
|
531
|
+
} catch (error) {
|
|
532
|
+
const cause = error?.cause;
|
|
533
|
+
const detail = cause?.code || cause?.message || (error instanceof Error ? error.message : "");
|
|
534
|
+
throw new CliError(`Could not reach ${base}${detail ? ` (${detail})` : ""}.`, {
|
|
535
|
+
exitCode: EXIT_CODES.server,
|
|
536
|
+
code: "network_error"
|
|
537
|
+
});
|
|
538
|
+
}
|
|
359
539
|
const body = await readJson(response);
|
|
360
540
|
if (!response.ok) {
|
|
361
541
|
const errorBody = body && typeof body === "object" ? body : {};
|
|
362
542
|
throw new ApiError(
|
|
363
543
|
errorBody.error || `Request failed with status ${response.status}`,
|
|
364
544
|
response.status,
|
|
365
|
-
errorBody.code
|
|
545
|
+
errorBody.code,
|
|
546
|
+
body
|
|
366
547
|
);
|
|
367
548
|
}
|
|
368
549
|
return body;
|
|
369
550
|
}
|
|
370
551
|
function createApiClient(url, profile) {
|
|
371
|
-
const request = (
|
|
552
|
+
const request = (path2, init2 = {}) => apiRequest(url, path2, { ...init2, apiKey: profile.apiKey });
|
|
372
553
|
return {
|
|
373
554
|
url: normalizeUrl(url),
|
|
374
|
-
get: (
|
|
375
|
-
post: (
|
|
376
|
-
patch: (
|
|
377
|
-
delete: (
|
|
555
|
+
get: (path2) => request(path2, { method: "GET" }),
|
|
556
|
+
post: (path2, body) => request(path2, { method: "POST", body }),
|
|
557
|
+
patch: (path2, body) => request(path2, { method: "PATCH", body }),
|
|
558
|
+
delete: (path2) => request(path2, { method: "DELETE" })
|
|
378
559
|
};
|
|
379
560
|
}
|
|
380
561
|
|
|
@@ -438,8 +619,11 @@ async function runDeviceFlow(url, io, options = {}) {
|
|
|
438
619
|
throw new CliError("Login expired. Re-run headband login.");
|
|
439
620
|
}
|
|
440
621
|
async function login(url, io, options = {}) {
|
|
441
|
-
const
|
|
442
|
-
|
|
622
|
+
const baseUrl = normalizeUrl(url);
|
|
623
|
+
const profile = validateProfileName(options.profileName || profileNameForUrl(baseUrl));
|
|
624
|
+
const token = await runDeviceFlow(baseUrl, io, options);
|
|
625
|
+
saveProfile(profile, {
|
|
626
|
+
url: baseUrl,
|
|
443
627
|
apiKey: token.apiKey,
|
|
444
628
|
keyId: token.keyId,
|
|
445
629
|
keyPrefix: token.keyPrefix,
|
|
@@ -447,7 +631,17 @@ async function login(url, io, options = {}) {
|
|
|
447
631
|
orgName: token.orgName,
|
|
448
632
|
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
449
633
|
});
|
|
450
|
-
io.stdout(
|
|
634
|
+
io.stdout(
|
|
635
|
+
import_picocolors2.default.green(`Logged in to ${baseUrl} as ${token.orgName} (${token.keyPrefix}), profile ${profile}`)
|
|
636
|
+
);
|
|
637
|
+
return {
|
|
638
|
+
profile,
|
|
639
|
+
url: baseUrl,
|
|
640
|
+
orgId: token.orgId,
|
|
641
|
+
orgName: token.orgName,
|
|
642
|
+
keyId: token.keyId,
|
|
643
|
+
keyPrefix: token.keyPrefix
|
|
644
|
+
};
|
|
451
645
|
}
|
|
452
646
|
|
|
453
647
|
// src/config-load.ts
|
|
@@ -464,8 +658,8 @@ var CONFIG_FILENAMES = [
|
|
|
464
658
|
];
|
|
465
659
|
function findConfigFile(cwd) {
|
|
466
660
|
for (const name of CONFIG_FILENAMES) {
|
|
467
|
-
const
|
|
468
|
-
if ((0, import_fs3.existsSync)(
|
|
661
|
+
const path2 = (0, import_path4.join)(cwd, name);
|
|
662
|
+
if ((0, import_fs3.existsSync)(path2)) return path2;
|
|
469
663
|
}
|
|
470
664
|
return void 0;
|
|
471
665
|
}
|
|
@@ -473,8 +667,8 @@ function isSyncConfig(value) {
|
|
|
473
667
|
return typeof value === "object" && value !== null && Array.isArray(value.indexes);
|
|
474
668
|
}
|
|
475
669
|
async function loadHeadbandConfig(cwd) {
|
|
476
|
-
const
|
|
477
|
-
if (!
|
|
670
|
+
const path2 = findConfigFile(cwd);
|
|
671
|
+
if (!path2) {
|
|
478
672
|
throw new CliError(
|
|
479
673
|
`No headband.config.ts found in ${cwd}. Run "headband init" or create one that exports a Headband sync config.`
|
|
480
674
|
);
|
|
@@ -486,27 +680,27 @@ async function loadHeadbandConfig(cwd) {
|
|
|
486
680
|
});
|
|
487
681
|
let mod;
|
|
488
682
|
try {
|
|
489
|
-
mod = await jiti.import(
|
|
683
|
+
mod = await jiti.import(path2);
|
|
490
684
|
} catch (err) {
|
|
491
685
|
throw new CliError(
|
|
492
|
-
`Failed to load ${
|
|
686
|
+
`Failed to load ${path2}: ${err instanceof Error ? err.message : String(err)}`
|
|
493
687
|
);
|
|
494
688
|
}
|
|
495
689
|
const candidate = mod.default ?? mod;
|
|
496
690
|
if (!isSyncConfig(candidate)) {
|
|
497
|
-
throw new CliError(`${
|
|
691
|
+
throw new CliError(`${path2} does not export a valid Headband sync config (missing "indexes").`);
|
|
498
692
|
}
|
|
499
693
|
const adapter = mod.adapter;
|
|
500
|
-
return { config: candidate, adapter, path };
|
|
694
|
+
return { config: candidate, adapter, path: path2 };
|
|
501
695
|
}
|
|
502
696
|
|
|
503
697
|
// src/docs.ts
|
|
504
698
|
var import_path5 = require("path");
|
|
505
699
|
var import_fs4 = require("fs");
|
|
506
700
|
var DOCS_BATCH_SIZE = 1e3;
|
|
507
|
-
function parseDocumentsFile(
|
|
508
|
-
const content = (0, import_fs4.readFileSync)(
|
|
509
|
-
const ext = (0, import_path5.extname)(
|
|
701
|
+
function parseDocumentsFile(path2) {
|
|
702
|
+
const content = (0, import_fs4.readFileSync)(path2, "utf-8");
|
|
703
|
+
const ext = (0, import_path5.extname)(path2).toLowerCase();
|
|
510
704
|
if (ext === ".jsonl") {
|
|
511
705
|
return content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
|
|
512
706
|
}
|
|
@@ -529,6 +723,49 @@ async function pushDocuments(client, index, documents, io) {
|
|
|
529
723
|
}
|
|
530
724
|
return pushed;
|
|
531
725
|
}
|
|
726
|
+
var EXPORT_BATCH_SIZE = 1e3;
|
|
727
|
+
var TYPESENSE_MAX_PAGE = 250;
|
|
728
|
+
async function defaultExportBatchSize(client, index) {
|
|
729
|
+
try {
|
|
730
|
+
const list = await client.get("/v1/indexes");
|
|
731
|
+
const row = list.results?.find((r) => r.uid === index);
|
|
732
|
+
return row?.engine === "typesense" ? TYPESENSE_MAX_PAGE : EXPORT_BATCH_SIZE;
|
|
733
|
+
} catch {
|
|
734
|
+
return EXPORT_BATCH_SIZE;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
async function exportDocuments(client, options) {
|
|
738
|
+
const { index, format, batchSize, write } = options;
|
|
739
|
+
let offset = 0;
|
|
740
|
+
let exported = 0;
|
|
741
|
+
let pending;
|
|
742
|
+
if (format === "json") await write("[");
|
|
743
|
+
for (; ; ) {
|
|
744
|
+
const page = await client.get(
|
|
745
|
+
`/v1/documents?index=${encodeURIComponent(index)}&limit=${batchSize}&offset=${offset}`
|
|
746
|
+
);
|
|
747
|
+
const results = Array.isArray(page.results) ? page.results : [];
|
|
748
|
+
for (const doc of results) {
|
|
749
|
+
const line = JSON.stringify(doc);
|
|
750
|
+
if (format === "jsonl") {
|
|
751
|
+
await write(line);
|
|
752
|
+
} else {
|
|
753
|
+
if (pending !== void 0) await write(` ${pending},`);
|
|
754
|
+
pending = line;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
exported += results.length;
|
|
758
|
+
offset += results.length;
|
|
759
|
+
options.onPage?.(exported, typeof page.total === "number" ? page.total : null);
|
|
760
|
+
if (results.length < batchSize) break;
|
|
761
|
+
if (typeof page.total === "number" && offset >= page.total) break;
|
|
762
|
+
}
|
|
763
|
+
if (format === "json") {
|
|
764
|
+
if (pending !== void 0) await write(` ${pending}`);
|
|
765
|
+
await write("]");
|
|
766
|
+
}
|
|
767
|
+
return exported;
|
|
768
|
+
}
|
|
532
769
|
|
|
533
770
|
// ../../src/lib/density/catalog-constants.ts
|
|
534
771
|
function formatMemory(mb) {
|
|
@@ -537,7 +774,48 @@ function formatMemory(mb) {
|
|
|
537
774
|
|
|
538
775
|
// ../../src/lib/density/snippets.ts
|
|
539
776
|
var ADMIN_NOTE = "Creating an index and adding a document need an admin key. A search key can only search.";
|
|
777
|
+
function visorSnippets(endpoint) {
|
|
778
|
+
return {
|
|
779
|
+
packageName: "built-in fetch",
|
|
780
|
+
install: "# Nothing to install. Node 20 and later have fetch built in.",
|
|
781
|
+
node: [
|
|
782
|
+
`const base = "${endpoint}";`,
|
|
783
|
+
"const headers = {",
|
|
784
|
+
" Authorization: `Bearer ${process.env.HEADBAND_API_KEY}`,",
|
|
785
|
+
' "Content-Type": "application/json",',
|
|
786
|
+
"};",
|
|
787
|
+
"",
|
|
788
|
+
`// ${ADMIN_NOTE}`,
|
|
789
|
+
"// Creating the index is safe to repeat: an existing index is kept as it is.",
|
|
790
|
+
'await fetch(`${base}/v1/indexes/movies`, { method: "PUT", headers, body: JSON.stringify({ primaryKey: "id" }) });',
|
|
791
|
+
'await fetch(`${base}/v1/indexes/movies/documents`, { method: "POST", headers, body: JSON.stringify([{ id: 1, title: "The Matrix" }]) });',
|
|
792
|
+
"",
|
|
793
|
+
'const res = await fetch(`${base}/v1/indexes/movies/search`, { method: "POST", headers, body: JSON.stringify({ q: "matrix" }) });',
|
|
794
|
+
"console.log((await res.json()).hits);"
|
|
795
|
+
].join("\n"),
|
|
796
|
+
curl: [
|
|
797
|
+
`# ${ADMIN_NOTE}`,
|
|
798
|
+
"# On a second run the index already exists and PUT keeps it. That is fine.",
|
|
799
|
+
`curl -X PUT "${endpoint}/v1/indexes/movies" \\`,
|
|
800
|
+
' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
|
|
801
|
+
' -H "Content-Type: application/json" \\',
|
|
802
|
+
` -d '{"primaryKey":"id"}'`,
|
|
803
|
+
"",
|
|
804
|
+
"# Writes are searchable when they return. There is nothing to wait for.",
|
|
805
|
+
`curl "${endpoint}/v1/indexes/movies/documents" \\`,
|
|
806
|
+
' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
|
|
807
|
+
' -H "Content-Type: application/json" \\',
|
|
808
|
+
` -d '[{"id":1,"title":"The Matrix"}]'`,
|
|
809
|
+
"",
|
|
810
|
+
`curl "${endpoint}/v1/indexes/movies/search" \\`,
|
|
811
|
+
' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
|
|
812
|
+
' -H "Content-Type: application/json" \\',
|
|
813
|
+
` -d '{"q":"matrix"}'`
|
|
814
|
+
].join("\n")
|
|
815
|
+
};
|
|
816
|
+
}
|
|
540
817
|
function connectSnippets(engine, endpoint) {
|
|
818
|
+
if (engine === "visor") return visorSnippets(endpoint);
|
|
541
819
|
if (engine === "elasticsearch") {
|
|
542
820
|
return {
|
|
543
821
|
packageName: "@elastic/elasticsearch",
|
|
@@ -718,27 +996,30 @@ function summarizeHit(hit) {
|
|
|
718
996
|
}
|
|
719
997
|
|
|
720
998
|
// src/instances.ts
|
|
721
|
-
var ENGINES = ["meilisearch", "elasticsearch", "typesense"];
|
|
999
|
+
var ENGINES = ["meilisearch", "elasticsearch", "typesense", "visor"];
|
|
722
1000
|
var CLIENTS = ["node", "curl"];
|
|
723
1001
|
var COLUMNS = ["id", "name", "engine", "lane", "memory", "region", "state"];
|
|
724
1002
|
var INSTANCES_USAGE = [
|
|
725
1003
|
"Usage:",
|
|
726
1004
|
" headband instances list [--json]",
|
|
727
|
-
" headband instances create <meilisearch|elasticsearch|typesense> [--name <name>] [--lane <serverless|always_on>]",
|
|
1005
|
+
" headband instances create <meilisearch|elasticsearch|typesense|visor> [--name <name>] [--lane <serverless|always_on>]",
|
|
728
1006
|
" [--memory <512m|1g|...>] [--region <region>] [--idle-timeout <seconds>] [--json]",
|
|
729
1007
|
" headband instances show <id> [--json]",
|
|
730
1008
|
" headband instances wake <id> [--json]",
|
|
731
1009
|
" headband instances park <id> [--json]",
|
|
1010
|
+
" headband instances update <id> [--name <name>] [--lane <serverless|always_on>] [--memory <512m|1g|...>]",
|
|
1011
|
+
" [--idle-timeout <seconds>] [--json] (--size is an alias for --memory)",
|
|
1012
|
+
" headband instances wakes <id> [--limit <n>] [--json]",
|
|
732
1013
|
" headband instances delete <id> [--yes] [--json]",
|
|
733
|
-
" headband instances connect <id> [--client <node|curl>]"
|
|
1014
|
+
" headband instances connect <id> [--client <node|curl>] [--json]"
|
|
734
1015
|
].join("\n");
|
|
735
1016
|
function parseMemory(raw) {
|
|
736
1017
|
const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(raw.trim());
|
|
737
|
-
if (!m) throw new
|
|
1018
|
+
if (!m) throw new UsageError("--memory must look like 512m, 1g or 2048");
|
|
738
1019
|
const value = Number(m[1]);
|
|
739
1020
|
const mb = (m[2] ?? "m").toLowerCase().startsWith("g") ? value * 1024 : value;
|
|
740
1021
|
if (!Number.isInteger(mb) || mb <= 0)
|
|
741
|
-
throw new
|
|
1022
|
+
throw new UsageError("--memory must be a whole number of megabytes");
|
|
742
1023
|
return mb;
|
|
743
1024
|
}
|
|
744
1025
|
function formatMemoryMb(mb) {
|
|
@@ -747,13 +1028,13 @@ function formatMemoryMb(mb) {
|
|
|
747
1028
|
function parseLane(raw) {
|
|
748
1029
|
const lane = raw.replace(/-/g, "_");
|
|
749
1030
|
if (lane !== "serverless" && lane !== "always_on")
|
|
750
|
-
throw new
|
|
1031
|
+
throw new UsageError("--lane must be serverless or always_on");
|
|
751
1032
|
return lane;
|
|
752
1033
|
}
|
|
753
1034
|
function parsePositiveInt(raw, name) {
|
|
754
1035
|
const value = Number(raw);
|
|
755
1036
|
if (!Number.isInteger(value) || value <= 0)
|
|
756
|
-
throw new
|
|
1037
|
+
throw new UsageError(`${name} must be a positive integer`);
|
|
757
1038
|
return value;
|
|
758
1039
|
}
|
|
759
1040
|
function stateLabel(i) {
|
|
@@ -773,17 +1054,21 @@ function summaryRow(i) {
|
|
|
773
1054
|
}
|
|
774
1055
|
function requireId(args) {
|
|
775
1056
|
const id = args[1];
|
|
776
|
-
if (!id) throw new
|
|
1057
|
+
if (!id) throw new UsageError("Missing engine id");
|
|
777
1058
|
return id;
|
|
778
1059
|
}
|
|
779
1060
|
function friendly(err) {
|
|
780
1061
|
if (err instanceof ApiError) {
|
|
781
1062
|
if (err.status === 404 && err.code === "not_found") {
|
|
782
|
-
throw new CliError("Dedicated engines are not enabled for this organization yet."
|
|
1063
|
+
throw new CliError("Dedicated engines are not enabled for this organization yet.", {
|
|
1064
|
+
exitCode: 5,
|
|
1065
|
+
code: "not_found"
|
|
1066
|
+
});
|
|
783
1067
|
}
|
|
784
1068
|
if (err.status === 403 && err.code === "permission_denied") {
|
|
785
1069
|
throw new CliError(
|
|
786
|
-
"This CLI key cannot manage engines. Run headband login again to get a key that can."
|
|
1070
|
+
"This CLI key cannot manage engines. Run headband login again to get a key that can.",
|
|
1071
|
+
{ exitCode: 4, code: "permission_denied" }
|
|
787
1072
|
);
|
|
788
1073
|
}
|
|
789
1074
|
}
|
|
@@ -792,8 +1077,8 @@ function friendly(err) {
|
|
|
792
1077
|
async function runInstances(args, flags, io, client, confirm) {
|
|
793
1078
|
const action = args[0];
|
|
794
1079
|
const json = flags["--json"] === true;
|
|
795
|
-
const
|
|
796
|
-
const
|
|
1080
|
+
const str2 = (name) => typeof flags[name] === "string" ? flags[name] : void 0;
|
|
1081
|
+
const path2 = (id, suffix = "") => `/v1/instances/${encodeURIComponent(id)}${suffix}`;
|
|
797
1082
|
try {
|
|
798
1083
|
if (action === "list") {
|
|
799
1084
|
const res = await client.get("/v1/instances");
|
|
@@ -815,14 +1100,14 @@ async function runInstances(args, flags, io, client, confirm) {
|
|
|
815
1100
|
if (action === "create") {
|
|
816
1101
|
const engine = args[1];
|
|
817
1102
|
if (!engine || !ENGINES.includes(engine)) {
|
|
818
|
-
throw new
|
|
1103
|
+
throw new UsageError("Engine must be one of: meilisearch, elasticsearch, typesense, visor");
|
|
819
1104
|
}
|
|
820
1105
|
const body = { engine };
|
|
821
|
-
const name =
|
|
822
|
-
const lane =
|
|
823
|
-
const memory =
|
|
824
|
-
const region =
|
|
825
|
-
const idle =
|
|
1106
|
+
const name = str2("--name");
|
|
1107
|
+
const lane = str2("--lane");
|
|
1108
|
+
const memory = str2("--memory");
|
|
1109
|
+
const region = str2("--region");
|
|
1110
|
+
const idle = str2("--idle-timeout");
|
|
826
1111
|
if (name) body.name = name;
|
|
827
1112
|
if (lane) body.lane = parseLane(lane);
|
|
828
1113
|
if (memory) body.memoryMb = parseMemory(memory);
|
|
@@ -837,7 +1122,7 @@ async function runInstances(args, flags, io, client, confirm) {
|
|
|
837
1122
|
}
|
|
838
1123
|
if (action === "show") {
|
|
839
1124
|
const id = requireId(args);
|
|
840
|
-
const res = await client.get(
|
|
1125
|
+
const res = await client.get(path2(id));
|
|
841
1126
|
if (json) return printJson(io, res);
|
|
842
1127
|
const i = res.data;
|
|
843
1128
|
const lines = [
|
|
@@ -860,7 +1145,7 @@ async function runInstances(args, flags, io, client, confirm) {
|
|
|
860
1145
|
}
|
|
861
1146
|
if (action === "wake") {
|
|
862
1147
|
const id = requireId(args);
|
|
863
|
-
const res = await client.post(
|
|
1148
|
+
const res = await client.post(path2(id, "/wake"));
|
|
864
1149
|
if (json) return printJson(io, res);
|
|
865
1150
|
const { wake } = res.data;
|
|
866
1151
|
io.stdout(
|
|
@@ -870,18 +1155,66 @@ async function runInstances(args, flags, io, client, confirm) {
|
|
|
870
1155
|
}
|
|
871
1156
|
if (action === "park") {
|
|
872
1157
|
const id = requireId(args);
|
|
873
|
-
const res = await client.post(
|
|
1158
|
+
const res = await client.post(path2(id, "/park"));
|
|
874
1159
|
if (json) return printJson(io, res);
|
|
875
1160
|
io.stdout(`Engine ${id} is ${res.data.state}.`);
|
|
876
1161
|
return;
|
|
877
1162
|
}
|
|
1163
|
+
if (action === "update") {
|
|
1164
|
+
const id = requireId(args);
|
|
1165
|
+
const body = {};
|
|
1166
|
+
const name = str2("--name");
|
|
1167
|
+
const lane = str2("--lane");
|
|
1168
|
+
const memory = str2("--memory") ?? str2("--size");
|
|
1169
|
+
const idle = str2("--idle-timeout");
|
|
1170
|
+
if (name) body.name = name;
|
|
1171
|
+
if (lane) body.lane = parseLane(lane);
|
|
1172
|
+
if (memory) body.memoryMb = parseMemory(memory);
|
|
1173
|
+
if (idle) body.idleTimeoutS = parsePositiveInt(idle, "--idle-timeout");
|
|
1174
|
+
if (Object.keys(body).length === 0) {
|
|
1175
|
+
throw new UsageError(
|
|
1176
|
+
"Nothing to update. Pass at least one of --name, --lane, --memory (or --size), --idle-timeout."
|
|
1177
|
+
);
|
|
1178
|
+
}
|
|
1179
|
+
const res = await client.patch(path2(id), body);
|
|
1180
|
+
if (json) return printJson(io, res);
|
|
1181
|
+
printTable(io, [summaryRow(res.data)], COLUMNS);
|
|
1182
|
+
if (res.data.pendingChanges) {
|
|
1183
|
+
io.stdout(
|
|
1184
|
+
"The engine applies the change in the background. Check it with: headband instances show " + id
|
|
1185
|
+
);
|
|
1186
|
+
}
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
if (action === "wakes") {
|
|
1190
|
+
const id = requireId(args);
|
|
1191
|
+
const limit = str2("--limit");
|
|
1192
|
+
const query = limit ? `?limit=${parsePositiveInt(limit, "--limit")}` : "";
|
|
1193
|
+
const res = await client.get(path2(id, `/wakes${query}`));
|
|
1194
|
+
if (json) return printJson(io, res);
|
|
1195
|
+
if (res.data.length === 0) {
|
|
1196
|
+
io.stdout(`No wakes recorded for engine ${id}.`);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
printTable(
|
|
1200
|
+
io,
|
|
1201
|
+
res.data.map((w) => ({
|
|
1202
|
+
requestedAt: w.requestedAt,
|
|
1203
|
+
via: w.via,
|
|
1204
|
+
outcome: w.outcome,
|
|
1205
|
+
ready: w.readyMs === null ? "" : `${w.readyMs} ms`
|
|
1206
|
+
})),
|
|
1207
|
+
["requestedAt", "via", "outcome", "ready"]
|
|
1208
|
+
);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
878
1211
|
if (action === "delete") {
|
|
879
1212
|
const id = requireId(args);
|
|
880
1213
|
if (!await confirm(`Delete engine ${id}? This removes its data and backups.`)) {
|
|
881
1214
|
io.stdout("Cancelled.");
|
|
882
1215
|
return;
|
|
883
1216
|
}
|
|
884
|
-
const res = await client.delete(
|
|
1217
|
+
const res = await client.delete(path2(id));
|
|
885
1218
|
if (json) return printJson(io, res);
|
|
886
1219
|
io.stdout(
|
|
887
1220
|
res.data.deleted ? `Deleted engine ${id}.` : `Engine ${id} is being deleted. It finishes in the background.`
|
|
@@ -890,10 +1223,20 @@ async function runInstances(args, flags, io, client, confirm) {
|
|
|
890
1223
|
}
|
|
891
1224
|
if (action === "connect") {
|
|
892
1225
|
const id = requireId(args);
|
|
893
|
-
const which =
|
|
894
|
-
if (which && !CLIENTS.includes(which)) throw new
|
|
895
|
-
const res = await client.get(
|
|
1226
|
+
const which = str2("--client");
|
|
1227
|
+
if (which && !CLIENTS.includes(which)) throw new UsageError("--client must be node or curl");
|
|
1228
|
+
const res = await client.get(path2(id));
|
|
896
1229
|
const s = connectSnippets(res.data.engine, res.data.endpoint);
|
|
1230
|
+
if (json) {
|
|
1231
|
+
return printJson(io, {
|
|
1232
|
+
id: res.data.id,
|
|
1233
|
+
engine: res.data.engine,
|
|
1234
|
+
endpoint: res.data.endpoint,
|
|
1235
|
+
install: s.install,
|
|
1236
|
+
...!which || which === "node" ? { node: s.node } : {},
|
|
1237
|
+
...!which || which === "curl" ? { curl: s.curl } : {}
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
897
1240
|
io.stdout(`Endpoint: ${res.data.endpoint}`);
|
|
898
1241
|
io.stdout("");
|
|
899
1242
|
io.stdout(
|
|
@@ -912,7 +1255,7 @@ async function runInstances(args, flags, io, client, confirm) {
|
|
|
912
1255
|
return;
|
|
913
1256
|
}
|
|
914
1257
|
io.stdout(INSTANCES_USAGE);
|
|
915
|
-
throw new
|
|
1258
|
+
throw new UsageError("Unknown instances command");
|
|
916
1259
|
} catch (err) {
|
|
917
1260
|
friendly(err);
|
|
918
1261
|
}
|
|
@@ -979,15 +1322,15 @@ function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new D
|
|
|
979
1322
|
const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
|
|
980
1323
|
const safeSlug = sanitizeSlug(meta.slug);
|
|
981
1324
|
const fileName = `${String(nextVersion).padStart(4, "0")}_${safeSlug}.json`;
|
|
982
|
-
const
|
|
1325
|
+
const path2 = (0, import_path6.join)(dir, fileName);
|
|
983
1326
|
const file = {
|
|
984
1327
|
version: nextVersion,
|
|
985
1328
|
createdAt: now().toISOString(),
|
|
986
1329
|
parentHashes: meta.parentHashes,
|
|
987
1330
|
ops
|
|
988
1331
|
};
|
|
989
|
-
(0, import_fs5.writeFileSync)(
|
|
990
|
-
return
|
|
1332
|
+
(0, import_fs5.writeFileSync)(path2, JSON.stringify(file, null, 2));
|
|
1333
|
+
return path2;
|
|
991
1334
|
}
|
|
992
1335
|
function readMigrations(dir) {
|
|
993
1336
|
let entries;
|
|
@@ -1000,15 +1343,15 @@ function readMigrations(dir) {
|
|
|
1000
1343
|
for (const entry of entries) {
|
|
1001
1344
|
const match = FILE_NAME_RE.exec(entry);
|
|
1002
1345
|
if (!match) continue;
|
|
1003
|
-
const
|
|
1004
|
-
const raw = (0, import_fs5.readFileSync)(
|
|
1346
|
+
const path2 = (0, import_path6.join)(dir, entry);
|
|
1347
|
+
const raw = (0, import_fs5.readFileSync)(path2, "utf-8");
|
|
1005
1348
|
let parsed;
|
|
1006
1349
|
try {
|
|
1007
1350
|
parsed = JSON.parse(raw);
|
|
1008
1351
|
} catch {
|
|
1009
|
-
throw new Error(`Malformed migration file: ${
|
|
1352
|
+
throw new Error(`Malformed migration file: ${path2}`);
|
|
1010
1353
|
}
|
|
1011
|
-
files.push({ ...parsed, path });
|
|
1354
|
+
files.push({ ...parsed, path: path2 });
|
|
1012
1355
|
}
|
|
1013
1356
|
files.sort((a, b) => a.version - b.version);
|
|
1014
1357
|
for (let i = 0; i < files.length; i++) {
|
|
@@ -1102,11 +1445,11 @@ var HttpError = class extends Error {
|
|
|
1102
1445
|
this.name = "HttpError";
|
|
1103
1446
|
}
|
|
1104
1447
|
};
|
|
1105
|
-
function
|
|
1448
|
+
function isRecord2(v) {
|
|
1106
1449
|
return typeof v === "object" && v !== null;
|
|
1107
1450
|
}
|
|
1108
1451
|
function parsePartialFailureItem(raw) {
|
|
1109
|
-
const item =
|
|
1452
|
+
const item = isRecord2(raw) ? raw : {};
|
|
1110
1453
|
const status = typeof item.status === "number" ? item.status : void 0;
|
|
1111
1454
|
return {
|
|
1112
1455
|
id: typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : null,
|
|
@@ -1147,7 +1490,7 @@ var HeadbandClient = class {
|
|
|
1147
1490
|
{ ids },
|
|
1148
1491
|
{ allowPartialFailure: true }
|
|
1149
1492
|
);
|
|
1150
|
-
return
|
|
1493
|
+
return isRecord2(result) && "partialFailure" in result ? { partialFailure: result.partialFailure } : {};
|
|
1151
1494
|
}
|
|
1152
1495
|
async createIndex(uid, opts) {
|
|
1153
1496
|
const body = { uid };
|
|
@@ -1169,11 +1512,11 @@ var HeadbandClient = class {
|
|
|
1169
1512
|
async reportRun(run2) {
|
|
1170
1513
|
await this.request("POST", "/v1/connectors/runs", run2);
|
|
1171
1514
|
}
|
|
1172
|
-
async request(method,
|
|
1515
|
+
async request(method, path2, body, opts = {}) {
|
|
1173
1516
|
let attempt = 0;
|
|
1174
1517
|
for (; ; ) {
|
|
1175
1518
|
attempt += 1;
|
|
1176
|
-
const response = await this.fetchImpl(`${this.baseUrl}${
|
|
1519
|
+
const response = await this.fetchImpl(`${this.baseUrl}${path2}`, {
|
|
1177
1520
|
method,
|
|
1178
1521
|
headers: {
|
|
1179
1522
|
Authorization: `Bearer ${this.adminKey}`,
|
|
@@ -1230,10 +1573,10 @@ function printOpsTable(io, ops) {
|
|
|
1230
1573
|
printTable(io, opRows(ops), ["op", "index", "detail"]);
|
|
1231
1574
|
}
|
|
1232
1575
|
function readAppliedMarker(dir) {
|
|
1233
|
-
const
|
|
1234
|
-
if (!(0, import_fs6.existsSync)(
|
|
1576
|
+
const path2 = (0, import_path7.join)(dir, APPLIED_MARKER_FILE);
|
|
1577
|
+
if (!(0, import_fs6.existsSync)(path2)) return 0;
|
|
1235
1578
|
try {
|
|
1236
|
-
const parsed = JSON.parse((0, import_fs6.readFileSync)(
|
|
1579
|
+
const parsed = JSON.parse((0, import_fs6.readFileSync)(path2, "utf-8"));
|
|
1237
1580
|
return typeof parsed.version === "number" ? parsed.version : 0;
|
|
1238
1581
|
} catch {
|
|
1239
1582
|
return 0;
|
|
@@ -1243,11 +1586,9 @@ function writeAppliedMarker(dir, version) {
|
|
|
1243
1586
|
(0, import_fs6.mkdirSync)(dir, { recursive: true });
|
|
1244
1587
|
(0, import_fs6.writeFileSync)((0, import_path7.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
|
|
1245
1588
|
}
|
|
1246
|
-
async function
|
|
1589
|
+
async function planSchemaDiff(deps) {
|
|
1247
1590
|
const applied = await deps.client.getSchemaState();
|
|
1248
|
-
|
|
1249
|
-
printOpsTable(deps.io, ops);
|
|
1250
|
-
return ops.length > 0 ? 1 : 0;
|
|
1591
|
+
return planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
|
|
1251
1592
|
}
|
|
1252
1593
|
async function runSchemaGenerate(deps) {
|
|
1253
1594
|
const applied = await deps.client.getSchemaState();
|
|
@@ -1261,20 +1602,19 @@ async function runSchemaGenerate(deps) {
|
|
|
1261
1602
|
for (const op of ops) {
|
|
1262
1603
|
parentHashes[op.index] = appliedByUid.get(op.index)?.schemaHash ?? "";
|
|
1263
1604
|
}
|
|
1264
|
-
const
|
|
1605
|
+
const path2 = generateMigrationFile(
|
|
1265
1606
|
deps.dir,
|
|
1266
1607
|
ops,
|
|
1267
1608
|
{ parentHashes, slug: deps.slug ?? "migration" },
|
|
1268
1609
|
deps.now
|
|
1269
1610
|
);
|
|
1270
|
-
deps.io.stdout(`Wrote ${
|
|
1611
|
+
deps.io.stdout(`Wrote ${path2}`);
|
|
1271
1612
|
printOpsTable(deps.io, ops);
|
|
1272
|
-
return
|
|
1613
|
+
return path2;
|
|
1273
1614
|
}
|
|
1274
1615
|
function isConflictError(err) {
|
|
1275
1616
|
if (!(err instanceof Error)) return false;
|
|
1276
|
-
|
|
1277
|
-
return shaped.status === 409 || shaped.code === "index_already_exists";
|
|
1617
|
+
return err.code === "index_already_exists";
|
|
1278
1618
|
}
|
|
1279
1619
|
async function createIndexIdempotent(ctx, uid, opts) {
|
|
1280
1620
|
try {
|
|
@@ -1482,6 +1822,7 @@ async function runSchemaStatus(deps) {
|
|
|
1482
1822
|
const localVersion = readAppliedMarker(deps.dir);
|
|
1483
1823
|
const localHead = files.length > 0 ? files[files.length - 1].version : 0;
|
|
1484
1824
|
deps.io.stdout(`Local migrations applied: ${localVersion}/${localHead}`);
|
|
1825
|
+
return { indexes: rows, localApplied: localVersion, localHead };
|
|
1485
1826
|
}
|
|
1486
1827
|
|
|
1487
1828
|
// src/sync.ts
|
|
@@ -1900,11 +2241,11 @@ function runSyncInit(deps) {
|
|
|
1900
2241
|
const ddl = outboxDDL(deps.table);
|
|
1901
2242
|
deps.io.stdout(ddl);
|
|
1902
2243
|
if (deps.write) {
|
|
1903
|
-
const
|
|
2244
|
+
const path2 = (0, import_path8.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
|
|
1904
2245
|
(0, import_fs7.mkdirSync)((0, import_path8.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
|
|
1905
|
-
(0, import_fs7.writeFileSync)(
|
|
2246
|
+
(0, import_fs7.writeFileSync)(path2, `${ddl}
|
|
1906
2247
|
`);
|
|
1907
|
-
deps.io.stdout(`Wrote ${
|
|
2248
|
+
deps.io.stdout(`Wrote ${path2}`);
|
|
1908
2249
|
}
|
|
1909
2250
|
return ddl;
|
|
1910
2251
|
}
|
|
@@ -2027,136 +2368,1265 @@ async function runSyncRetryDeadLetters(deps) {
|
|
|
2027
2368
|
return rows.length;
|
|
2028
2369
|
}
|
|
2029
2370
|
function runSyncScaffoldDocker(deps) {
|
|
2030
|
-
const
|
|
2031
|
-
if ((0, import_fs7.existsSync)(
|
|
2032
|
-
throw new CliError(`${DOCKERFILE_NAME} already exists at ${
|
|
2371
|
+
const path2 = (0, import_path8.join)(deps.cwd, DOCKERFILE_NAME);
|
|
2372
|
+
if ((0, import_fs7.existsSync)(path2) && !deps.force) {
|
|
2373
|
+
throw new CliError(`${DOCKERFILE_NAME} already exists at ${path2}. Pass --force to overwrite.`);
|
|
2033
2374
|
}
|
|
2034
|
-
(0, import_fs7.writeFileSync)(
|
|
2035
|
-
deps.io.stdout(`Wrote ${
|
|
2375
|
+
(0, import_fs7.writeFileSync)(path2, DOCKERFILE_CONTENT);
|
|
2376
|
+
deps.io.stdout(`Wrote ${path2}`);
|
|
2036
2377
|
deps.io.stdout("Add this service to your docker-compose.yml:");
|
|
2037
2378
|
deps.io.stdout(COMPOSE_SNIPPET);
|
|
2038
|
-
return
|
|
2379
|
+
return path2;
|
|
2039
2380
|
}
|
|
2040
2381
|
|
|
2041
|
-
// src/
|
|
2042
|
-
var
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
"
|
|
2060
|
-
"
|
|
2061
|
-
"
|
|
2062
|
-
"
|
|
2063
|
-
"
|
|
2064
|
-
"--region",
|
|
2065
|
-
"--idle-timeout",
|
|
2066
|
-
"--client",
|
|
2067
|
-
"--instance"
|
|
2068
|
-
]);
|
|
2069
|
-
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
2070
|
-
"--json",
|
|
2071
|
-
"--yes",
|
|
2072
|
-
"-y",
|
|
2073
|
-
"--help",
|
|
2074
|
-
"-h",
|
|
2075
|
-
"--allow-destructive",
|
|
2076
|
-
"--write",
|
|
2077
|
-
"--force",
|
|
2078
|
-
"--fix",
|
|
2079
|
-
"--loop"
|
|
2080
|
-
]);
|
|
2081
|
-
var DEFAULT_MIGRATIONS_DIR = "headband/migrations";
|
|
2082
|
-
function parseArgv(argv) {
|
|
2083
|
-
const flags = {};
|
|
2084
|
-
const positional = [];
|
|
2085
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
2086
|
-
const arg = argv[index];
|
|
2087
|
-
if (!arg.startsWith("-")) {
|
|
2088
|
-
positional.push(arg);
|
|
2089
|
-
continue;
|
|
2090
|
-
}
|
|
2091
|
-
const equalsIndex = arg.indexOf("=");
|
|
2092
|
-
const flagName = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
|
2093
|
-
const inlineValue = equalsIndex === -1 ? void 0 : arg.slice(equalsIndex + 1);
|
|
2094
|
-
if (VALUE_FLAGS.has(flagName)) {
|
|
2095
|
-
const value = inlineValue ?? argv[index + 1];
|
|
2096
|
-
if (!value || value.startsWith("-")) {
|
|
2097
|
-
throw new CliError(`Missing value for ${flagName}`);
|
|
2098
|
-
}
|
|
2099
|
-
flags[flagName] = value;
|
|
2100
|
-
if (inlineValue === void 0) index += 1;
|
|
2101
|
-
continue;
|
|
2102
|
-
}
|
|
2103
|
-
if (BOOLEAN_FLAGS.has(flagName)) {
|
|
2104
|
-
flags[flagName === "-y" ? "--yes" : flagName === "-h" ? "--help" : flagName] = true;
|
|
2105
|
-
continue;
|
|
2106
|
-
}
|
|
2107
|
-
throw new CliError(`Unknown option: ${arg}`);
|
|
2108
|
-
}
|
|
2109
|
-
return { flags, positional };
|
|
2382
|
+
// src/sync-indexes.ts
|
|
2383
|
+
var import_fs9 = require("fs");
|
|
2384
|
+
|
|
2385
|
+
// src/sync-sources.ts
|
|
2386
|
+
var import_fs8 = require("fs");
|
|
2387
|
+
var import_pg2 = require("pg");
|
|
2388
|
+
|
|
2389
|
+
// ../../src/lib/sync/copy.ts
|
|
2390
|
+
var BROAD_ROLE_CONSENT = "BataDB sources currently use a broad role: it can read and write every table in this database. Sync only reads, inside read-only transactions, but the role itself is not limited. A narrower replication role replaces it when BataDB ships one.";
|
|
2391
|
+
|
|
2392
|
+
// src/scram.ts
|
|
2393
|
+
var import_node_crypto2 = require("crypto");
|
|
2394
|
+
var SCRAM_ITERATIONS = 4096;
|
|
2395
|
+
var TOKEN = ":'sync_password'";
|
|
2396
|
+
var VERIFIER_RE = /^SCRAM-SHA-256\$[1-9][0-9]*:[A-Za-z0-9+/]+={0,2}\$[A-Za-z0-9+/]+={0,2}:[A-Za-z0-9+/]+={0,2}$/;
|
|
2397
|
+
function scramVerifier(password, salt = (0, import_node_crypto2.randomBytes)(16), iterations = SCRAM_ITERATIONS) {
|
|
2398
|
+
if (!/^[\x20-\x7e]{1,1024}$/.test(password))
|
|
2399
|
+
throw new Error("The password must be printable ASCII.");
|
|
2400
|
+
const salted = (0, import_node_crypto2.pbkdf2Sync)(password, salt, iterations, 32, "sha256");
|
|
2401
|
+
const clientKey = (0, import_node_crypto2.createHmac)("sha256", salted).update("Client Key").digest();
|
|
2402
|
+
const storedKey = (0, import_node_crypto2.createHash)("sha256").update(clientKey).digest();
|
|
2403
|
+
const serverKey = (0, import_node_crypto2.createHmac)("sha256", salted).update("Server Key").digest();
|
|
2404
|
+
return `SCRAM-SHA-256$${iterations}:${salt.toString("base64")}$${storedKey.toString("base64")}:${serverKey.toString("base64")}`;
|
|
2110
2405
|
}
|
|
2111
|
-
function
|
|
2112
|
-
|
|
2113
|
-
return typeof value === "string" ? value : void 0;
|
|
2406
|
+
function newSyncPassword() {
|
|
2407
|
+
return (0, import_node_crypto2.randomBytes)(24).toString("base64url");
|
|
2114
2408
|
}
|
|
2115
|
-
function
|
|
2116
|
-
|
|
2409
|
+
function replaceToken(sql, value) {
|
|
2410
|
+
const parts = sql.split(TOKEN);
|
|
2411
|
+
if (parts.length !== 2)
|
|
2412
|
+
throw new Error("The setup SQL must contain the password token exactly once.");
|
|
2413
|
+
return `${parts[0]}'${value}'${parts[1]}`;
|
|
2117
2414
|
}
|
|
2118
|
-
function
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
return
|
|
2415
|
+
function withVerifier(sql, verifier) {
|
|
2416
|
+
if (!VERIFIER_RE.test(verifier))
|
|
2417
|
+
throw new Error("withVerifier takes a SCRAM-SHA-256 verifier, never a plain password.");
|
|
2418
|
+
return replaceToken(sql, verifier);
|
|
2122
2419
|
}
|
|
2123
|
-
function
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2420
|
+
function withoutPasswordChange(sql) {
|
|
2421
|
+
return replaceToken(sql, "");
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
// src/sync-sources.ts
|
|
2425
|
+
var SOURCE_USAGE = [
|
|
2426
|
+
"Usage:",
|
|
2427
|
+
" headband sync source add <name> [--db-url <url>] [--tls-mode verify-full|require] [--ca-file <pem>] [--json]",
|
|
2428
|
+
" headband sync source add <name> --kind batadb --batadb-project <id> --batadb-branch <id> --batadb-compute <id> --accept-broad-role",
|
|
2429
|
+
" headband sync source list [--json]",
|
|
2430
|
+
" headband sync source show <id> [--json]",
|
|
2431
|
+
" headband sync source test <id> [--json]",
|
|
2432
|
+
" headband sync source setup-sql <id> [--provider generic|rds|batadb] [--json]",
|
|
2433
|
+
" headband sync source setup <id> [--owner-url <url>] [--provider generic|rds|batadb] [--yes] [--json]",
|
|
2434
|
+
" headband sync source set-ca <id> (--ca-file <pem> | --none) [--tls-mode verify-full|require] [--json]",
|
|
2435
|
+
" headband sync source remove <id> [--yes] [--json]",
|
|
2436
|
+
"",
|
|
2437
|
+
"The source URL can come from HEADBAND_SYNC_DB_URL and the owner URL from HEADBAND_SYNC_OWNER_URL,",
|
|
2438
|
+
"which keeps them out of your shell history. Leave the password out of the source URL: setup makes one.",
|
|
2439
|
+
"A BataDB API key is read from HEADBAND_BATADB_API_KEY only.",
|
|
2440
|
+
"",
|
|
2441
|
+
"--ca-file names a PEM file of CA certificates the source's certificate is verified against, for a",
|
|
2442
|
+
"provider whose CA is not in the system roots. It needs --tls-mode verify-full, the default. On a",
|
|
2443
|
+
"require source, set-ca --ca-file <pem> --tls-mode verify-full changes both in one step.",
|
|
2444
|
+
"",
|
|
2445
|
+
"setup runs the SQL on your machine as the tables' owner, in one transaction. The owner URL never",
|
|
2446
|
+
"leaves this machine. When the SQL creates or changes the Sync role, the owner URL's user must be",
|
|
2447
|
+
"another role. Without sslmode in the owner URL, setup uses TLS and verifies the server's",
|
|
2448
|
+
"certificate, except on localhost."
|
|
2449
|
+
].join("\n");
|
|
2450
|
+
function readCaFile(deps, file) {
|
|
2451
|
+
let text;
|
|
2452
|
+
try {
|
|
2453
|
+
text = (deps.readFile ?? ((p) => (0, import_fs8.readFileSync)(p, "utf8")))(file);
|
|
2454
|
+
} catch {
|
|
2455
|
+
throw new CliError("Cannot read the --ca-file.");
|
|
2129
2456
|
}
|
|
2130
|
-
|
|
2457
|
+
if (/PRIVATE KEY/.test(text)) {
|
|
2458
|
+
throw new CliError(
|
|
2459
|
+
"The --ca-file holds a private key. Give only the CA certificates. Nothing was sent."
|
|
2460
|
+
);
|
|
2461
|
+
}
|
|
2462
|
+
return text;
|
|
2131
2463
|
}
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2464
|
+
var MIN_SERVER_VERSION_NUM = 14e4;
|
|
2465
|
+
var OwnerSqlError = class extends CliError {
|
|
2466
|
+
constructor(message, opts) {
|
|
2467
|
+
super(message);
|
|
2468
|
+
this.name = "OwnerSqlError";
|
|
2469
|
+
this.sqlstate = opts.sqlstate;
|
|
2470
|
+
this.hint = opts.hint;
|
|
2471
|
+
this.stage = opts.stage;
|
|
2472
|
+
this.nothingChanged = opts.nothingChanged;
|
|
2473
|
+
}
|
|
2474
|
+
};
|
|
2475
|
+
var SESSION_PARAMS = [
|
|
2476
|
+
"options",
|
|
2477
|
+
"statement_timeout",
|
|
2478
|
+
"lock_timeout",
|
|
2479
|
+
"idle_in_transaction_session_timeout",
|
|
2480
|
+
"replication"
|
|
2481
|
+
];
|
|
2482
|
+
var TLS_PARAMS = ["ssl", "sslmode", "sslcert", "sslkey", "sslrootcert", "sslnegotiation"];
|
|
2483
|
+
var POOLER_HOST_RE = /pooler|pgbouncer|\.proxy-/;
|
|
2484
|
+
var POOLER_PORTS = /* @__PURE__ */ new Set([6543, 6432, 25061]);
|
|
2485
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "::1"]);
|
|
2486
|
+
var URL_IN_TEXT_RE = /postgres(?:ql)?:\/\/\S*/gi;
|
|
2487
|
+
var VERIFIER_IN_TEXT_RE = /SCRAM-SHA-256\$\S*/g;
|
|
2488
|
+
function decodePart(part) {
|
|
2489
|
+
try {
|
|
2490
|
+
return decodeURIComponent(part);
|
|
2491
|
+
} catch {
|
|
2492
|
+
return part;
|
|
2135
2493
|
}
|
|
2136
|
-
return engine;
|
|
2137
2494
|
}
|
|
2138
|
-
function
|
|
2139
|
-
|
|
2140
|
-
|
|
2495
|
+
function parseOwnerUrl(ownerUrl, env) {
|
|
2496
|
+
let url;
|
|
2497
|
+
try {
|
|
2498
|
+
url = new URL(ownerUrl);
|
|
2499
|
+
} catch {
|
|
2500
|
+
throw new CliError("The owner URL is not a valid postgres:// URL.");
|
|
2141
2501
|
}
|
|
2142
|
-
|
|
2502
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
|
2503
|
+
throw new CliError("The owner URL must start with postgres:// or postgresql://.");
|
|
2504
|
+
}
|
|
2505
|
+
for (const name of SESSION_PARAMS) {
|
|
2506
|
+
if (url.searchParams.has(name)) {
|
|
2507
|
+
throw new CliError(
|
|
2508
|
+
`Remove ${name} from the owner URL: setup sets nothing on the session, and poolers refuse it.`
|
|
2509
|
+
);
|
|
2510
|
+
}
|
|
2511
|
+
}
|
|
2512
|
+
const host = (url.searchParams.get("host") || decodePart(url.hostname)).replace(/^\[|\]$/g, "").toLowerCase() || "localhost";
|
|
2513
|
+
const port = Number(url.searchParams.get("port") || url.port || 5432);
|
|
2514
|
+
const database = decodePart(url.pathname.replace(/^\//, "")) || null;
|
|
2515
|
+
const user = url.searchParams.get("user") || decodePart(url.username) || env.PGUSER || env.USER || env.USERNAME || null;
|
|
2516
|
+
return { url, host, port, database, user };
|
|
2143
2517
|
}
|
|
2144
|
-
function
|
|
2145
|
-
|
|
2518
|
+
function ownerClientConfig(ownerUrl, env = process.env) {
|
|
2519
|
+
const target = parseOwnerUrl(ownerUrl, env);
|
|
2520
|
+
for (const name of ["PGOPTIONS", "PGREPLICATION"]) {
|
|
2521
|
+
if (env[name]) {
|
|
2522
|
+
throw new CliError(
|
|
2523
|
+
`${name} is set in your environment. Unset it: setup sets nothing on the session.`
|
|
2524
|
+
);
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
const urlChoosesTls = TLS_PARAMS.some((p) => target.url.searchParams.has(p)) || Boolean(env.PGSSLMODE);
|
|
2528
|
+
const local = target.host.startsWith("/") || LOOPBACK.has(target.host);
|
|
2529
|
+
return {
|
|
2530
|
+
connectionString: ownerUrl,
|
|
2531
|
+
connectionTimeoutMillis: 15e3,
|
|
2532
|
+
keepAlive: true,
|
|
2533
|
+
...urlChoosesTls || local ? {} : { ssl: true }
|
|
2534
|
+
};
|
|
2146
2535
|
}
|
|
2147
|
-
function
|
|
2148
|
-
|
|
2149
|
-
return createApiClient(url, profile);
|
|
2536
|
+
function looksLikePooler(host, port) {
|
|
2537
|
+
return POOLER_HOST_RE.test(host) || POOLER_PORTS.has(port);
|
|
2150
2538
|
}
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
const
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2539
|
+
function scrubSecrets(text, secrets) {
|
|
2540
|
+
let out = text;
|
|
2541
|
+
const known = secrets.filter((s) => Boolean(s)).sort((a, b) => b.length - a.length);
|
|
2542
|
+
for (const s of known) out = out.split(s).join("[hidden]");
|
|
2543
|
+
return out.replace(URL_IN_TEXT_RE, "[connection URL]").replace(VERIFIER_IN_TEXT_RE, "[verifier]");
|
|
2544
|
+
}
|
|
2545
|
+
function ownerSecrets(ownerUrl, env) {
|
|
2546
|
+
const out = [ownerUrl, env.PGPASSWORD ?? ""];
|
|
2547
|
+
try {
|
|
2548
|
+
const u = new URL(ownerUrl);
|
|
2549
|
+
out.push(u.password, decodePart(u.password), u.searchParams.get("password") ?? "");
|
|
2550
|
+
} catch {
|
|
2551
|
+
}
|
|
2552
|
+
return out;
|
|
2553
|
+
}
|
|
2554
|
+
function checkServerVersion(versionNum) {
|
|
2555
|
+
if (!Number.isInteger(versionNum) || versionNum < MIN_SERVER_VERSION_NUM) {
|
|
2556
|
+
const major = Number.isInteger(versionNum) ? Math.floor(versionNum / 1e4) : "unknown";
|
|
2557
|
+
throw new OwnerSqlError(
|
|
2558
|
+
`This server runs Postgres ${major}. Sync needs Postgres 14 or later.`,
|
|
2559
|
+
{ stage: "connect", nothingChanged: true }
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
function connectHint(message) {
|
|
2564
|
+
if (/certificate|self[- ]signed|altnames/i.test(message)) {
|
|
2565
|
+
return "Setup verifies the server's certificate, as sslmode=verify-full does. Trust your provider's CA with NODE_EXTRA_CA_CERTS=<file>, or add sslrootcert=<file> to the owner URL.";
|
|
2566
|
+
}
|
|
2567
|
+
if (/does not support SSL/i.test(message)) {
|
|
2568
|
+
return "Add sslmode=disable to the owner URL only if this connection runs on a network you trust.";
|
|
2569
|
+
}
|
|
2570
|
+
return void 0;
|
|
2571
|
+
}
|
|
2572
|
+
function toOwnerSqlError(err, stage, secrets) {
|
|
2573
|
+
const e = typeof err === "object" && err !== null ? err : {};
|
|
2574
|
+
const fromServer = typeof e.code === "string" && /^[0-9A-Z]{5}$/.test(e.code) && typeof e.severity === "string";
|
|
2575
|
+
const message = scrubSecrets(
|
|
2576
|
+
// Node's AggregateError (both addresses of localhost refused) has an empty message.
|
|
2577
|
+
typeof e.message === "string" && e.message ? e.message : String(e.errors?.[0]?.message ?? (typeof e.code === "string" ? e.code : err)),
|
|
2578
|
+
secrets
|
|
2579
|
+
);
|
|
2580
|
+
const serverHint = typeof e.hint === "string" && e.hint ? scrubSecrets(e.hint, secrets) : void 0;
|
|
2581
|
+
return new OwnerSqlError(message, {
|
|
2582
|
+
sqlstate: fromServer ? e.code : void 0,
|
|
2583
|
+
hint: serverHint ?? (stage === "connect" ? connectHint(message) : void 0),
|
|
2584
|
+
stage,
|
|
2585
|
+
// An ERROR aborts the transaction, and the rollback follows. A FATAL or a dropped connection
|
|
2586
|
+
// mid-run is unknown: a FATAL (57P01 waiting for a synchronous standby) can follow a local commit.
|
|
2587
|
+
nothingChanged: stage === "connect" || fromServer && e.severity === "ERROR"
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
async function runOwnerSqlWithPg(ownerUrl, sqlText) {
|
|
2591
|
+
const config = ownerClientConfig(ownerUrl);
|
|
2592
|
+
const secrets = ownerSecrets(ownerUrl, process.env);
|
|
2593
|
+
let client;
|
|
2594
|
+
try {
|
|
2595
|
+
client = new import_pg2.Client(config);
|
|
2596
|
+
} catch (err) {
|
|
2597
|
+
throw toOwnerSqlError(err, "connect", secrets);
|
|
2598
|
+
}
|
|
2599
|
+
client.on("error", () => void 0);
|
|
2600
|
+
let connected = false;
|
|
2601
|
+
let stage = "connect";
|
|
2602
|
+
try {
|
|
2603
|
+
await client.connect();
|
|
2604
|
+
connected = true;
|
|
2605
|
+
await client.query("BEGIN");
|
|
2606
|
+
await client.query("SET LOCAL statement_timeout = '60s'");
|
|
2607
|
+
const version = await client.query(
|
|
2608
|
+
"SELECT current_setting('server_version_num')::int AS num"
|
|
2609
|
+
);
|
|
2610
|
+
checkServerVersion(Number(version.rows[0]?.num));
|
|
2611
|
+
stage = "run";
|
|
2612
|
+
await client.query(sqlText);
|
|
2613
|
+
await client.query("COMMIT");
|
|
2614
|
+
} catch (err) {
|
|
2615
|
+
if (connected) await client.query("ROLLBACK").catch(() => void 0);
|
|
2616
|
+
throw err instanceof OwnerSqlError ? err : toOwnerSqlError(err, stage, secrets);
|
|
2617
|
+
} finally {
|
|
2618
|
+
await client.end().catch(() => void 0);
|
|
2619
|
+
}
|
|
2620
|
+
}
|
|
2621
|
+
function friendlySyncError(err) {
|
|
2622
|
+
if (err instanceof ApiError) {
|
|
2623
|
+
if (err.status === 404 && err.code === "not_found") {
|
|
2624
|
+
throw new CliError("Sync is not enabled for this organization yet.");
|
|
2625
|
+
}
|
|
2626
|
+
if (err.status === 403 && err.code === "permission_denied") {
|
|
2627
|
+
throw new CliError(
|
|
2628
|
+
"This CLI key cannot manage Sync. Run headband login again to get a key that can."
|
|
2629
|
+
);
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
throw err;
|
|
2633
|
+
}
|
|
2634
|
+
var SOURCE_ID_RE = /^src_[a-z0-9]{12}$/;
|
|
2635
|
+
var SOURCE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
2636
|
+
var PROVIDERS = ["generic", "rds", "batadb"];
|
|
2637
|
+
var TLS_MODES = ["verify-full", "require"];
|
|
2638
|
+
var str = (flags, name) => typeof flags[name] === "string" ? flags[name] : void 0;
|
|
2639
|
+
var sourcePath = (id, suffix = "") => `/v1/sync/sources/${encodeURIComponent(id)}${suffix}`;
|
|
2640
|
+
var setupSqlPath = (id, provider) => sourcePath(id, `/setup-sql${provider ? `?provider=${encodeURIComponent(provider)}` : ""}`);
|
|
2641
|
+
var mark = (ok) => ok ? "ok " : "FAIL";
|
|
2642
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
2643
|
+
function choiceFlag(flags, name, choices) {
|
|
2644
|
+
const value = str(flags, name);
|
|
2645
|
+
if (value !== void 0 && !choices.includes(value)) {
|
|
2646
|
+
throw new CliError(
|
|
2647
|
+
`${name} must be ${choices.slice(0, -1).join(", ")} or ${choices[choices.length - 1]}.`
|
|
2648
|
+
);
|
|
2649
|
+
}
|
|
2650
|
+
return value;
|
|
2651
|
+
}
|
|
2652
|
+
function checkedId(value, re, shape, listCommand) {
|
|
2653
|
+
if (!re.test(value)) {
|
|
2654
|
+
throw new CliError(
|
|
2655
|
+
`Expected ${shape}, and got something else. Run ${listCommand} to see them.`
|
|
2656
|
+
);
|
|
2657
|
+
}
|
|
2658
|
+
return value;
|
|
2659
|
+
}
|
|
2660
|
+
var SOURCE_ID_SHAPE = "a source id like src_ followed by 12 lowercase letters or digits";
|
|
2661
|
+
var SOURCE_ID_PATTERN = SOURCE_ID_RE;
|
|
2662
|
+
function requireSourceId(args) {
|
|
2663
|
+
const id = args[1];
|
|
2664
|
+
if (!id) throw new CliError("Missing source id (headband sync source list shows them).");
|
|
2665
|
+
return checkedId(id, SOURCE_ID_RE, SOURCE_ID_SHAPE, "headband sync source list");
|
|
2666
|
+
}
|
|
2667
|
+
function printChecks(io, checks) {
|
|
2668
|
+
const width = Math.max(4, ...checks.map((c) => c.name.length));
|
|
2669
|
+
for (const c of checks) io.stdout(`${mark(c.ok)} ${c.name.padEnd(width)} ${c.detail}`);
|
|
2670
|
+
}
|
|
2671
|
+
function lastTested(s) {
|
|
2672
|
+
if (!s.lastTest) return "never";
|
|
2673
|
+
const failed = s.lastTest.checks.filter((c) => !c.ok).length;
|
|
2674
|
+
return failed === 0 ? `passed ${s.lastTest.at}` : `${failed} failed ${s.lastTest.at}`;
|
|
2675
|
+
}
|
|
2676
|
+
function roleKey(url) {
|
|
2677
|
+
try {
|
|
2678
|
+
const u = new URL(url);
|
|
2679
|
+
const port = u.port ? Number(u.port) : 5432;
|
|
2680
|
+
return {
|
|
2681
|
+
host: u.hostname.toLowerCase().replace(/^\[|\]$/g, ""),
|
|
2682
|
+
port,
|
|
2683
|
+
username: decodeURIComponent(u.username)
|
|
2684
|
+
};
|
|
2685
|
+
} catch {
|
|
2686
|
+
return null;
|
|
2687
|
+
}
|
|
2688
|
+
}
|
|
2689
|
+
async function runAdd(args, flags, deps, env) {
|
|
2690
|
+
const { io, client } = deps;
|
|
2691
|
+
const name = args[1];
|
|
2692
|
+
if (!name) throw new CliError("Missing source name.");
|
|
2693
|
+
if (!SOURCE_NAME_RE.test(name)) {
|
|
2694
|
+
throw new CliError(
|
|
2695
|
+
"A source name is 1 to 64 lowercase letters, digits, _ or -, starting with a letter or digit."
|
|
2696
|
+
);
|
|
2697
|
+
}
|
|
2698
|
+
const url = str(flags, "--db-url") ?? env.HEADBAND_SYNC_DB_URL;
|
|
2699
|
+
if (!url) {
|
|
2700
|
+
throw new CliError("Give the source's connection URL with --db-url or HEADBAND_SYNC_DB_URL.");
|
|
2701
|
+
}
|
|
2702
|
+
const kind = str(flags, "--kind") ?? "postgres";
|
|
2703
|
+
if (kind !== "postgres" && kind !== "batadb")
|
|
2704
|
+
throw new CliError("--kind must be postgres or batadb.");
|
|
2705
|
+
const ids = {
|
|
2706
|
+
projectId: str(flags, "--batadb-project"),
|
|
2707
|
+
branchId: str(flags, "--batadb-branch"),
|
|
2708
|
+
computeId: str(flags, "--batadb-compute")
|
|
2709
|
+
};
|
|
2710
|
+
const body = { name, url };
|
|
2711
|
+
const tlsMode = choiceFlag(flags, "--tls-mode", TLS_MODES);
|
|
2712
|
+
if (tlsMode) body.tlsMode = tlsMode;
|
|
2713
|
+
const caFile = str(flags, "--ca-file");
|
|
2714
|
+
if (caFile) body.tlsCaPem = readCaFile(deps, caFile);
|
|
2715
|
+
if (kind === "batadb") {
|
|
2716
|
+
if (flags["--accept-broad-role"] !== true) {
|
|
2717
|
+
io.stderr(BROAD_ROLE_CONSENT);
|
|
2718
|
+
throw new CliError(
|
|
2719
|
+
"A BataDB source needs your consent to that role: add --accept-broad-role."
|
|
2720
|
+
);
|
|
2721
|
+
}
|
|
2722
|
+
if (!ids.projectId || !ids.branchId || !ids.computeId) {
|
|
2723
|
+
throw new CliError(
|
|
2724
|
+
"A BataDB source needs --batadb-project, --batadb-branch and --batadb-compute."
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
body.kind = "batadb";
|
|
2728
|
+
body.batadb = {
|
|
2729
|
+
...ids,
|
|
2730
|
+
acceptBroadRole: true,
|
|
2731
|
+
...env.HEADBAND_BATADB_API_KEY ? { apiKey: env.HEADBAND_BATADB_API_KEY } : {}
|
|
2732
|
+
};
|
|
2733
|
+
} else {
|
|
2734
|
+
if (ids.projectId || ids.branchId || ids.computeId) {
|
|
2735
|
+
throw new CliError("The --batadb-* options are for --kind batadb.");
|
|
2736
|
+
}
|
|
2737
|
+
const key = roleKey(url);
|
|
2738
|
+
if (key) {
|
|
2739
|
+
const existing = (await client.get("/v1/sync/sources")).data;
|
|
2740
|
+
const other = existing.find(
|
|
2741
|
+
(s2) => s2.kind === "postgres" && s2.host === key.host && s2.port === key.port && s2.username === key.username
|
|
2742
|
+
);
|
|
2743
|
+
if (other) {
|
|
2744
|
+
throw new CliError(
|
|
2745
|
+
`Source ${other.id} (${other.name}) already uses the user ${key.username} on ${key.host}:${key.port}. Each source needs its own role, and setup never changes a role made for another source: give this source a user name no other source uses on that server.`
|
|
2746
|
+
);
|
|
2747
|
+
}
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
const res = await client.post("/v1/sync/sources", body);
|
|
2751
|
+
if (flags["--json"] === true) return printJson(io, res);
|
|
2752
|
+
const s = res.data;
|
|
2753
|
+
io.stdout(
|
|
2754
|
+
`Created source ${s.id} (${s.name}): ${s.host}:${s.port}/${s.database} as ${s.username}.`
|
|
2755
|
+
);
|
|
2756
|
+
if (s.kind === "postgres" && s.hasPassword) {
|
|
2757
|
+
io.stderr(
|
|
2758
|
+
`Note: the URL has a password, so setup keeps it and makes no new one. The setup SQL creates a missing role only with a password, so ${s.username} must already be Headband's role for this source. Otherwise run the setup SQL yourself with that password (headband sync source setup-sql ${s.id} shows the psql command), or add the source again without a password.`
|
|
2759
|
+
);
|
|
2760
|
+
}
|
|
2761
|
+
io.stdout("Next: create the role and publication as the tables' owner:");
|
|
2762
|
+
io.stdout(` headband sync source setup ${s.id} --owner-url <owner URL>`);
|
|
2763
|
+
io.stdout(`or print the SQL to run yourself: headband sync source setup-sql ${s.id}`);
|
|
2764
|
+
}
|
|
2765
|
+
function roleEffect(user, provider, mode) {
|
|
2766
|
+
const attrs = provider === "rds" ? "LOGIN and membership in rds_replication" : "LOGIN REPLICATION";
|
|
2767
|
+
if (mode === "none") return `creates and changes no role: BataDB manages ${user}.`;
|
|
2768
|
+
const guard = `Any other role named ${user} stops the SQL, and nothing changes.`;
|
|
2769
|
+
if (mode === "new") {
|
|
2770
|
+
return `creates the role ${user} (${attrs}) with a new password, or, when ${user} exists and Headband Sync created it for this source, gives it that new password and ${attrs}. ${guard}`;
|
|
2771
|
+
}
|
|
2772
|
+
return `creates no role and keeps the password Headband has: it gives ${user} ${attrs} only when Headband Sync created ${user} for this source. A missing role stops the SQL too. ${guard}`;
|
|
2773
|
+
}
|
|
2774
|
+
function confirmQuestion(owner, user, pub, mode) {
|
|
2775
|
+
const as = `Run it now as ${owner}?`;
|
|
2776
|
+
if (mode === "none")
|
|
2777
|
+
return `${as} It creates or updates the publication ${pub} and changes no role.`;
|
|
2778
|
+
if (mode === "new") {
|
|
2779
|
+
return `${as} It creates the role ${user}, or updates ${user} only if Headband Sync created it for this source.`;
|
|
2780
|
+
}
|
|
2781
|
+
return `${as} It creates no role, and updates ${user} only if Headband Sync created it for this source.`;
|
|
2782
|
+
}
|
|
2783
|
+
function ownerFailure(err, ctx, secrets) {
|
|
2784
|
+
const info = err instanceof OwnerSqlError ? err : void 0;
|
|
2785
|
+
const reason = scrubSecrets(err instanceof Error ? err.message : String(err), secrets);
|
|
2786
|
+
const lines = [
|
|
2787
|
+
info?.stage === "connect" ? `Could not run the setup SQL as the owner: ${reason}` : `The setup SQL failed on your database: ${reason}`
|
|
2788
|
+
];
|
|
2789
|
+
let advice;
|
|
2790
|
+
const state = info?.sqlstate;
|
|
2791
|
+
if (state === "42710" && reason.includes("was not created by Headband Sync")) {
|
|
2792
|
+
advice = `Sync changes only a role it created for this source, so ${ctx.user} stays as it is. Each source needs its own role: use a user name that does not exist on this server yet (add a new source with it), then run its setup.`;
|
|
2793
|
+
} else if (state === "42710" && reason.includes("carries the Headband Sync marker")) {
|
|
2794
|
+
advice = `${ctx.user} is Headband's role for this source, but another role is its member or admin, or it has SUPERUSER, CREATEROLE or BYPASSRLS. It needs none of these: remove them, then run setup again.`;
|
|
2795
|
+
} else if (state === "22023" && reason.includes("does not exist yet") && ctx.mode === "keep") {
|
|
2796
|
+
advice = `This source's URL has a password, so setup keeps it and makes no new one, and the SQL creates a missing role only with a password. Run the setup SQL yourself with psql and that password (headband sync source setup-sql ${ctx.id} shows the command), or add the source again without a password so setup makes one.`;
|
|
2797
|
+
} else if (state === "42501") {
|
|
2798
|
+
advice = "Run setup as the tables' owner, a role that can also create roles and publications.";
|
|
2799
|
+
} else if (state === "55P03") {
|
|
2800
|
+
advice = "Another transaction held a lock on one of these tables for over 5 seconds. Run setup again when it is quiet.";
|
|
2801
|
+
}
|
|
2802
|
+
if (advice) lines.push(advice);
|
|
2803
|
+
else if (info?.hint) lines.push(`Hint: ${scrubSecrets(info.hint, secrets)}`);
|
|
2804
|
+
if (info && !info.nothingChanged) {
|
|
2805
|
+
lines.push(
|
|
2806
|
+
"The connection failed while the SQL ran. Postgres rolls back a transaction that does not commit, and Headband was not updated. Run setup again: it is safe to repeat."
|
|
2807
|
+
);
|
|
2808
|
+
} else if (info?.stage === "connect") {
|
|
2809
|
+
lines.push("Nothing changed on your database or in Headband.");
|
|
2810
|
+
} else {
|
|
2811
|
+
lines.push("Nothing changed: the transaction rolled back, and Headband was not updated.");
|
|
2812
|
+
}
|
|
2813
|
+
return new CliError(lines.join("\n"));
|
|
2814
|
+
}
|
|
2815
|
+
async function runSetup(args, flags, deps, env) {
|
|
2816
|
+
const { client } = deps;
|
|
2817
|
+
const json = flags["--json"] === true;
|
|
2818
|
+
const io = json ? { stdout: deps.io.stderr, stderr: deps.io.stderr } : deps.io;
|
|
2819
|
+
const id = requireSourceId(args);
|
|
2820
|
+
const ownerUrl = str(flags, "--owner-url") ?? env.HEADBAND_SYNC_OWNER_URL;
|
|
2821
|
+
if (!ownerUrl) {
|
|
2822
|
+
throw new CliError(
|
|
2823
|
+
"Give the tables' owner URL with --owner-url or HEADBAND_SYNC_OWNER_URL. It never leaves this machine."
|
|
2824
|
+
);
|
|
2825
|
+
}
|
|
2826
|
+
ownerClientConfig(ownerUrl, env);
|
|
2827
|
+
const owner = parseOwnerUrl(ownerUrl, env);
|
|
2828
|
+
const provider = choiceFlag(flags, "--provider", PROVIDERS);
|
|
2829
|
+
const source = (await client.get(sourcePath(id))).data;
|
|
2830
|
+
if (source.state === "deleting") throw new CliError(`Source ${id} is being removed.`);
|
|
2831
|
+
const setup = (await client.get(setupSqlPath(id, provider))).data;
|
|
2832
|
+
if (setup.needsPassword && owner.user === source.username) {
|
|
2833
|
+
throw new CliError(
|
|
2834
|
+
`The owner URL logs in as ${source.username}, the user Sync reads with. The setup SQL creates or updates that role, so run it as the tables' owner, a different role.`
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
if (looksLikePooler(owner.host, owner.port)) {
|
|
2838
|
+
io.stderr(
|
|
2839
|
+
`Warning: the owner host (${owner.host}:${owner.port}) looks like a connection pooler. Setup runs one transaction and sets nothing on the session, but if the pooler refuses it, use the database's direct host.`
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
if (owner.host !== source.host) {
|
|
2843
|
+
io.stderr(
|
|
2844
|
+
`Note: the owner URL's host (${owner.host}) differs from the source's (${source.host}). Make sure both reach the same database.`
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
let password = null;
|
|
2848
|
+
let verifier = null;
|
|
2849
|
+
let mode;
|
|
2850
|
+
let sqlText;
|
|
2851
|
+
try {
|
|
2852
|
+
if (!setup.needsPassword) {
|
|
2853
|
+
mode = "none";
|
|
2854
|
+
sqlText = setup.sql;
|
|
2855
|
+
} else if (source.hasPassword) {
|
|
2856
|
+
mode = "keep";
|
|
2857
|
+
sqlText = withoutPasswordChange(setup.sql);
|
|
2858
|
+
} else {
|
|
2859
|
+
mode = "new";
|
|
2860
|
+
password = (deps.newPassword ?? newSyncPassword)();
|
|
2861
|
+
verifier = scramVerifier(password);
|
|
2862
|
+
sqlText = withVerifier(setup.sql, verifier);
|
|
2863
|
+
}
|
|
2864
|
+
} catch {
|
|
2865
|
+
throw new CliError(
|
|
2866
|
+
"Headband's setup SQL is not in the shape this CLI expects. Update @headband/cli and try again."
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
const ownerName = owner.user ?? "the owner URL's user";
|
|
2870
|
+
const where = `${owner.host}:${owner.port}${owner.database ? `/${owner.database}` : ""}`;
|
|
2871
|
+
io.stdout(`Setup runs Headband's SQL on ${where} as ${ownerName}, in one transaction. It:`);
|
|
2872
|
+
io.stdout(` - ${roleEffect(source.username, setup.provider, mode)}`);
|
|
2873
|
+
if (setup.needsPassword && setup.tables.length > 0) {
|
|
2874
|
+
io.stdout(
|
|
2875
|
+
` - grants ${source.username} SELECT on ${setup.tables.join(", ")}, and USAGE on their schemas.`
|
|
2876
|
+
);
|
|
2877
|
+
}
|
|
2878
|
+
io.stdout(
|
|
2879
|
+
setup.tables.length > 0 ? ` - creates the publication ${source.publicationName} when it is missing, and sets it to exactly these tables: ${setup.tables.join(", ")}.` : ` - creates the publication ${source.publicationName} when it is missing. No synced index reads a table yet, so it lists none.`
|
|
2880
|
+
);
|
|
2881
|
+
io.stdout(
|
|
2882
|
+
mode === "new" ? "Only the new password goes to Headband. The owner URL stays on this machine." : "Nothing goes to Headband but the test that follows. The owner URL stays on this machine."
|
|
2883
|
+
);
|
|
2884
|
+
for (const n of setup.notes) io.stderr(`Note: ${n}`);
|
|
2885
|
+
if (!await deps.confirm(confirmQuestion(ownerName, source.username, source.publicationName, mode))) {
|
|
2886
|
+
throw new CliError("Cancelled. Nothing changed.");
|
|
2887
|
+
}
|
|
2888
|
+
const secrets = [
|
|
2889
|
+
...ownerSecrets(ownerUrl, env),
|
|
2890
|
+
password,
|
|
2891
|
+
verifier,
|
|
2892
|
+
env.HEADBAND_SYNC_DB_URL,
|
|
2893
|
+
env.HEADBAND_BATADB_API_KEY
|
|
2894
|
+
];
|
|
2895
|
+
try {
|
|
2896
|
+
await (deps.runOwnerSql ?? runOwnerSqlWithPg)(ownerUrl, sqlText);
|
|
2897
|
+
} catch (err) {
|
|
2898
|
+
throw ownerFailure(err, { id, user: source.username, mode }, secrets);
|
|
2899
|
+
}
|
|
2900
|
+
io.stdout("Setup SQL applied.");
|
|
2901
|
+
if (password) {
|
|
2902
|
+
try {
|
|
2903
|
+
await client.patch(sourcePath(id), { password });
|
|
2904
|
+
} catch (err) {
|
|
2905
|
+
io.stderr(
|
|
2906
|
+
"The role is set up, but Headband did not take its password. Run this setup again: it is safe to repeat."
|
|
2907
|
+
);
|
|
2908
|
+
throw err;
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
let res;
|
|
2912
|
+
try {
|
|
2913
|
+
res = await client.post(sourcePath(id, "/test"), {});
|
|
2914
|
+
} catch (err) {
|
|
2915
|
+
io.stderr(
|
|
2916
|
+
`Setup is done, but the source test did not run. Run it with: headband sync source test ${id}`
|
|
2917
|
+
);
|
|
2918
|
+
throw err;
|
|
2919
|
+
}
|
|
2920
|
+
io.stdout(`Tested from ${res.data.boxId}:`);
|
|
2921
|
+
printChecks(io, res.data.checks);
|
|
2922
|
+
const failed = res.data.checks.filter((c) => !c.ok).length;
|
|
2923
|
+
if (failed > 0) {
|
|
2924
|
+
throw new CliError(
|
|
2925
|
+
`${plural(failed, "check")} failed. Fix them, then run headband sync source test ${id}.`
|
|
2926
|
+
);
|
|
2927
|
+
}
|
|
2928
|
+
io.stdout(
|
|
2929
|
+
res.data.activated ? "The source is ready and active. Its synced indexes start backfilling." : "The source is ready."
|
|
2930
|
+
);
|
|
2931
|
+
if (json) {
|
|
2932
|
+
printJson(deps.io, {
|
|
2933
|
+
data: {
|
|
2934
|
+
id,
|
|
2935
|
+
provider: setup.provider,
|
|
2936
|
+
password: mode,
|
|
2937
|
+
boxId: res.data.boxId,
|
|
2938
|
+
activated: res.data.activated,
|
|
2939
|
+
checks: res.data.checks
|
|
2940
|
+
}
|
|
2941
|
+
});
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
async function runSyncSource(args, flags, deps) {
|
|
2945
|
+
const { io, client } = deps;
|
|
2946
|
+
const env = deps.env ?? process.env;
|
|
2947
|
+
const json = flags["--json"] === true;
|
|
2948
|
+
const action = args[0];
|
|
2949
|
+
try {
|
|
2950
|
+
if (action === "add") return await runAdd(args, flags, deps, env);
|
|
2951
|
+
if (action === "setup") return await runSetup(args, flags, deps, env);
|
|
2952
|
+
if (action === "list") {
|
|
2953
|
+
const res = await client.get("/v1/sync/sources");
|
|
2954
|
+
if (json) return printJson(io, res);
|
|
2955
|
+
if (res.data.length === 0) {
|
|
2956
|
+
io.stdout("No sources yet. Add one with: headband sync source add <name> --db-url <url>");
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
printTable(
|
|
2960
|
+
io,
|
|
2961
|
+
res.data.map((s) => ({
|
|
2962
|
+
id: s.id,
|
|
2963
|
+
name: s.name,
|
|
2964
|
+
kind: s.kind,
|
|
2965
|
+
host: s.host,
|
|
2966
|
+
state: s.state,
|
|
2967
|
+
"last test": lastTested(s)
|
|
2968
|
+
})),
|
|
2969
|
+
["id", "name", "kind", "host", "state", "last test"]
|
|
2970
|
+
);
|
|
2971
|
+
return;
|
|
2972
|
+
}
|
|
2973
|
+
if (action === "show") {
|
|
2974
|
+
const res = await client.get(sourcePath(requireSourceId(args)));
|
|
2975
|
+
if (json) return printJson(io, res);
|
|
2976
|
+
const s = res.data;
|
|
2977
|
+
const lines = [
|
|
2978
|
+
["id", s.id],
|
|
2979
|
+
["name", s.name],
|
|
2980
|
+
["kind", s.kind],
|
|
2981
|
+
["host", `${s.host}:${s.port}`],
|
|
2982
|
+
["database", s.database],
|
|
2983
|
+
["user", `${s.username}${s.hasPassword ? "" : " (no password yet)"}`],
|
|
2984
|
+
["tls", s.tlsMode],
|
|
2985
|
+
["ca bundle", s.tlsCaPem ? "set" : "system roots"],
|
|
2986
|
+
["capture", s.captureMode],
|
|
2987
|
+
["publication", s.publicationName],
|
|
2988
|
+
["state", s.error ? `${s.state}: ${s.error}` : s.state],
|
|
2989
|
+
["postgres", s.serverVersion === null ? "unknown" : String(s.serverVersion)],
|
|
2990
|
+
["last test", lastTested(s)]
|
|
2991
|
+
];
|
|
2992
|
+
const width = Math.max(...lines.map(([k]) => k.length));
|
|
2993
|
+
for (const [k, v] of lines) io.stdout(`${k.padEnd(width)} ${v}`);
|
|
2994
|
+
if (s.lastTest) printChecks(io, s.lastTest.checks);
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
if (action === "test") {
|
|
2998
|
+
const id = requireSourceId(args);
|
|
2999
|
+
const res = await client.post(sourcePath(id, "/test"), {});
|
|
3000
|
+
const failed = res.data.checks.filter((c) => !c.ok).length;
|
|
3001
|
+
if (json) {
|
|
3002
|
+
printJson(io, res);
|
|
3003
|
+
if (failed > 0) throw new CliError(`${plural(failed, "check")} failed.`);
|
|
3004
|
+
return;
|
|
3005
|
+
}
|
|
3006
|
+
io.stdout(`Tested from ${res.data.boxId}:`);
|
|
3007
|
+
printChecks(io, res.data.checks);
|
|
3008
|
+
if (failed > 0) {
|
|
3009
|
+
io.stdout(
|
|
3010
|
+
`Fix the failed checks, then test again. The setup SQL: headband sync source setup-sql ${id}`
|
|
3011
|
+
);
|
|
3012
|
+
throw new CliError(`${plural(failed, "check")} failed.`);
|
|
3013
|
+
}
|
|
3014
|
+
io.stdout(
|
|
3015
|
+
res.data.activated ? "All checks passed. The source is active, and its synced indexes start backfilling." : "All checks passed."
|
|
3016
|
+
);
|
|
3017
|
+
return;
|
|
3018
|
+
}
|
|
3019
|
+
if (action === "setup-sql") {
|
|
3020
|
+
const id = requireSourceId(args);
|
|
3021
|
+
const res = await client.get(
|
|
3022
|
+
setupSqlPath(id, choiceFlag(flags, "--provider", PROVIDERS))
|
|
3023
|
+
);
|
|
3024
|
+
if (json) return printJson(io, res);
|
|
3025
|
+
io.stdout(res.data.sql.trimEnd());
|
|
3026
|
+
for (const n of res.data.notes) io.stderr(`Note: ${n}`);
|
|
3027
|
+
return;
|
|
3028
|
+
}
|
|
3029
|
+
if (action === "set-ca") {
|
|
3030
|
+
const id = requireSourceId(args);
|
|
3031
|
+
const caFile = str(flags, "--ca-file");
|
|
3032
|
+
const none = flags["--none"] === true;
|
|
3033
|
+
if (Boolean(caFile) === none)
|
|
3034
|
+
throw new CliError("Give exactly one of --ca-file <pem> or --none.");
|
|
3035
|
+
const tlsMode = choiceFlag(flags, "--tls-mode", TLS_MODES);
|
|
3036
|
+
if (caFile && tlsMode === "require") {
|
|
3037
|
+
throw new CliError(
|
|
3038
|
+
"A CA bundle verifies the source's certificate, so it needs --tls-mode verify-full."
|
|
3039
|
+
);
|
|
3040
|
+
}
|
|
3041
|
+
const body = {
|
|
3042
|
+
tlsCaPem: none ? null : readCaFile(deps, caFile)
|
|
3043
|
+
};
|
|
3044
|
+
if (tlsMode) body.tlsMode = tlsMode;
|
|
3045
|
+
const res = await client.patch(sourcePath(id), body);
|
|
3046
|
+
if (json) return printJson(io, res);
|
|
3047
|
+
const mode = tlsMode ? `, and set its TLS mode to ${tlsMode}` : "";
|
|
3048
|
+
io.stdout(
|
|
3049
|
+
!none ? `Set the CA bundle of ${id}${mode}. The box reconnects with it.` : tlsMode === "require" ? `Cleared the CA bundle of ${id}${mode}. The box reconnects encrypted, without verifying the certificate.` : `Cleared the CA bundle of ${id}${mode}. The box verifies against the system roots.`
|
|
3050
|
+
);
|
|
3051
|
+
return;
|
|
3052
|
+
}
|
|
3053
|
+
if (action === "remove") {
|
|
3054
|
+
const id = requireSourceId(args);
|
|
3055
|
+
if (!await deps.confirm(`Remove source ${id}? Sync drops its replication slots.`)) {
|
|
3056
|
+
throw new CliError("Cancelled. Nothing changed.");
|
|
3057
|
+
}
|
|
3058
|
+
const res = await client.delete(sourcePath(id));
|
|
3059
|
+
if (json) return printJson(io, res);
|
|
3060
|
+
io.stdout(
|
|
3061
|
+
res.data.deleted ? `Removed source ${id}.` : `Removing source ${id}: its pipelines are stopping.`
|
|
3062
|
+
);
|
|
3063
|
+
io.stdout("Run this as the tables' owner to finish:");
|
|
3064
|
+
io.stdout(res.dropPublicationSql.trimEnd());
|
|
3065
|
+
return;
|
|
3066
|
+
}
|
|
3067
|
+
io.stdout(SOURCE_USAGE);
|
|
3068
|
+
throw new CliError("Unknown sync source command");
|
|
3069
|
+
} catch (err) {
|
|
3070
|
+
friendlySyncError(err);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
// src/sync-indexes.ts
|
|
3075
|
+
var INDEX_USAGE = [
|
|
3076
|
+
"Usage:",
|
|
3077
|
+
" headband sync index create -f <file.sync.json> [--json]",
|
|
3078
|
+
" headband sync index list [--source <id>] [--instance <id>] [--json]",
|
|
3079
|
+
" headband sync index show <id> [--version <n>] [--json]",
|
|
3080
|
+
" headband sync index update <id> -f <file.sync.json> [--rebuild] [--json]",
|
|
3081
|
+
" headband sync index validate -f <file.sync.json> [--index <id>] [--json]",
|
|
3082
|
+
" headband sync index preview <id> [--ids <id,id,...>] [-f <file.sync.json>] [--json]",
|
|
3083
|
+
" headband sync index rebuild|pause|resume|check <id> [--yes] [--json]",
|
|
3084
|
+
" headband sync index remove <id> [--drop-index] [--yes] [--json]",
|
|
3085
|
+
" headband sync status [--json]"
|
|
3086
|
+
].join("\n");
|
|
3087
|
+
var SYNC_INDEX_ID_RE = /^sx_[a-z0-9]{12}$/;
|
|
3088
|
+
var INSTANCE_ID_RE = /^i[a-z0-9]{12}$/;
|
|
3089
|
+
var PREVIEW_MAX_IDS = 20;
|
|
3090
|
+
var ATTENTION_INDEX = /* @__PURE__ */ new Set(["error", "needs_rebuild", "awaiting_setup", "degraded"]);
|
|
3091
|
+
var ATTENTION_SOURCE = /* @__PURE__ */ new Set(["error", "awaiting_setup"]);
|
|
3092
|
+
var path = (id, suffix = "") => `/v1/sync/indexes/${encodeURIComponent(id)}${suffix}`;
|
|
3093
|
+
function readDefinition(file, readFile = (p) => (0, import_fs9.readFileSync)(p, "utf8")) {
|
|
3094
|
+
let text;
|
|
3095
|
+
try {
|
|
3096
|
+
text = readFile(file);
|
|
3097
|
+
} catch {
|
|
3098
|
+
throw new CliError(`Cannot read ${file}.`);
|
|
3099
|
+
}
|
|
3100
|
+
try {
|
|
3101
|
+
return JSON.parse(text);
|
|
3102
|
+
} catch {
|
|
3103
|
+
throw new CliError(`${file} is not valid JSON.`);
|
|
3104
|
+
}
|
|
3105
|
+
}
|
|
3106
|
+
function printIssues(io, issues) {
|
|
3107
|
+
for (const i of issues) io.stderr(`${i.path ? `${i.path}: ` : ""}${i.message} (${i.code})`);
|
|
3108
|
+
}
|
|
3109
|
+
function issuesOf(err) {
|
|
3110
|
+
if (!(err instanceof ApiError) || typeof err.details !== "object" || err.details === null)
|
|
3111
|
+
return [];
|
|
3112
|
+
const issues = err.details.issues;
|
|
3113
|
+
return Array.isArray(issues) ? issues : [];
|
|
3114
|
+
}
|
|
3115
|
+
var INDEX_ID_SHAPE = "a synced index id like sx_ followed by 12 lowercase letters or digits";
|
|
3116
|
+
var LIST_INDEXES = "headband sync index list";
|
|
3117
|
+
function requireIndexId(args) {
|
|
3118
|
+
const id = args[1];
|
|
3119
|
+
if (!id) throw new CliError(`Missing synced index id (${LIST_INDEXES} shows them).`);
|
|
3120
|
+
return checkedId(id, SYNC_INDEX_ID_RE, INDEX_ID_SHAPE, LIST_INDEXES);
|
|
3121
|
+
}
|
|
3122
|
+
function idFlag(flags, name, re, shape, listCommand) {
|
|
3123
|
+
const value = str(flags, name);
|
|
3124
|
+
return value === void 0 ? void 0 : checkedId(value, re, shape, listCommand);
|
|
3125
|
+
}
|
|
3126
|
+
function indexApiError(err, id) {
|
|
3127
|
+
if (err instanceof ApiError) {
|
|
3128
|
+
if (err.status === 409 && err.code === "index_managed_by_sync") {
|
|
3129
|
+
throw new CliError(
|
|
3130
|
+
`${err.message} Change its definition with headband sync index update <id> -f <file>.`
|
|
3131
|
+
);
|
|
3132
|
+
}
|
|
3133
|
+
if (err.status === 409 && err.code === "index_dropping" && id) {
|
|
3134
|
+
throw new CliError(
|
|
3135
|
+
`${err.message} Finish with: headband sync index remove ${id} --drop-index`
|
|
3136
|
+
);
|
|
3137
|
+
}
|
|
3138
|
+
if (err.status === 409 && err.code === "not_on_box_yet" && id) {
|
|
3139
|
+
throw new CliError(`${err.message} Watch it start with: headband sync index show ${id}`);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
return friendlySyncError(err);
|
|
3143
|
+
}
|
|
3144
|
+
function fileFlag(flags, deps, required) {
|
|
3145
|
+
const file = str(flags, "--file");
|
|
3146
|
+
if (!file) {
|
|
3147
|
+
if (required) throw new CliError("Give the definition file with -f <file.sync.json>.");
|
|
3148
|
+
return void 0;
|
|
3149
|
+
}
|
|
3150
|
+
return readDefinition(file, deps.readFile);
|
|
3151
|
+
}
|
|
3152
|
+
function stateLine(i) {
|
|
3153
|
+
if (i.error) return `${i.state}: ${i.errorCode ?? "error"}: ${i.error}`;
|
|
3154
|
+
if (i.build) return `${i.state} (${i.build.phase}, ${i.build.rendered} rendered)`;
|
|
3155
|
+
return i.state;
|
|
3156
|
+
}
|
|
3157
|
+
function lag(i) {
|
|
3158
|
+
return i.lagMs === null ? "" : i.lagMs < 1e3 ? `${i.lagMs} ms` : `${Math.round(i.lagMs / 100) / 10} s`;
|
|
3159
|
+
}
|
|
3160
|
+
function printClassification(io, c) {
|
|
3161
|
+
if (!c) return;
|
|
3162
|
+
io.stdout(`Change: ${c.class}${c.reasons.length ? ` (${c.reasons.join("; ")})` : ""}`);
|
|
3163
|
+
}
|
|
3164
|
+
async function runSyncIndex(args, flags, deps) {
|
|
3165
|
+
const { io, client } = deps;
|
|
3166
|
+
const json = flags["--json"] === true;
|
|
3167
|
+
const action = args[0];
|
|
3168
|
+
try {
|
|
3169
|
+
if (action === "create") {
|
|
3170
|
+
const definition = fileFlag(flags, deps, true);
|
|
3171
|
+
const res = await client.post("/v1/sync/indexes", definition);
|
|
3172
|
+
if (json) return printJson(io, res);
|
|
3173
|
+
io.stdout(`Created synced index ${res.data.id} (${res.data.name}): ${stateLine(res.data)}.`);
|
|
3174
|
+
if (res.data.state === "awaiting_setup") {
|
|
3175
|
+
io.stdout(
|
|
3176
|
+
`Its tables are not published yet. Run the setup again: headband sync source setup ${res.data.sourceId}`
|
|
3177
|
+
);
|
|
3178
|
+
} else {
|
|
3179
|
+
io.stdout(`Watch it with: headband sync index show ${res.data.id}`);
|
|
3180
|
+
}
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
if (action === "list") {
|
|
3184
|
+
const q = new URLSearchParams();
|
|
3185
|
+
const source = idFlag(
|
|
3186
|
+
flags,
|
|
3187
|
+
"--source",
|
|
3188
|
+
SOURCE_ID_PATTERN,
|
|
3189
|
+
SOURCE_ID_SHAPE,
|
|
3190
|
+
"headband sync source list"
|
|
3191
|
+
);
|
|
3192
|
+
const instance = idFlag(
|
|
3193
|
+
flags,
|
|
3194
|
+
"--instance",
|
|
3195
|
+
INSTANCE_ID_RE,
|
|
3196
|
+
"an engine id like i followed by 12 lowercase letters or digits",
|
|
3197
|
+
"headband instances list"
|
|
3198
|
+
);
|
|
3199
|
+
if (source) q.set("source", source);
|
|
3200
|
+
if (instance) q.set("instance", instance);
|
|
3201
|
+
const qs = q.toString();
|
|
3202
|
+
const res = await client.get(
|
|
3203
|
+
`/v1/sync/indexes${qs ? `?${qs}` : ""}`
|
|
3204
|
+
);
|
|
3205
|
+
if (json) return printJson(io, res);
|
|
3206
|
+
if (res.data.length === 0) {
|
|
3207
|
+
io.stdout(
|
|
3208
|
+
"No synced indexes yet. Create one with: headband sync index create -f <file.sync.json>"
|
|
3209
|
+
);
|
|
3210
|
+
return;
|
|
3211
|
+
}
|
|
3212
|
+
printTable(
|
|
3213
|
+
io,
|
|
3214
|
+
res.data.map((i) => ({
|
|
3215
|
+
id: i.id,
|
|
3216
|
+
name: i.name,
|
|
3217
|
+
source: i.sourceId,
|
|
3218
|
+
instance: i.instanceId,
|
|
3219
|
+
state: stateLine(i),
|
|
3220
|
+
lag: lag(i),
|
|
3221
|
+
documents: i.documents ?? "",
|
|
3222
|
+
pending: i.pendingChanges || i.definitionPending ? "yes" : ""
|
|
3223
|
+
})),
|
|
3224
|
+
["id", "name", "source", "instance", "state", "lag", "documents", "pending"]
|
|
3225
|
+
);
|
|
3226
|
+
return;
|
|
3227
|
+
}
|
|
3228
|
+
if (action === "show") {
|
|
3229
|
+
const id = requireIndexId(args);
|
|
3230
|
+
const version = str(flags, "--version");
|
|
3231
|
+
if (version !== void 0) {
|
|
3232
|
+
if (!/^[1-9]\d{0,8}$/.test(version))
|
|
3233
|
+
throw new CliError("--version must be a positive whole number.");
|
|
3234
|
+
const res2 = await client.get(
|
|
3235
|
+
path(id, `?version=${version}`)
|
|
3236
|
+
);
|
|
3237
|
+
if (json) return printJson(io, res2);
|
|
3238
|
+
io.stdout(JSON.stringify(res2.data.definition, null, 2));
|
|
3239
|
+
return;
|
|
3240
|
+
}
|
|
3241
|
+
const res = await client.get(path(id));
|
|
3242
|
+
const jobs = await client.get(path(id, "/jobs?limit=5"));
|
|
3243
|
+
if (json) return printJson(io, { data: res.data, jobs: jobs.data });
|
|
3244
|
+
const i = res.data;
|
|
3245
|
+
const lines = [
|
|
3246
|
+
["id", i.id],
|
|
3247
|
+
["name", i.name],
|
|
3248
|
+
["source", i.sourceId],
|
|
3249
|
+
["instance", i.instanceId],
|
|
3250
|
+
["state", stateLine(i)],
|
|
3251
|
+
["desired", i.desired],
|
|
3252
|
+
[
|
|
3253
|
+
"definition",
|
|
3254
|
+
`version ${i.definitionVersion}${i.servingDefinitionVersion !== null && i.servingDefinitionVersion !== i.definitionVersion ? `, serving ${i.servingDefinitionVersion}` : ""}${i.definitionPending ? " (waits for a rebuild)" : ""}`
|
|
3255
|
+
],
|
|
3256
|
+
["physical", i.physicalName],
|
|
3257
|
+
["documents", i.documents === null ? "unknown" : String(i.documents)],
|
|
3258
|
+
["lag", lag(i) || "unknown"],
|
|
3259
|
+
["dead letters", String(i.deadLetters)],
|
|
3260
|
+
["last applied", i.lastAppliedAt ?? "never"],
|
|
3261
|
+
["pending", i.pendingChanges ? "a change has not reached the box yet" : "no"]
|
|
3262
|
+
];
|
|
3263
|
+
const width = Math.max(...lines.map(([k]) => k.length));
|
|
3264
|
+
for (const [k, v] of lines) io.stdout(`${k.padEnd(width)} ${v}`);
|
|
3265
|
+
if (jobs.data.length > 0) {
|
|
3266
|
+
io.stdout("");
|
|
3267
|
+
printTable(
|
|
3268
|
+
io,
|
|
3269
|
+
jobs.data.map((j) => ({
|
|
3270
|
+
job: j.id,
|
|
3271
|
+
kind: j.kind,
|
|
3272
|
+
trigger: j.trigger,
|
|
3273
|
+
state: j.state,
|
|
3274
|
+
started: j.startedAt,
|
|
3275
|
+
error: j.error ?? ""
|
|
3276
|
+
})),
|
|
3277
|
+
["job", "kind", "trigger", "state", "started", "error"]
|
|
3278
|
+
);
|
|
3279
|
+
}
|
|
3280
|
+
return;
|
|
3281
|
+
}
|
|
3282
|
+
if (action === "update") {
|
|
3283
|
+
const id = requireIndexId(args);
|
|
3284
|
+
const definition = fileFlag(flags, deps, true);
|
|
3285
|
+
const rebuild = flags["--rebuild"] === true;
|
|
3286
|
+
const res = await client.patch(path(id), {
|
|
3287
|
+
definition,
|
|
3288
|
+
...rebuild ? { rebuild: true } : {}
|
|
3289
|
+
});
|
|
3290
|
+
if (json) return printJson(io, res);
|
|
3291
|
+
if (res.saved) io.stdout(`Saved version ${res.data.definitionVersion}.`);
|
|
3292
|
+
else io.stdout(`No definition change${rebuild ? "; rebuild requested." : "."}`);
|
|
3293
|
+
printClassification(io, res.classification);
|
|
3294
|
+
if (res.saved && rebuild)
|
|
3295
|
+
io.stdout("Rebuild requested: the new version serves once it finishes.");
|
|
3296
|
+
if (res.saved && !rebuild)
|
|
3297
|
+
io.stdout(`It takes effect at the next rebuild: headband sync index rebuild ${id}`);
|
|
3298
|
+
return;
|
|
3299
|
+
}
|
|
3300
|
+
if (action === "validate") {
|
|
3301
|
+
const definition = fileFlag(flags, deps, true);
|
|
3302
|
+
const syncIndexId = idFlag(flags, "--index", SYNC_INDEX_ID_RE, INDEX_ID_SHAPE, LIST_INDEXES);
|
|
3303
|
+
const res = await client.post("/v1/sync/indexes/validate", { definition, ...syncIndexId ? { syncIndexId } : {} });
|
|
3304
|
+
if (json) return printJson(io, res);
|
|
3305
|
+
printIssues(io, res.data.errors);
|
|
3306
|
+
for (const w of res.data.warnings)
|
|
3307
|
+
io.stderr(`Warning: ${w.path ? `${w.path}: ` : ""}${w.message} (${w.code})`);
|
|
3308
|
+
if (!res.data.valid) throw new CliError("The definition is not valid.");
|
|
3309
|
+
io.stdout(`Valid${res.data.checkedOnBox ? ", checked against the source" : ""}.`);
|
|
3310
|
+
printClassification(io, res.data.classification);
|
|
3311
|
+
return;
|
|
3312
|
+
}
|
|
3313
|
+
if (action === "preview") {
|
|
3314
|
+
const id = requireIndexId(args);
|
|
3315
|
+
const rawIds = str(flags, "--ids");
|
|
3316
|
+
const ids = rawIds?.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3317
|
+
if (ids && ids.length > PREVIEW_MAX_IDS)
|
|
3318
|
+
throw new CliError(`Preview renders at most ${PREVIEW_MAX_IDS} ids.`);
|
|
3319
|
+
const definition = fileFlag(flags, deps, false);
|
|
3320
|
+
const res = await client.post(path(id, "/preview"), {
|
|
3321
|
+
...ids ? { ids } : {},
|
|
3322
|
+
...definition !== void 0 ? { definition } : {}
|
|
3323
|
+
});
|
|
3324
|
+
const previewFailed = () => new CliError(
|
|
3325
|
+
`The preview has ${res.data.errors.length} error${res.data.errors.length === 1 ? "" : "s"}.`
|
|
3326
|
+
);
|
|
3327
|
+
if (json) {
|
|
3328
|
+
printJson(io, res);
|
|
3329
|
+
if (res.data.errors.length > 0) throw previewFailed();
|
|
3330
|
+
return;
|
|
3331
|
+
}
|
|
3332
|
+
printIssues(io, res.data.errors);
|
|
3333
|
+
for (const w of res.data.warnings) io.stderr(`Warning: ${w.message} (${w.code})`);
|
|
3334
|
+
io.stdout(JSON.stringify(res.data.documents, null, 2));
|
|
3335
|
+
if (res.data.errors.length > 0) throw previewFailed();
|
|
3336
|
+
return;
|
|
3337
|
+
}
|
|
3338
|
+
if (action === "rebuild") {
|
|
3339
|
+
const id = requireIndexId(args);
|
|
3340
|
+
if (!await deps.confirm(
|
|
3341
|
+
`Rebuild ${id}? It renders every row again into a new index, then swaps.`
|
|
3342
|
+
)) {
|
|
3343
|
+
throw new CliError("Cancelled. Nothing changed.");
|
|
3344
|
+
}
|
|
3345
|
+
const res = await client.post(path(id, "/rebuild"), {});
|
|
3346
|
+
if (json) return printJson(io, res);
|
|
3347
|
+
io.stdout(
|
|
3348
|
+
`Rebuild requested for ${id}. Searches keep using the current index until the swap.`
|
|
3349
|
+
);
|
|
3350
|
+
return;
|
|
3351
|
+
}
|
|
3352
|
+
if (action === "pause" || action === "resume" || action === "check") {
|
|
3353
|
+
const id = requireIndexId(args);
|
|
3354
|
+
const res = await client.post(path(id, `/${action}`), {});
|
|
3355
|
+
if (json) return printJson(io, res);
|
|
3356
|
+
io.stdout(
|
|
3357
|
+
action === "pause" ? `Paused ${id}. Changes wait on the source until you resume.` : action === "resume" ? `Resumed ${id}.` : `Check started for ${id}.`
|
|
3358
|
+
);
|
|
3359
|
+
return;
|
|
3360
|
+
}
|
|
3361
|
+
if (action === "remove") {
|
|
3362
|
+
const id = requireIndexId(args);
|
|
3363
|
+
const drop = flags["--drop-index"] === true;
|
|
3364
|
+
const prompt = drop ? `Remove ${id} and delete its engine index? Searches against it stop working.` : `Remove ${id}? Sync stops; the engine index stays and becomes writable.`;
|
|
3365
|
+
if (!await deps.confirm(prompt)) throw new CliError("Cancelled. Nothing changed.");
|
|
3366
|
+
const res = await client.delete(path(id, drop ? "?dropIndex=1" : ""));
|
|
3367
|
+
if (json) return printJson(io, res);
|
|
3368
|
+
io.stdout(
|
|
3369
|
+
res.data.indexDropped ? `Removed ${id} and its engine index.` : drop && res.data.claimed === true ? `Removed ${id}. Deleting its engine index started and did not finish: run this command again to finish.` : drop ? `Removed ${id}. Its engine index is not deleted yet: run this command again to finish, or run it without --drop-index to keep the engine index.` : `Removed ${id}. Its engine index stays.`
|
|
3370
|
+
);
|
|
3371
|
+
return;
|
|
3372
|
+
}
|
|
3373
|
+
io.stdout(INDEX_USAGE);
|
|
3374
|
+
throw new CliError("Unknown sync index command");
|
|
3375
|
+
} catch (err) {
|
|
3376
|
+
const issues = issuesOf(err);
|
|
3377
|
+
if (issues.length > 0) printIssues(io, issues);
|
|
3378
|
+
indexApiError(err, SYNC_INDEX_ID_RE.test(args[1] ?? "") ? args[1] : void 0);
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
async function runSyncStatus(flags, deps) {
|
|
3382
|
+
const { io, client } = deps;
|
|
3383
|
+
try {
|
|
3384
|
+
const sources = await client.get("/v1/sync/sources");
|
|
3385
|
+
const indexes = await client.get("/v1/sync/indexes");
|
|
3386
|
+
if (flags["--json"] === true)
|
|
3387
|
+
return printJson(io, { sources: sources.data, indexes: indexes.data });
|
|
3388
|
+
if (sources.data.length === 0) {
|
|
3389
|
+
io.stdout("No sources yet. Start with: headband sync source add <name> --db-url <url>");
|
|
3390
|
+
return;
|
|
3391
|
+
}
|
|
3392
|
+
printTable(
|
|
3393
|
+
io,
|
|
3394
|
+
sources.data.map((s) => ({ source: s.id, name: s.name, host: s.host, state: s.state })),
|
|
3395
|
+
["source", "name", "host", "state"]
|
|
3396
|
+
);
|
|
3397
|
+
if (indexes.data.length > 0) {
|
|
3398
|
+
io.stdout("");
|
|
3399
|
+
printTable(
|
|
3400
|
+
io,
|
|
3401
|
+
indexes.data.map((i) => ({
|
|
3402
|
+
index: i.id,
|
|
3403
|
+
name: i.name,
|
|
3404
|
+
source: i.sourceId,
|
|
3405
|
+
state: i.state,
|
|
3406
|
+
lag: lag(i),
|
|
3407
|
+
documents: i.documents ?? ""
|
|
3408
|
+
})),
|
|
3409
|
+
["index", "name", "source", "state", "lag", "documents"]
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
3412
|
+
const attention = [];
|
|
3413
|
+
for (const s of sources.data) {
|
|
3414
|
+
if (s.state === "awaiting_setup")
|
|
3415
|
+
attention.push(`${s.id} waits for setup: headband sync source setup ${s.id}`);
|
|
3416
|
+
else if (ATTENTION_SOURCE.has(s.state))
|
|
3417
|
+
attention.push(
|
|
3418
|
+
`${s.id} ${s.state}${s.error ? `: ${s.error}` : ""}. Test it: headband sync source test ${s.id}`
|
|
3419
|
+
);
|
|
3420
|
+
}
|
|
3421
|
+
for (const i of indexes.data) {
|
|
3422
|
+
if (i.state === "awaiting_setup")
|
|
3423
|
+
attention.push(
|
|
3424
|
+
`${i.id} waits for its tables to be published: headband sync source setup ${i.sourceId}`
|
|
3425
|
+
);
|
|
3426
|
+
else if (i.state === "needs_rebuild")
|
|
3427
|
+
attention.push(`${i.id} needs a rebuild: headband sync index rebuild ${i.id}`);
|
|
3428
|
+
else if (ATTENTION_INDEX.has(i.state))
|
|
3429
|
+
attention.push(
|
|
3430
|
+
`${i.id} ${i.state}${i.error ? `: ${i.errorCode ?? "error"}: ${i.error}` : ""}. Details: headband sync index show ${i.id}`
|
|
3431
|
+
);
|
|
3432
|
+
}
|
|
3433
|
+
io.stdout("");
|
|
3434
|
+
if (attention.length === 0) io.stdout("Everything is running.");
|
|
3435
|
+
else {
|
|
3436
|
+
io.stdout("Needs attention:");
|
|
3437
|
+
for (const line of attention) io.stdout(` ${line}`);
|
|
3438
|
+
}
|
|
3439
|
+
} catch (err) {
|
|
3440
|
+
friendlySyncError(err);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
|
|
3444
|
+
// src/index.ts
|
|
3445
|
+
async function askWithPrompts(message) {
|
|
3446
|
+
let aborted = false;
|
|
3447
|
+
const answer = await (0, import_prompts2.default)(
|
|
3448
|
+
{ type: "confirm", name: "confirmed", message, initial: false },
|
|
3449
|
+
{
|
|
3450
|
+
onCancel: () => {
|
|
3451
|
+
aborted = true;
|
|
3452
|
+
return false;
|
|
3453
|
+
}
|
|
3454
|
+
}
|
|
3455
|
+
);
|
|
3456
|
+
return aborted ? void 0 : answer.confirmed === true;
|
|
3457
|
+
}
|
|
3458
|
+
var ENGINES2 = /* @__PURE__ */ new Set(["meilisearch", "typesense", "elasticsearch", "visor"]);
|
|
3459
|
+
var KEY_TYPES = /* @__PURE__ */ new Set(["admin", "search"]);
|
|
3460
|
+
var EXPORT_FORMATS = /* @__PURE__ */ new Set(["jsonl", "json"]);
|
|
3461
|
+
var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
3462
|
+
"--url",
|
|
3463
|
+
"--token",
|
|
3464
|
+
"--profile",
|
|
3465
|
+
"--engine",
|
|
3466
|
+
"--primary-key",
|
|
3467
|
+
"--file",
|
|
3468
|
+
"--index",
|
|
3469
|
+
"--limit",
|
|
3470
|
+
"--filter",
|
|
3471
|
+
"--type",
|
|
3472
|
+
"--project",
|
|
3473
|
+
"--dir",
|
|
3474
|
+
"--slug",
|
|
3475
|
+
"--table",
|
|
3476
|
+
"--scaffold",
|
|
3477
|
+
"--budget-ms",
|
|
3478
|
+
"--batch-size",
|
|
3479
|
+
"--reconcile-every",
|
|
3480
|
+
"--name",
|
|
3481
|
+
"--lane",
|
|
3482
|
+
"--memory",
|
|
3483
|
+
"--size",
|
|
3484
|
+
"--region",
|
|
3485
|
+
"--idle-timeout",
|
|
3486
|
+
"--client",
|
|
3487
|
+
"--instance",
|
|
3488
|
+
"--db-url",
|
|
3489
|
+
"--owner-url",
|
|
3490
|
+
"--tls-mode",
|
|
3491
|
+
"--ca-file",
|
|
3492
|
+
"--kind",
|
|
3493
|
+
"--batadb-project",
|
|
3494
|
+
"--batadb-branch",
|
|
3495
|
+
"--batadb-compute",
|
|
3496
|
+
"--provider",
|
|
3497
|
+
"-f",
|
|
3498
|
+
"--source",
|
|
3499
|
+
"--ids",
|
|
3500
|
+
"--version",
|
|
3501
|
+
"--out",
|
|
3502
|
+
"--format",
|
|
3503
|
+
"--batch",
|
|
3504
|
+
"--permissions",
|
|
3505
|
+
"--expires"
|
|
3506
|
+
]);
|
|
3507
|
+
var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
3508
|
+
"--json",
|
|
3509
|
+
"--yes",
|
|
3510
|
+
"-y",
|
|
3511
|
+
"--help",
|
|
3512
|
+
"-h",
|
|
3513
|
+
"--allow-destructive",
|
|
3514
|
+
"--write",
|
|
3515
|
+
"--force",
|
|
3516
|
+
"--fix",
|
|
3517
|
+
"--loop",
|
|
3518
|
+
"--accept-broad-role",
|
|
3519
|
+
"--rebuild",
|
|
3520
|
+
"--drop-index",
|
|
3521
|
+
"--none",
|
|
3522
|
+
"--no-revoke"
|
|
3523
|
+
]);
|
|
3524
|
+
var DEFAULT_MIGRATIONS_DIR = "headband/migrations";
|
|
3525
|
+
function parseArgv(argv) {
|
|
3526
|
+
const flags = {};
|
|
3527
|
+
const positional = [];
|
|
3528
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
3529
|
+
const arg = argv[index];
|
|
3530
|
+
if (!arg.startsWith("-")) {
|
|
3531
|
+
positional.push(arg);
|
|
3532
|
+
continue;
|
|
3533
|
+
}
|
|
3534
|
+
const equalsIndex = arg.indexOf("=");
|
|
3535
|
+
const flagName = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
|
3536
|
+
const inlineValue = equalsIndex === -1 ? void 0 : arg.slice(equalsIndex + 1);
|
|
3537
|
+
if (VALUE_FLAGS.has(flagName)) {
|
|
3538
|
+
const value = inlineValue ?? argv[index + 1];
|
|
3539
|
+
if (!value || value.startsWith("-")) {
|
|
3540
|
+
throw new UsageError(`Missing value for ${flagName}`);
|
|
3541
|
+
}
|
|
3542
|
+
flags[flagName === "-f" ? "--file" : flagName] = value;
|
|
3543
|
+
if (inlineValue === void 0) index += 1;
|
|
3544
|
+
continue;
|
|
3545
|
+
}
|
|
3546
|
+
if (BOOLEAN_FLAGS.has(flagName)) {
|
|
3547
|
+
flags[flagName === "-y" ? "--yes" : flagName === "-h" ? "--help" : flagName] = true;
|
|
3548
|
+
continue;
|
|
3549
|
+
}
|
|
3550
|
+
throw new UsageError(
|
|
3551
|
+
/^--?[a-z][a-z0-9-]{0,40}$/.test(flagName) ? `Unknown option: ${flagName}` : "Unknown option."
|
|
3552
|
+
);
|
|
3553
|
+
}
|
|
3554
|
+
return { flags, positional };
|
|
3555
|
+
}
|
|
3556
|
+
function stringFlag(flags, name) {
|
|
3557
|
+
const value = flags[name];
|
|
3558
|
+
return typeof value === "string" ? value : void 0;
|
|
3559
|
+
}
|
|
3560
|
+
function boolFlag(flags, name) {
|
|
3561
|
+
return flags[name] === true;
|
|
3562
|
+
}
|
|
3563
|
+
function requiredFlag(flags, name) {
|
|
3564
|
+
const value = stringFlag(flags, name);
|
|
3565
|
+
if (!value) throw new UsageError(`Missing required option: ${name}`);
|
|
3566
|
+
return value;
|
|
3567
|
+
}
|
|
3568
|
+
function requiredArg(value, what) {
|
|
3569
|
+
if (!value) throw new UsageError(`Missing ${what}`);
|
|
3570
|
+
return value;
|
|
3571
|
+
}
|
|
3572
|
+
function parseLimit(flags) {
|
|
3573
|
+
const raw = stringFlag(flags, "--limit");
|
|
3574
|
+
if (!raw) return void 0;
|
|
3575
|
+
const limit = Number(raw);
|
|
3576
|
+
if (!Number.isInteger(limit) || limit <= 0) {
|
|
3577
|
+
throw new UsageError("--limit must be a positive integer");
|
|
3578
|
+
}
|
|
3579
|
+
return limit;
|
|
3580
|
+
}
|
|
3581
|
+
function validateEngine(engine) {
|
|
3582
|
+
if (!ENGINES2.has(engine)) {
|
|
3583
|
+
throw new UsageError("Engine must be one of: meilisearch, typesense, elasticsearch, visor");
|
|
3584
|
+
}
|
|
3585
|
+
return engine;
|
|
3586
|
+
}
|
|
3587
|
+
function validateKeyType(type) {
|
|
3588
|
+
if (!KEY_TYPES.has(type)) {
|
|
3589
|
+
throw new UsageError("Key type must be one of: admin, search");
|
|
3590
|
+
}
|
|
3591
|
+
return type;
|
|
3592
|
+
}
|
|
3593
|
+
function encode(value) {
|
|
3594
|
+
return encodeURIComponent(value);
|
|
3595
|
+
}
|
|
3596
|
+
function chatterIO(io, flags) {
|
|
3597
|
+
return boolFlag(flags, "--json") ? { stdout: io.stderr, stderr: io.stderr } : io;
|
|
3598
|
+
}
|
|
3599
|
+
function authFromFlags(flags) {
|
|
3600
|
+
return resolveAuth({
|
|
3601
|
+
token: stringFlag(flags, "--token"),
|
|
3602
|
+
url: stringFlag(flags, "--url"),
|
|
3603
|
+
profile: stringFlag(flags, "--profile")
|
|
2158
3604
|
});
|
|
2159
|
-
|
|
3605
|
+
}
|
|
3606
|
+
function clientFromFlags(flags) {
|
|
3607
|
+
const auth = authFromFlags(flags);
|
|
3608
|
+
return createApiClient(auth.url, { apiKey: auth.apiKey });
|
|
3609
|
+
}
|
|
3610
|
+
function stdinIsInteractive() {
|
|
3611
|
+
return process.stdin.isTTY === true;
|
|
3612
|
+
}
|
|
3613
|
+
async function confirmOrSkip(message, flags, ask = askWithPrompts) {
|
|
3614
|
+
if (boolFlag(flags, "--yes")) return true;
|
|
3615
|
+
if (!stdinIsInteractive()) {
|
|
3616
|
+
throw new UsageError(
|
|
3617
|
+
`Confirmation required: ${message} stdin is not a terminal, so re-run with --yes to confirm.`,
|
|
3618
|
+
"confirmation_required"
|
|
3619
|
+
);
|
|
3620
|
+
}
|
|
3621
|
+
const answer = await ask(message);
|
|
3622
|
+
if (answer === void 0) {
|
|
3623
|
+
throw new CliError("Interrupted.", { exitCode: EXIT_CODES.interrupted, code: "interrupted" });
|
|
3624
|
+
}
|
|
3625
|
+
return answer;
|
|
3626
|
+
}
|
|
3627
|
+
function cancelled(io, flags) {
|
|
3628
|
+
if (boolFlag(flags, "--json")) printJson(io, { cancelled: true });
|
|
3629
|
+
else io.stdout("Cancelled.");
|
|
2160
3630
|
}
|
|
2161
3631
|
function printHelp(io) {
|
|
2162
3632
|
io.stdout(`@headband/cli
|
|
@@ -2166,58 +3636,74 @@ Usage:
|
|
|
2166
3636
|
|
|
2167
3637
|
Commands:
|
|
2168
3638
|
init [--yes]
|
|
2169
|
-
login [--url <url>]
|
|
2170
|
-
logout [--
|
|
2171
|
-
whoami [--
|
|
3639
|
+
login [--url <url>] [--profile <name>] [--json]
|
|
3640
|
+
logout [--profile <name>] [--yes] [--no-revoke] [--json]
|
|
3641
|
+
whoami [--json]
|
|
3642
|
+
profiles list [--json]
|
|
3643
|
+
profiles use <name>
|
|
2172
3644
|
projects list [--json]
|
|
2173
|
-
projects create <name> --engine <meilisearch|typesense|elasticsearch>
|
|
3645
|
+
projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]
|
|
3646
|
+
projects delete <id> [--force] [--yes] [--json]
|
|
2174
3647
|
keys list [--json]
|
|
2175
|
-
keys create <name> --type <admin|search> [--project <id>] [--json]
|
|
2176
|
-
keys revoke <id> [--yes]
|
|
3648
|
+
keys create <name> --type <admin|search> [--project <id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
|
|
3649
|
+
keys revoke <id> [--yes] [--json]
|
|
2177
3650
|
indexes list [--json]
|
|
2178
3651
|
indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
|
|
2179
|
-
indexes delete <uid> [--yes]
|
|
3652
|
+
indexes delete <uid> [--yes] [--json]
|
|
2180
3653
|
indexes settings get <uid> [--json]
|
|
2181
3654
|
indexes settings set <uid> --file <settings.json> [--json]
|
|
2182
3655
|
indexes stats <uid> [--json]
|
|
2183
3656
|
instances list [--json]
|
|
2184
|
-
instances create <engine> [--name <name>] [--lane <lane>] [--memory <size>] [--region <region>] [--idle-timeout <s>]
|
|
3657
|
+
instances create <engine> [--name <name>] [--lane <lane>] [--memory <size>] [--region <region>] [--idle-timeout <s>] [--json]
|
|
3658
|
+
instances update <id> [--name <name>] [--lane <lane>] [--memory <size>] [--idle-timeout <s>] [--json]
|
|
2185
3659
|
instances show|wake|park <id> [--json]
|
|
2186
|
-
instances
|
|
2187
|
-
instances
|
|
2188
|
-
|
|
2189
|
-
docs
|
|
3660
|
+
instances wakes <id> [--limit <n>] [--json]
|
|
3661
|
+
instances delete <id> [--yes] [--json]
|
|
3662
|
+
instances connect <id> [--client <node|curl>] [--json]
|
|
3663
|
+
docs push <file> --index <uid> [--json]
|
|
3664
|
+
docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>]
|
|
3665
|
+
docs delete <docId> --index <uid> [--json]
|
|
2190
3666
|
search <index> <query> [--limit N] [--filter <expr>] [--json]
|
|
2191
3667
|
tasks list [--json]
|
|
2192
3668
|
tasks get <id> [--json]
|
|
2193
|
-
schema diff [--
|
|
2194
|
-
schema generate [--slug <slug>] [--dir <dir>] [--
|
|
2195
|
-
schema apply [--allow-destructive] [--dir <dir>] [--yes] [--
|
|
2196
|
-
schema status [--dir <dir>] [--
|
|
2197
|
-
sync init [--table <name>] [--write]
|
|
2198
|
-
sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--
|
|
2199
|
-
sync worker --scaffold docker [--force]
|
|
2200
|
-
sync reconcile [--fix] [--
|
|
2201
|
-
sync retry-dead-letters
|
|
3669
|
+
schema diff [--allow-destructive] [--json]
|
|
3670
|
+
schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]
|
|
3671
|
+
schema apply [--allow-destructive] [--dir <dir>] [--yes] [--json]
|
|
3672
|
+
schema status [--dir <dir>] [--json]
|
|
3673
|
+
sync init [--table <name>] [--write] [--json]
|
|
3674
|
+
sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--json]
|
|
3675
|
+
sync worker --scaffold docker [--force] [--json]
|
|
3676
|
+
sync reconcile [--fix] [--json]
|
|
3677
|
+
sync retry-dead-letters [--json]
|
|
3678
|
+
sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)
|
|
3679
|
+
sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)
|
|
3680
|
+
sync status [--json]
|
|
2202
3681
|
|
|
2203
3682
|
Global options:
|
|
2204
|
-
--
|
|
2205
|
-
--
|
|
2206
|
-
--
|
|
2207
|
-
--
|
|
3683
|
+
--token <key> API key for this call. Wins over HEADBAND_API_KEY and stored logins.
|
|
3684
|
+
--profile <name> Stored login to use (else HEADBAND_PROFILE, else the current profile)
|
|
3685
|
+
--url <url> Headband URL (else HEADBAND_URL, else the profile's, else ${DEFAULT_URL})
|
|
3686
|
+
--json Print one JSON document on stdout; errors print one JSON object on stderr
|
|
3687
|
+
--yes Answer yes to confirmations (required when stdin is not a terminal)
|
|
3688
|
+
--help Show help
|
|
3689
|
+
--version Show the CLI version
|
|
3690
|
+
|
|
3691
|
+
Exit codes: 0 ok, 1 error, 2 usage, 3 auth, 4 permission, 5 not found, 6 conflict,
|
|
3692
|
+
7 rate limited, 8 server or engine, 130 interrupted.`);
|
|
2208
3693
|
}
|
|
2209
3694
|
function printCommandHelp(command, io) {
|
|
2210
3695
|
const help = {
|
|
2211
|
-
login: "Usage: headband login [--url <url>]",
|
|
2212
|
-
logout: "Usage: headband logout [--
|
|
2213
|
-
whoami: "Usage: headband whoami [--
|
|
2214
|
-
|
|
2215
|
-
|
|
3696
|
+
login: "Usage: headband login [--url <url>] [--profile <name>] [--json]",
|
|
3697
|
+
logout: "Usage: headband logout [--profile <name>] [--yes] [--no-revoke] [--json]",
|
|
3698
|
+
whoami: "Usage: headband whoami [--json]",
|
|
3699
|
+
profiles: "Usage:\n headband profiles list [--json]\n headband profiles use <name>",
|
|
3700
|
+
projects: "Usage:\n headband projects list [--json]\n headband projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]\n headband projects delete <id> [--force] [--yes] [--json]\n\n delete refuses a project that still has indexes; --force deletes its indexes with it.",
|
|
3701
|
+
keys: "Usage:\n headband keys list [--json]\n headband keys create <name> --type <admin|search> [--project <id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]\n headband keys revoke <id> [--yes] [--json]",
|
|
2216
3702
|
indexes: [
|
|
2217
3703
|
"Usage:",
|
|
2218
3704
|
" headband indexes list [--json]",
|
|
2219
3705
|
" headband indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]",
|
|
2220
|
-
" headband indexes delete <uid> [--yes]",
|
|
3706
|
+
" headband indexes delete <uid> [--yes] [--json]",
|
|
2221
3707
|
" headband indexes settings get <uid> [--json]",
|
|
2222
3708
|
" headband indexes settings set <uid> --file <settings.json> [--json]",
|
|
2223
3709
|
" headband indexes stats <uid> [--json]",
|
|
@@ -2227,55 +3713,121 @@ function printCommandHelp(command, io) {
|
|
|
2227
3713
|
" The index takes that engine's type; --engine, if given, must match it."
|
|
2228
3714
|
].join("\n"),
|
|
2229
3715
|
instances: INSTANCES_USAGE,
|
|
2230
|
-
docs: "Usage:\n headband docs push <file> --index <uid
|
|
3716
|
+
docs: "Usage:\n headband docs push <file> --index <uid> [--json]\n headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--json]\n headband docs delete <docId> --index <uid> [--json]",
|
|
2231
3717
|
search: "Usage: headband search <index> <query> [--limit N] [--filter <expr>] [--json]",
|
|
2232
3718
|
tasks: "Usage:\n headband tasks list [--json]\n headband tasks get <id> [--json]",
|
|
2233
|
-
schema: "Usage:\n headband schema diff\n headband schema generate [--slug <slug>] [--dir <dir>]\n headband schema apply [--allow-destructive] [--dir <dir>] [--yes]\n headband schema status [--dir <dir>]",
|
|
2234
|
-
sync: "Usage:\n headband sync init [--table <name>] [--write]\n headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>]\n headband sync worker --scaffold docker [--force]\n headband sync reconcile [--fix]\n headband sync retry-dead-letters"
|
|
3719
|
+
schema: "Usage:\n headband schema diff [--allow-destructive] [--json]\n headband schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]\n headband schema apply [--allow-destructive] [--dir <dir>] [--yes] [--json]\n headband schema status [--dir <dir>] [--json]",
|
|
3720
|
+
sync: "Usage:\n headband sync init [--table <name>] [--write] [--json]\n headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--json]\n headband sync worker --scaffold docker [--force] [--json]\n headband sync reconcile [--fix] [--json]\n headband sync retry-dead-letters [--json]\n headband sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)\n headband sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)\n headband sync status [--json]"
|
|
2235
3721
|
};
|
|
2236
3722
|
io.stdout(help[command] || "");
|
|
2237
3723
|
}
|
|
2238
3724
|
async function runLogin(flags, io) {
|
|
2239
|
-
|
|
3725
|
+
const url = stringFlag(flags, "--url") || process.env.HEADBAND_URL || DEFAULT_URL;
|
|
3726
|
+
const result = await login(url, chatterIO(io, flags), {
|
|
3727
|
+
profileName: stringFlag(flags, "--profile")
|
|
3728
|
+
});
|
|
3729
|
+
if (boolFlag(flags, "--json")) printJson(io, result);
|
|
2240
3730
|
}
|
|
2241
|
-
async function runLogout(flags, io) {
|
|
2242
|
-
const
|
|
2243
|
-
io
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
3731
|
+
async function runLogout(flags, io, confirm) {
|
|
3732
|
+
const json = boolFlag(flags, "--json");
|
|
3733
|
+
const chatter = chatterIO(io, flags);
|
|
3734
|
+
const selected = selectProfile({
|
|
3735
|
+
url: stringFlag(flags, "--url"),
|
|
3736
|
+
profile: stringFlag(flags, "--profile")
|
|
3737
|
+
});
|
|
3738
|
+
if (!selected) {
|
|
3739
|
+
throw new AuthError("No stored login to log out of. Run: headband profiles list");
|
|
3740
|
+
}
|
|
3741
|
+
const { name, profile } = selected;
|
|
3742
|
+
let revoked = false;
|
|
3743
|
+
const noRevoke = boolFlag(flags, "--no-revoke");
|
|
3744
|
+
if (!noRevoke) {
|
|
3745
|
+
if (!boolFlag(flags, "--yes")) {
|
|
3746
|
+
chatter.stdout(
|
|
3747
|
+
`Removing local credentials for profile ${name} (${profile.url}). Pass --no-revoke to keep the key working.`
|
|
3748
|
+
);
|
|
3749
|
+
}
|
|
3750
|
+
if (await confirm("Revoke this CLI key server-side before logging out?")) {
|
|
3751
|
+
const client = createApiClient(profile.url, profile);
|
|
3752
|
+
try {
|
|
3753
|
+
await client.delete(`/v1/keys/${encode(profile.keyId)}`);
|
|
3754
|
+
revoked = true;
|
|
3755
|
+
chatter.stdout("Revoked CLI key.");
|
|
3756
|
+
} catch (error) {
|
|
3757
|
+
if (!(error instanceof ApiError && (error.status === 401 || error.status === 404))) {
|
|
3758
|
+
throw error;
|
|
3759
|
+
}
|
|
3760
|
+
chatter.stderr("The key was already revoked or no longer exists.");
|
|
3761
|
+
}
|
|
2255
3762
|
}
|
|
2256
3763
|
}
|
|
2257
|
-
removeProfile(
|
|
2258
|
-
io.
|
|
3764
|
+
removeProfile(name);
|
|
3765
|
+
if (json) printJson(io, { profile: name, url: profile.url, revoked, loggedOut: true });
|
|
3766
|
+
else io.stdout(`Logged out of ${profile.url} (profile ${name}).`);
|
|
2259
3767
|
}
|
|
2260
3768
|
async function runWhoami(flags, io) {
|
|
2261
|
-
const
|
|
2262
|
-
const client = createApiClient(url,
|
|
3769
|
+
const auth = authFromFlags(flags);
|
|
3770
|
+
const client = createApiClient(auth.url, auth);
|
|
2263
3771
|
await client.get("/v1/indexes");
|
|
2264
3772
|
const info = {
|
|
2265
|
-
url,
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
3773
|
+
url: auth.url,
|
|
3774
|
+
source: auth.source,
|
|
3775
|
+
profile: auth.profileName ?? null,
|
|
3776
|
+
orgName: auth.profile?.orgName ?? null,
|
|
3777
|
+
orgId: auth.profile?.orgId ?? null,
|
|
3778
|
+
keyPrefix: auth.profile?.keyPrefix ?? auth.apiKey.slice(0, 12),
|
|
3779
|
+
keyId: auth.profile?.keyId ?? null,
|
|
2270
3780
|
valid: true
|
|
2271
3781
|
};
|
|
2272
3782
|
if (boolFlag(flags, "--json")) {
|
|
2273
3783
|
printJson(io, info);
|
|
2274
3784
|
return;
|
|
2275
3785
|
}
|
|
2276
|
-
printTable(
|
|
3786
|
+
printTable(
|
|
3787
|
+
io,
|
|
3788
|
+
[info],
|
|
3789
|
+
auth.profile ? ["url", "profile", "orgName", "keyPrefix", "keyId", "valid"] : ["url", "source", "keyPrefix", "valid"]
|
|
3790
|
+
);
|
|
3791
|
+
}
|
|
3792
|
+
async function runProfiles(args, flags, io) {
|
|
3793
|
+
const action = args[0];
|
|
3794
|
+
if (action === "list") {
|
|
3795
|
+
const credentials = readCredentials();
|
|
3796
|
+
const profiles = Object.entries(credentials.profiles).map(([name, p]) => ({
|
|
3797
|
+
name,
|
|
3798
|
+
current: credentials.current === name,
|
|
3799
|
+
url: p.url,
|
|
3800
|
+
orgName: p.orgName,
|
|
3801
|
+
orgId: p.orgId,
|
|
3802
|
+
keyPrefix: p.keyPrefix,
|
|
3803
|
+
keyId: p.keyId
|
|
3804
|
+
}));
|
|
3805
|
+
if (boolFlag(flags, "--json")) {
|
|
3806
|
+
printJson(io, { data: { current: credentials.current ?? null, profiles } });
|
|
3807
|
+
return;
|
|
3808
|
+
}
|
|
3809
|
+
if (profiles.length === 0) {
|
|
3810
|
+
io.stdout("No stored logins. Run: headband login");
|
|
3811
|
+
return;
|
|
3812
|
+
}
|
|
3813
|
+
printTable(
|
|
3814
|
+
io,
|
|
3815
|
+
profiles.map((p) => ({ ...p, current: p.current ? "*" : "" })),
|
|
3816
|
+
["current", "name", "url", "orgName", "keyPrefix"]
|
|
3817
|
+
);
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
if (action === "use") {
|
|
3821
|
+
const name = requiredArg(args[1], "profile name");
|
|
3822
|
+
const profile = setCurrentProfile(name);
|
|
3823
|
+
if (boolFlag(flags, "--json")) printJson(io, { current: name, url: profile.url });
|
|
3824
|
+
else io.stdout(`Now using profile ${name} (${profile.url}).`);
|
|
3825
|
+
return;
|
|
3826
|
+
}
|
|
3827
|
+
printCommandHelp("profiles", io);
|
|
3828
|
+
throw new UsageError("Unknown profiles command");
|
|
2277
3829
|
}
|
|
2278
|
-
async function runProjects(args, flags, io) {
|
|
3830
|
+
async function runProjects(args, flags, io, confirm) {
|
|
2279
3831
|
const action = args[0];
|
|
2280
3832
|
const client = clientFromFlags(flags);
|
|
2281
3833
|
if (action === "list") {
|
|
@@ -2287,8 +3839,7 @@ async function runProjects(args, flags, io) {
|
|
|
2287
3839
|
return;
|
|
2288
3840
|
}
|
|
2289
3841
|
if (action === "create") {
|
|
2290
|
-
const name = args[1];
|
|
2291
|
-
if (!name) throw new CliError("Missing project name");
|
|
3842
|
+
const name = requiredArg(args[1], "project name");
|
|
2292
3843
|
const engine = validateEngine(requiredFlag(flags, "--engine"));
|
|
2293
3844
|
const response = await client.post(
|
|
2294
3845
|
"/v1/projects",
|
|
@@ -2301,10 +3852,58 @@ async function runProjects(args, flags, io) {
|
|
|
2301
3852
|
else printTable(io, [response.data.project], ["id", "name", "engine", "prefix", "createdAt"]);
|
|
2302
3853
|
return;
|
|
2303
3854
|
}
|
|
3855
|
+
if (action === "delete") {
|
|
3856
|
+
const id = requiredArg(args[1], "project id");
|
|
3857
|
+
const force = boolFlag(flags, "--force");
|
|
3858
|
+
const question = force ? `Delete project ${id} and every index in it?` : `Delete project ${id}?`;
|
|
3859
|
+
if (!await confirm(question)) return cancelled(io, flags);
|
|
3860
|
+
const response = await client.delete(`/v1/projects/${encode(id)}${force ? "?force=true" : ""}`);
|
|
3861
|
+
if (boolFlag(flags, "--json")) printJson(io, response);
|
|
3862
|
+
else io.stdout(`Deleted project ${id}.`);
|
|
3863
|
+
return;
|
|
3864
|
+
}
|
|
2304
3865
|
printCommandHelp("projects", io);
|
|
2305
|
-
throw new
|
|
3866
|
+
throw new UsageError("Unknown projects command");
|
|
3867
|
+
}
|
|
3868
|
+
var DURATION = /^(\d+)\s*(h|d|w|y)$/i;
|
|
3869
|
+
var DURATION_MS = {
|
|
3870
|
+
h: 60 * 60 * 1e3,
|
|
3871
|
+
d: 24 * 60 * 60 * 1e3,
|
|
3872
|
+
w: 7 * 24 * 60 * 60 * 1e3,
|
|
3873
|
+
y: 365 * 24 * 60 * 60 * 1e3
|
|
3874
|
+
};
|
|
3875
|
+
function parseExpires(raw, now = Date.now()) {
|
|
3876
|
+
const duration = DURATION.exec(raw.trim());
|
|
3877
|
+
if (duration) {
|
|
3878
|
+
const amount = Number(duration[1]);
|
|
3879
|
+
if (amount <= 0) throw new UsageError("--expires must be in the future");
|
|
3880
|
+
return new Date(now + amount * DURATION_MS[duration[2].toLowerCase()]).toISOString();
|
|
3881
|
+
}
|
|
3882
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(raw.trim())) {
|
|
3883
|
+
const at = Date.parse(raw.trim());
|
|
3884
|
+
if (!Number.isNaN(at)) {
|
|
3885
|
+
if (at <= now) throw new UsageError("--expires must be in the future");
|
|
3886
|
+
return new Date(at).toISOString();
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
throw new UsageError(
|
|
3890
|
+
"--expires must be a duration (12h, 30d, 2w, 1y) or an ISO date (2027-01-31)"
|
|
3891
|
+
);
|
|
2306
3892
|
}
|
|
2307
|
-
|
|
3893
|
+
function parsePermissions(raw) {
|
|
3894
|
+
const permissions = [
|
|
3895
|
+
...new Set(
|
|
3896
|
+
raw.split(",").map((p) => p.trim()).filter(Boolean)
|
|
3897
|
+
)
|
|
3898
|
+
];
|
|
3899
|
+
if (permissions.length === 0) {
|
|
3900
|
+
throw new UsageError(
|
|
3901
|
+
"--permissions needs at least one permission, such as search,documents.read"
|
|
3902
|
+
);
|
|
3903
|
+
}
|
|
3904
|
+
return permissions;
|
|
3905
|
+
}
|
|
3906
|
+
async function runKeys(args, flags, io, confirm) {
|
|
2308
3907
|
const action = args[0];
|
|
2309
3908
|
const client = clientFromFlags(flags);
|
|
2310
3909
|
if (action === "list") {
|
|
@@ -2317,19 +3916,23 @@ async function runKeys(args, flags, io) {
|
|
|
2317
3916
|
"type",
|
|
2318
3917
|
"keyPrefix",
|
|
2319
3918
|
"projectId",
|
|
2320
|
-
"lastUsedAt"
|
|
3919
|
+
"lastUsedAt",
|
|
3920
|
+
"expiresAt"
|
|
2321
3921
|
]);
|
|
2322
3922
|
return;
|
|
2323
3923
|
}
|
|
2324
3924
|
if (action === "create") {
|
|
2325
|
-
const name = args[1];
|
|
2326
|
-
if (!name) throw new CliError("Missing key name");
|
|
3925
|
+
const name = requiredArg(args[1], "key name");
|
|
2327
3926
|
const body = {
|
|
2328
3927
|
name,
|
|
2329
3928
|
type: validateKeyType(requiredFlag(flags, "--type"))
|
|
2330
3929
|
};
|
|
2331
3930
|
const projectId = stringFlag(flags, "--project");
|
|
2332
3931
|
if (projectId) body.projectId = projectId;
|
|
3932
|
+
const permissions = stringFlag(flags, "--permissions");
|
|
3933
|
+
if (permissions) body.permissions = parsePermissions(permissions);
|
|
3934
|
+
const expires = stringFlag(flags, "--expires");
|
|
3935
|
+
if (expires) body.expiresAt = parseExpires(expires);
|
|
2333
3936
|
const response = await client.post("/v1/keys", body);
|
|
2334
3937
|
if (boolFlag(flags, "--json")) {
|
|
2335
3938
|
printJson(io, response);
|
|
@@ -2337,25 +3940,21 @@ async function runKeys(args, flags, io) {
|
|
|
2337
3940
|
}
|
|
2338
3941
|
io.stdout(import_picocolors3.default.yellow("Store this API key now. It will not be shown again."));
|
|
2339
3942
|
io.stdout(response.data.apiKey);
|
|
2340
|
-
printTable(io, [response.data.key], ["id", "name", "type", "keyPrefix"]);
|
|
3943
|
+
printTable(io, [response.data.key], ["id", "name", "type", "keyPrefix", "expiresAt"]);
|
|
2341
3944
|
return;
|
|
2342
3945
|
}
|
|
2343
3946
|
if (action === "revoke") {
|
|
2344
|
-
const id = args[1];
|
|
2345
|
-
if (!
|
|
2346
|
-
if (!await confirmOrSkip(`Revoke key ${id}?`, flags)) {
|
|
2347
|
-
io.stdout("Cancelled.");
|
|
2348
|
-
return;
|
|
2349
|
-
}
|
|
3947
|
+
const id = requiredArg(args[1], "key id");
|
|
3948
|
+
if (!await confirm(`Revoke key ${id}?`)) return cancelled(io, flags);
|
|
2350
3949
|
const response = await client.delete(`/v1/keys/${encode(id)}`);
|
|
2351
3950
|
if (boolFlag(flags, "--json")) printJson(io, response);
|
|
2352
3951
|
else io.stdout(`Revoked key ${id}.`);
|
|
2353
3952
|
return;
|
|
2354
3953
|
}
|
|
2355
3954
|
printCommandHelp("keys", io);
|
|
2356
|
-
throw new
|
|
3955
|
+
throw new UsageError("Unknown keys command");
|
|
2357
3956
|
}
|
|
2358
|
-
async function runIndexes(args, flags, io) {
|
|
3957
|
+
async function runIndexes(args, flags, io, confirm) {
|
|
2359
3958
|
const action = args[0];
|
|
2360
3959
|
const client = clientFromFlags(flags);
|
|
2361
3960
|
if (action === "list") {
|
|
@@ -2375,8 +3974,7 @@ async function runIndexes(args, flags, io) {
|
|
|
2375
3974
|
return;
|
|
2376
3975
|
}
|
|
2377
3976
|
if (action === "create") {
|
|
2378
|
-
const uid = args[1];
|
|
2379
|
-
if (!uid) throw new CliError("Missing index uid");
|
|
3977
|
+
const uid = requiredArg(args[1], "index uid");
|
|
2380
3978
|
const body = { uid };
|
|
2381
3979
|
const primaryKey = stringFlag(flags, "--primary-key");
|
|
2382
3980
|
const engine = stringFlag(flags, "--engine");
|
|
@@ -2402,12 +4000,8 @@ async function runIndexes(args, flags, io) {
|
|
|
2402
4000
|
return;
|
|
2403
4001
|
}
|
|
2404
4002
|
if (action === "delete") {
|
|
2405
|
-
const uid = args[1];
|
|
2406
|
-
if (!
|
|
2407
|
-
if (!await confirmOrSkip(`Delete index ${uid}?`, flags)) {
|
|
2408
|
-
io.stdout("Cancelled.");
|
|
2409
|
-
return;
|
|
2410
|
-
}
|
|
4003
|
+
const uid = requiredArg(args[1], "index uid");
|
|
4004
|
+
if (!await confirm(`Delete index ${uid}?`)) return cancelled(io, flags);
|
|
2411
4005
|
const response = await client.delete(`/v1/indexes/${encode(uid)}`);
|
|
2412
4006
|
if (boolFlag(flags, "--json")) printJson(io, response);
|
|
2413
4007
|
else io.stdout(`Deleted index ${uid}.`);
|
|
@@ -2415,8 +4009,7 @@ async function runIndexes(args, flags, io) {
|
|
|
2415
4009
|
}
|
|
2416
4010
|
if (action === "settings") {
|
|
2417
4011
|
const settingsAction = args[1];
|
|
2418
|
-
const uid = args[2];
|
|
2419
|
-
if (!uid) throw new CliError("Missing index uid");
|
|
4012
|
+
const uid = requiredArg(args[2], "index uid");
|
|
2420
4013
|
if (settingsAction === "get") {
|
|
2421
4014
|
const response = await client.get(`/v1/indexes/${encode(uid)}/settings`);
|
|
2422
4015
|
printJson(io, response);
|
|
@@ -2424,51 +4017,95 @@ async function runIndexes(args, flags, io) {
|
|
|
2424
4017
|
}
|
|
2425
4018
|
if (settingsAction === "set") {
|
|
2426
4019
|
const file = requiredFlag(flags, "--file");
|
|
2427
|
-
const settings = JSON.parse((0,
|
|
4020
|
+
const settings = JSON.parse((0, import_fs11.readFileSync)(file, "utf-8"));
|
|
2428
4021
|
const response = await client.patch(`/v1/indexes/${encode(uid)}/settings`, settings);
|
|
2429
4022
|
printJson(io, response);
|
|
2430
4023
|
return;
|
|
2431
4024
|
}
|
|
2432
4025
|
}
|
|
2433
4026
|
if (action === "stats") {
|
|
2434
|
-
const uid = args[1];
|
|
2435
|
-
if (!uid) throw new CliError("Missing index uid");
|
|
4027
|
+
const uid = requiredArg(args[1], "index uid");
|
|
2436
4028
|
const response = await client.get(`/v1/indexes/${encode(uid)}/stats`);
|
|
2437
4029
|
printJson(io, response);
|
|
2438
4030
|
return;
|
|
2439
4031
|
}
|
|
2440
4032
|
printCommandHelp("indexes", io);
|
|
2441
|
-
throw new
|
|
4033
|
+
throw new UsageError("Unknown indexes command");
|
|
2442
4034
|
}
|
|
2443
|
-
|
|
2444
|
-
const
|
|
4035
|
+
function exportFormat(flags, out) {
|
|
4036
|
+
const raw = stringFlag(flags, "--format") ?? (out?.toLowerCase().endsWith(".json") ? "json" : "jsonl");
|
|
4037
|
+
if (!EXPORT_FORMATS.has(raw)) throw new UsageError("--format must be jsonl or json");
|
|
4038
|
+
return raw;
|
|
4039
|
+
}
|
|
4040
|
+
async function runDocsExport(flags, context) {
|
|
4041
|
+
const { io } = context;
|
|
4042
|
+
const index = requiredFlag(flags, "--index");
|
|
4043
|
+
const out = stringFlag(flags, "--out");
|
|
4044
|
+
const format = exportFormat(flags, out);
|
|
2445
4045
|
const client = clientFromFlags(flags);
|
|
4046
|
+
const batchSize = parseIntFlag(flags, "--batch") ?? await defaultExportBatchSize(client, index);
|
|
4047
|
+
if (!out) {
|
|
4048
|
+
const writeLine = context.writeLine ?? ((line) => io.stdout(line));
|
|
4049
|
+
await exportDocuments(client, { index, format, batchSize, write: writeLine });
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
const partial = `${out}.partial`;
|
|
4053
|
+
const fd = (0, import_fs10.openSync)(partial, "w", 384);
|
|
4054
|
+
let exported;
|
|
4055
|
+
try {
|
|
4056
|
+
exported = await exportDocuments(client, {
|
|
4057
|
+
index,
|
|
4058
|
+
format,
|
|
4059
|
+
batchSize,
|
|
4060
|
+
write: (line) => {
|
|
4061
|
+
(0, import_fs10.writeSync)(fd, `${line}
|
|
4062
|
+
`);
|
|
4063
|
+
},
|
|
4064
|
+
onPage: (count, total) => io.stderr(`Exported ${count}${total === null ? "" : `/${total}`} documents`)
|
|
4065
|
+
});
|
|
4066
|
+
(0, import_fs10.closeSync)(fd);
|
|
4067
|
+
} catch (error) {
|
|
4068
|
+
(0, import_fs10.closeSync)(fd);
|
|
4069
|
+
(0, import_fs10.rmSync)(partial, { force: true });
|
|
4070
|
+
throw error;
|
|
4071
|
+
}
|
|
4072
|
+
(0, import_fs10.renameSync)(partial, out);
|
|
4073
|
+
if (boolFlag(flags, "--json")) printJson(io, { index, out, format, exported });
|
|
4074
|
+
else io.stdout(`Exported ${exported} documents to ${out}.`);
|
|
4075
|
+
}
|
|
4076
|
+
async function runDocs(args, flags, context) {
|
|
4077
|
+
const { io } = context;
|
|
4078
|
+
const action = args[0];
|
|
4079
|
+
if (action === "export") {
|
|
4080
|
+
await runDocsExport(flags, context);
|
|
4081
|
+
return;
|
|
4082
|
+
}
|
|
2446
4083
|
if (action === "push") {
|
|
2447
|
-
const file = args[1];
|
|
2448
|
-
if (!file) throw new CliError("Missing documents file");
|
|
4084
|
+
const file = requiredArg(args[1], "documents file");
|
|
2449
4085
|
const index = requiredFlag(flags, "--index");
|
|
4086
|
+
const client = clientFromFlags(flags);
|
|
2450
4087
|
const documents = parseDocumentsFile(file);
|
|
2451
|
-
const count = await pushDocuments(client, index, documents, io);
|
|
2452
|
-
|
|
4088
|
+
const count = await pushDocuments(client, index, documents, chatterIO(io, flags));
|
|
4089
|
+
if (boolFlag(flags, "--json")) printJson(io, { index, pushed: count });
|
|
4090
|
+
else io.stdout(`Pushed ${count} documents.`);
|
|
2453
4091
|
return;
|
|
2454
4092
|
}
|
|
2455
4093
|
if (action === "delete") {
|
|
2456
|
-
const docId = args[1];
|
|
2457
|
-
if (!docId) throw new CliError("Missing document id");
|
|
4094
|
+
const docId = requiredArg(args[1], "document id");
|
|
2458
4095
|
const index = requiredFlag(flags, "--index");
|
|
4096
|
+
const client = clientFromFlags(flags);
|
|
2459
4097
|
const response = await client.delete(`/v1/documents/${encode(docId)}?index=${encode(index)}`);
|
|
2460
4098
|
if (boolFlag(flags, "--json")) printJson(io, response);
|
|
2461
4099
|
else io.stdout(`Deleted document ${docId}.`);
|
|
2462
4100
|
return;
|
|
2463
4101
|
}
|
|
2464
4102
|
printCommandHelp("docs", io);
|
|
2465
|
-
throw new
|
|
4103
|
+
throw new UsageError("Unknown docs command");
|
|
2466
4104
|
}
|
|
2467
4105
|
async function runSearch(args, flags, io) {
|
|
2468
|
-
const index = args[0];
|
|
4106
|
+
const index = requiredArg(args[0], "search index");
|
|
2469
4107
|
const query = args.slice(1).join(" ");
|
|
2470
|
-
if (!
|
|
2471
|
-
if (!query) throw new CliError("Missing search query");
|
|
4108
|
+
if (!query) throw new UsageError("Missing search query");
|
|
2472
4109
|
const client = clientFromFlags(flags);
|
|
2473
4110
|
const body = {
|
|
2474
4111
|
index,
|
|
@@ -2493,40 +4130,56 @@ async function runTasks(args, flags, io) {
|
|
|
2493
4130
|
if (action === "list") {
|
|
2494
4131
|
const response = await client.get("/v1/tasks");
|
|
2495
4132
|
if (boolFlag(flags, "--json")) printJson(io, response);
|
|
2496
|
-
else
|
|
4133
|
+
else
|
|
4134
|
+
printTable(io, response.results ?? [], [
|
|
4135
|
+
"uid",
|
|
4136
|
+
"indexUid",
|
|
4137
|
+
"type",
|
|
4138
|
+
"status",
|
|
4139
|
+
"enqueuedAt",
|
|
4140
|
+
"finishedAt"
|
|
4141
|
+
]);
|
|
2497
4142
|
return;
|
|
2498
4143
|
}
|
|
2499
4144
|
if (action === "get") {
|
|
2500
|
-
const id = args[1];
|
|
2501
|
-
if (!id) throw new CliError("Missing task id");
|
|
4145
|
+
const id = requiredArg(args[1], "task id");
|
|
2502
4146
|
const response = await client.get(`/v1/tasks/${encode(id)}`);
|
|
2503
4147
|
printJson(io, response);
|
|
2504
4148
|
return;
|
|
2505
4149
|
}
|
|
2506
4150
|
printCommandHelp("tasks", io);
|
|
2507
|
-
throw new
|
|
4151
|
+
throw new UsageError("Unknown tasks command");
|
|
2508
4152
|
}
|
|
2509
|
-
async function runSchema(args, flags, io) {
|
|
4153
|
+
async function runSchema(args, flags, io, confirm) {
|
|
2510
4154
|
const action = args[0];
|
|
4155
|
+
if (!["diff", "generate", "apply", "status"].includes(action)) {
|
|
4156
|
+
printCommandHelp("schema", io);
|
|
4157
|
+
throw new UsageError("Unknown schema command");
|
|
4158
|
+
}
|
|
4159
|
+
const json = boolFlag(flags, "--json");
|
|
4160
|
+
const chatter = chatterIO(io, flags);
|
|
2511
4161
|
const dir = stringFlag(flags, "--dir") || DEFAULT_MIGRATIONS_DIR;
|
|
2512
|
-
const
|
|
2513
|
-
const client = new HeadbandClient({ url, adminKey:
|
|
4162
|
+
const auth = authFromFlags(flags);
|
|
4163
|
+
const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
|
|
2514
4164
|
const { config, adapter } = await loadHeadbandConfig(process.cwd());
|
|
2515
4165
|
const allowDestructive = boolFlag(flags, "--allow-destructive");
|
|
2516
4166
|
if (action === "diff") {
|
|
2517
|
-
const
|
|
2518
|
-
if (
|
|
4167
|
+
const ops = await planSchemaDiff({ config, client, allowDestructive });
|
|
4168
|
+
if (json) printJson(io, { inSync: ops.length === 0, ops: opRows(ops) });
|
|
4169
|
+
else printOpsTable(io, ops);
|
|
4170
|
+
if (ops.length > 0) process.exitCode = 1;
|
|
2519
4171
|
return;
|
|
2520
4172
|
}
|
|
2521
4173
|
if (action === "generate") {
|
|
2522
|
-
await runSchemaGenerate({
|
|
4174
|
+
const path2 = await runSchemaGenerate({
|
|
2523
4175
|
config,
|
|
2524
4176
|
client,
|
|
2525
4177
|
dir,
|
|
2526
4178
|
slug: stringFlag(flags, "--slug"),
|
|
2527
|
-
io,
|
|
4179
|
+
io: chatter,
|
|
2528
4180
|
allowDestructive
|
|
2529
4181
|
});
|
|
4182
|
+
if (json) printJson(io, { path: path2 ?? null });
|
|
2530
4183
|
return;
|
|
2531
4184
|
}
|
|
2532
4185
|
if (action === "apply") {
|
|
@@ -2535,52 +4188,90 @@ async function runSchema(args, flags, io) {
|
|
|
2535
4188
|
"headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for schema apply \u2014 it's needed to backfill documents during a reindex."
|
|
2536
4189
|
);
|
|
2537
4190
|
}
|
|
2538
|
-
if (!await
|
|
2539
|
-
io
|
|
2540
|
-
return;
|
|
4191
|
+
if (!await confirm("Apply pending schema migrations?")) {
|
|
4192
|
+
return cancelled(io, flags);
|
|
2541
4193
|
}
|
|
2542
|
-
const result = await runSchemaApply({
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
4194
|
+
const result = await runSchemaApply({
|
|
4195
|
+
config,
|
|
4196
|
+
adapter,
|
|
4197
|
+
client,
|
|
4198
|
+
dir,
|
|
4199
|
+
allowDestructive,
|
|
4200
|
+
io: chatter
|
|
4201
|
+
});
|
|
4202
|
+
if (json) printJson(io, result);
|
|
4203
|
+
else
|
|
4204
|
+
io.stdout(
|
|
4205
|
+
`Applied ${result.appliedOps} op(s) across ${result.appliedFiles} migration file(s).`
|
|
4206
|
+
);
|
|
2550
4207
|
return;
|
|
2551
4208
|
}
|
|
2552
|
-
|
|
2553
|
-
|
|
4209
|
+
const status = await runSchemaStatus({ config, client, dir, io: chatter });
|
|
4210
|
+
if (json) printJson(io, status);
|
|
2554
4211
|
}
|
|
2555
4212
|
function parseIntFlag(flags, name) {
|
|
2556
4213
|
const raw = stringFlag(flags, name);
|
|
2557
4214
|
if (!raw) return void 0;
|
|
2558
4215
|
const value = Number(raw);
|
|
2559
4216
|
if (!Number.isInteger(value) || value <= 0) {
|
|
2560
|
-
throw new
|
|
4217
|
+
throw new UsageError(`${name} must be a positive integer`);
|
|
2561
4218
|
}
|
|
2562
4219
|
return value;
|
|
2563
4220
|
}
|
|
2564
|
-
async function runSync(args, flags, io) {
|
|
4221
|
+
async function runSync(args, flags, io, confirm) {
|
|
2565
4222
|
const action = args[0];
|
|
2566
|
-
|
|
2567
|
-
|
|
4223
|
+
const json = boolFlag(flags, "--json");
|
|
4224
|
+
const chatter = chatterIO(io, flags);
|
|
4225
|
+
if (action === "source") {
|
|
4226
|
+
await runSyncSource(args.slice(1), flags, {
|
|
4227
|
+
io,
|
|
4228
|
+
client: clientFromFlags(flags),
|
|
4229
|
+
confirm
|
|
4230
|
+
});
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
if (action === "index") {
|
|
4234
|
+
await runSyncIndex(args.slice(1), flags, {
|
|
4235
|
+
io,
|
|
4236
|
+
client: clientFromFlags(flags),
|
|
4237
|
+
confirm
|
|
4238
|
+
});
|
|
4239
|
+
return;
|
|
4240
|
+
}
|
|
4241
|
+
if (action === "status") {
|
|
4242
|
+
await runSyncStatus(flags, {
|
|
2568
4243
|
io,
|
|
4244
|
+
client: clientFromFlags(flags),
|
|
4245
|
+
confirm
|
|
4246
|
+
});
|
|
4247
|
+
return;
|
|
4248
|
+
}
|
|
4249
|
+
if (action === "init") {
|
|
4250
|
+
const cwd = process.cwd();
|
|
4251
|
+
const write = boolFlag(flags, "--write");
|
|
4252
|
+
const ddl = runSyncInit({
|
|
4253
|
+
io: json ? { stdout: () => void 0, stderr: io.stderr } : io,
|
|
2569
4254
|
table: stringFlag(flags, "--table"),
|
|
2570
|
-
write
|
|
2571
|
-
cwd
|
|
4255
|
+
write,
|
|
4256
|
+
cwd
|
|
2572
4257
|
});
|
|
4258
|
+
if (json) printJson(io, { ddl, path: write ? (0, import_path9.join)(cwd, OUTBOX_SQL_RELATIVE_PATH) : null });
|
|
2573
4259
|
return;
|
|
2574
4260
|
}
|
|
2575
4261
|
if (action === "worker") {
|
|
2576
4262
|
const scaffold = stringFlag(flags, "--scaffold");
|
|
2577
4263
|
if (scaffold) {
|
|
2578
|
-
if (scaffold !== "docker") throw new
|
|
2579
|
-
|
|
4264
|
+
if (scaffold !== "docker") throw new UsageError(`Unknown scaffold target: ${scaffold}`);
|
|
4265
|
+
const path2 = runSyncScaffoldDocker({
|
|
4266
|
+
cwd: process.cwd(),
|
|
4267
|
+
io: chatter,
|
|
4268
|
+
force: boolFlag(flags, "--force")
|
|
4269
|
+
});
|
|
4270
|
+
if (json) printJson(io, { path: path2 });
|
|
2580
4271
|
return;
|
|
2581
4272
|
}
|
|
2582
|
-
const
|
|
2583
|
-
const client = new HeadbandClient({ url, adminKey:
|
|
4273
|
+
const auth = authFromFlags(flags);
|
|
4274
|
+
const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
|
|
2584
4275
|
const { config, adapter } = await loadHeadbandConfig(process.cwd());
|
|
2585
4276
|
if (!adapter) {
|
|
2586
4277
|
throw new CliError(
|
|
@@ -2591,12 +4282,12 @@ async function runSync(args, flags, io) {
|
|
|
2591
4282
|
table: config.sync?.outboxTable
|
|
2592
4283
|
});
|
|
2593
4284
|
try {
|
|
2594
|
-
await runSyncWorkerCmd({
|
|
4285
|
+
const report = await runSyncWorkerCmd({
|
|
2595
4286
|
config,
|
|
2596
4287
|
adapter,
|
|
2597
4288
|
client,
|
|
2598
4289
|
outbox,
|
|
2599
|
-
io,
|
|
4290
|
+
io: chatter,
|
|
2600
4291
|
// Only override runSyncWorkerCmd's own default (loop unless
|
|
2601
4292
|
// --budget-ms is set) when the caller explicitly passed --loop —
|
|
2602
4293
|
// omitting it here keeps the bare `sync worker` default unchanged.
|
|
@@ -2610,34 +4301,36 @@ async function runSync(args, flags, io) {
|
|
|
2610
4301
|
config,
|
|
2611
4302
|
adapter,
|
|
2612
4303
|
client,
|
|
2613
|
-
io,
|
|
4304
|
+
io: chatter,
|
|
2614
4305
|
getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
|
|
2615
4306
|
});
|
|
2616
4307
|
}
|
|
2617
4308
|
});
|
|
4309
|
+
if (json) printJson(io, report);
|
|
2618
4310
|
} finally {
|
|
2619
4311
|
await close();
|
|
2620
4312
|
}
|
|
2621
4313
|
return;
|
|
2622
4314
|
}
|
|
2623
4315
|
if (action === "reconcile") {
|
|
2624
|
-
const
|
|
2625
|
-
const client = new HeadbandClient({ url, adminKey:
|
|
2626
|
-
const apiClient =
|
|
4316
|
+
const auth = authFromFlags(flags);
|
|
4317
|
+
const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
|
|
4318
|
+
const apiClient = createApiClient(auth.url, auth);
|
|
2627
4319
|
const { config, adapter } = await loadHeadbandConfig(process.cwd());
|
|
2628
4320
|
if (!adapter) {
|
|
2629
4321
|
throw new CliError(
|
|
2630
4322
|
"headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync reconcile."
|
|
2631
4323
|
);
|
|
2632
4324
|
}
|
|
2633
|
-
await runSyncReconcile({
|
|
4325
|
+
const rows = await runSyncReconcile({
|
|
2634
4326
|
config,
|
|
2635
4327
|
adapter,
|
|
2636
4328
|
client,
|
|
2637
|
-
io,
|
|
4329
|
+
io: chatter,
|
|
2638
4330
|
fix: boolFlag(flags, "--fix"),
|
|
2639
4331
|
getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
|
|
2640
4332
|
});
|
|
4333
|
+
if (json) printJson(io, { indexes: rows });
|
|
2641
4334
|
return;
|
|
2642
4335
|
}
|
|
2643
4336
|
if (action === "retry-dead-letters") {
|
|
@@ -2646,48 +4339,66 @@ async function runSync(args, flags, io) {
|
|
|
2646
4339
|
table: config.sync?.outboxTable
|
|
2647
4340
|
});
|
|
2648
4341
|
try {
|
|
2649
|
-
await runSyncRetryDeadLetters({ outbox, io });
|
|
4342
|
+
const requeued = await runSyncRetryDeadLetters({ outbox, io: chatter });
|
|
4343
|
+
if (json) printJson(io, { requeued });
|
|
2650
4344
|
} finally {
|
|
2651
4345
|
await close();
|
|
2652
4346
|
}
|
|
2653
4347
|
return;
|
|
2654
4348
|
}
|
|
2655
4349
|
printCommandHelp("sync", io);
|
|
2656
|
-
throw new
|
|
4350
|
+
throw new UsageError("Unknown sync command");
|
|
2657
4351
|
}
|
|
4352
|
+
var CLI_VERSION = true ? "0.3.0" : "dev";
|
|
2658
4353
|
async function run(argv, context = { io: defaultIO }) {
|
|
4354
|
+
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version")) {
|
|
4355
|
+
context.io.stdout(CLI_VERSION);
|
|
4356
|
+
return;
|
|
4357
|
+
}
|
|
2659
4358
|
const { flags, positional } = parseArgv(argv);
|
|
2660
4359
|
const command = positional[0];
|
|
2661
4360
|
const args = positional.slice(1);
|
|
2662
4361
|
if (boolFlag(flags, "--help") || command === "help") {
|
|
2663
|
-
if (command &&
|
|
4362
|
+
if (command === "sync" && args[0] === "source") context.io.stdout(SOURCE_USAGE);
|
|
4363
|
+
else if (command === "sync" && args[0] === "index") context.io.stdout(INDEX_USAGE);
|
|
4364
|
+
else if (command && command !== "help") printCommandHelp(command, context.io);
|
|
2664
4365
|
else printHelp(context.io);
|
|
2665
4366
|
return;
|
|
2666
4367
|
}
|
|
4368
|
+
const confirm = (message) => confirmOrSkip(message, flags, context.ask);
|
|
2667
4369
|
switch (command || "init") {
|
|
2668
4370
|
case "init":
|
|
4371
|
+
if (!boolFlag(flags, "--yes") && !stdinIsInteractive()) {
|
|
4372
|
+
throw new UsageError(
|
|
4373
|
+
"headband init asks questions, and stdin is not a terminal. Pass --yes to use the defaults.",
|
|
4374
|
+
"confirmation_required"
|
|
4375
|
+
);
|
|
4376
|
+
}
|
|
2669
4377
|
await init({ yes: boolFlag(flags, "--yes") });
|
|
2670
4378
|
return;
|
|
2671
4379
|
case "login":
|
|
2672
4380
|
await runLogin(flags, context.io);
|
|
2673
4381
|
return;
|
|
2674
4382
|
case "logout":
|
|
2675
|
-
await runLogout(flags, context.io);
|
|
4383
|
+
await runLogout(flags, context.io, confirm);
|
|
2676
4384
|
return;
|
|
2677
4385
|
case "whoami":
|
|
2678
4386
|
await runWhoami(flags, context.io);
|
|
2679
4387
|
return;
|
|
4388
|
+
case "profiles":
|
|
4389
|
+
await runProfiles(args, flags, context.io);
|
|
4390
|
+
return;
|
|
2680
4391
|
case "projects":
|
|
2681
|
-
await runProjects(args, flags, context.io);
|
|
4392
|
+
await runProjects(args, flags, context.io, confirm);
|
|
2682
4393
|
return;
|
|
2683
4394
|
case "keys":
|
|
2684
|
-
await runKeys(args, flags, context.io);
|
|
4395
|
+
await runKeys(args, flags, context.io, confirm);
|
|
2685
4396
|
return;
|
|
2686
4397
|
case "indexes":
|
|
2687
|
-
await runIndexes(args, flags, context.io);
|
|
4398
|
+
await runIndexes(args, flags, context.io, confirm);
|
|
2688
4399
|
return;
|
|
2689
4400
|
case "docs":
|
|
2690
|
-
await runDocs(args, flags, context
|
|
4401
|
+
await runDocs(args, flags, context);
|
|
2691
4402
|
return;
|
|
2692
4403
|
case "search":
|
|
2693
4404
|
await runSearch(args, flags, context.io);
|
|
@@ -2696,37 +4407,61 @@ async function run(argv, context = { io: defaultIO }) {
|
|
|
2696
4407
|
await runTasks(args, flags, context.io);
|
|
2697
4408
|
return;
|
|
2698
4409
|
case "schema":
|
|
2699
|
-
await runSchema(args, flags, context.io);
|
|
4410
|
+
await runSchema(args, flags, context.io, confirm);
|
|
2700
4411
|
return;
|
|
2701
4412
|
case "sync":
|
|
2702
|
-
await runSync(args, flags, context.io);
|
|
4413
|
+
await runSync(args, flags, context.io, confirm);
|
|
2703
4414
|
return;
|
|
2704
4415
|
case "instances":
|
|
2705
|
-
await runInstances(
|
|
2706
|
-
args,
|
|
2707
|
-
flags,
|
|
2708
|
-
context.io,
|
|
2709
|
-
clientFromFlags(flags),
|
|
2710
|
-
(message) => confirmOrSkip(message, flags)
|
|
2711
|
-
);
|
|
4416
|
+
await runInstances(args, flags, context.io, clientFromFlags(flags), confirm);
|
|
2712
4417
|
return;
|
|
2713
4418
|
default:
|
|
2714
4419
|
printHelp(context.io);
|
|
2715
|
-
throw new
|
|
4420
|
+
throw new UsageError(
|
|
4421
|
+
/^[a-z][a-z-]{0,31}$/.test(command) ? `Unknown command: ${command}` : "Unknown command."
|
|
4422
|
+
);
|
|
4423
|
+
}
|
|
4424
|
+
}
|
|
4425
|
+
async function main(argv, context = { io: defaultIO }) {
|
|
4426
|
+
const json = argv.includes("--json");
|
|
4427
|
+
try {
|
|
4428
|
+
await run(argv, context);
|
|
4429
|
+
return EXIT_CODES.ok;
|
|
4430
|
+
} catch (error) {
|
|
4431
|
+
if (json) context.io.stderr(JSON.stringify(errorPayload(error)));
|
|
4432
|
+
else
|
|
4433
|
+
context.io.stderr(
|
|
4434
|
+
error instanceof CliError ? error.message : error instanceof Error ? error.message : String(error)
|
|
4435
|
+
);
|
|
4436
|
+
return errorExitCode(error);
|
|
2716
4437
|
}
|
|
2717
4438
|
}
|
|
4439
|
+
function stdoutLineWriter(line) {
|
|
4440
|
+
if (process.stdout.write(`${line}
|
|
4441
|
+
`)) return;
|
|
4442
|
+
return new Promise((resolve) => process.stdout.once("drain", () => resolve()));
|
|
4443
|
+
}
|
|
2718
4444
|
function isDirectRun() {
|
|
2719
4445
|
return typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
|
|
2720
4446
|
}
|
|
2721
4447
|
if (isDirectRun()) {
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
4448
|
+
process.stdout.on("error", (error) => {
|
|
4449
|
+
if (error.code === "EPIPE") process.exit(EXIT_CODES.ok);
|
|
4450
|
+
throw error;
|
|
4451
|
+
});
|
|
4452
|
+
void main(process.argv.slice(2), { io: defaultIO, writeLine: stdoutLineWriter }).then((code) => {
|
|
4453
|
+
if (code === EXIT_CODES.ok) return;
|
|
4454
|
+
process.exitCode = code;
|
|
4455
|
+
setTimeout(() => process.exit(code), 2e3).unref();
|
|
2726
4456
|
});
|
|
2727
4457
|
}
|
|
2728
4458
|
// Annotate the CommonJS export names for ESM import in node:
|
|
2729
4459
|
0 && (module.exports = {
|
|
4460
|
+
CLI_VERSION,
|
|
4461
|
+
main,
|
|
2730
4462
|
parseArgv,
|
|
2731
|
-
|
|
4463
|
+
parseExpires,
|
|
4464
|
+
parsePermissions,
|
|
4465
|
+
run,
|
|
4466
|
+
stdinIsInteractive
|
|
2732
4467
|
});
|