@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.
Files changed (3) hide show
  1. package/README.md +334 -67
  2. package/dist/index.js +2136 -401
  3. 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
- run: () => run
37
+ parseExpires: () => parseExpires,
38
+ parsePermissions: () => parsePermissions,
39
+ run: () => run,
40
+ stdinIsInteractive: () => stdinIsInteractive
36
41
  });
37
42
  module.exports = __toCommonJS(index_exports);
38
- var import_fs8 = require("fs");
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.code = code;
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 path = getCredentialsPath();
275
- if (!(0, import_fs2.existsSync)(path)) {
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
- const parsed = JSON.parse((0, import_fs2.readFileSync)(path, "utf-8"));
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 ${path}`);
399
+ throw new CliError(`Could not read credentials at ${path2}`);
285
400
  }
286
401
  }
287
402
  function writeCredentials(credentials) {
288
- const path = getCredentialsPath();
289
- (0, import_fs2.mkdirSync)((0, import_path3.dirname)(path), { recursive: true, mode: 448 });
290
- (0, import_fs2.writeFileSync)(path, `${JSON.stringify(credentials, null, 2)}
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)(path, 384);
414
+ (0, import_fs2.chmodSync)(path2, 384);
296
415
  }
297
- function saveProfile(url, profile) {
416
+ function saveProfile(name, profile) {
417
+ validateProfileName(name);
298
418
  const credentials = readCredentials();
299
- credentials.profiles[normalizeUrl(url)] = profile;
419
+ credentials.profiles[name] = { ...profile, url: normalizeUrl(profile.url) };
420
+ credentials.current = name;
300
421
  writeCredentials(credentials);
301
422
  }
302
- function removeProfile(url) {
423
+ function removeProfile(name) {
303
424
  const credentials = readCredentials();
304
- const normalized = normalizeUrl(url);
305
- const existing = credentials.profiles[normalized];
306
- delete credentials.profiles[normalized];
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 resolveUrl(explicitUrl) {
318
- if (explicitUrl) return normalizeUrl(explicitUrl);
431
+ function setCurrentProfile(name) {
319
432
  const credentials = readCredentials();
320
- const urls = Object.keys(credentials.profiles);
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
- `Not logged in for ${resolvedUrl}. Run: headband login --url ${resolvedUrl}`
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 { url: resolvedUrl, profile };
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, path, init2 = {}) {
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 response = await fetch(`${normalizeUrl(baseUrl)}${path}`, {
355
- ...init2,
356
- headers,
357
- body: init2.body === void 0 || typeof init2.body === "string" ? init2.body : JSON.stringify(init2.body)
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 = (path, init2 = {}) => apiRequest(url, path, { ...init2, apiKey: profile.apiKey });
552
+ const request = (path2, init2 = {}) => apiRequest(url, path2, { ...init2, apiKey: profile.apiKey });
372
553
  return {
373
554
  url: normalizeUrl(url),
374
- get: (path) => request(path, { method: "GET" }),
375
- post: (path, body) => request(path, { method: "POST", body }),
376
- patch: (path, body) => request(path, { method: "PATCH", body }),
377
- delete: (path) => request(path, { method: "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 token = await runDeviceFlow(url, io, options);
442
- saveProfile(url, {
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(import_picocolors2.default.green(`Logged in to ${normalizeUrl(url)} as ${token.orgName} (${token.keyPrefix})`));
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 path = (0, import_path4.join)(cwd, name);
468
- if ((0, import_fs3.existsSync)(path)) return path;
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 path = findConfigFile(cwd);
477
- if (!path) {
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(path);
683
+ mod = await jiti.import(path2);
490
684
  } catch (err) {
491
685
  throw new CliError(
492
- `Failed to load ${path}: ${err instanceof Error ? err.message : String(err)}`
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(`${path} does not export a valid Headband sync config (missing "indexes").`);
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(path) {
508
- const content = (0, import_fs4.readFileSync)(path, "utf-8");
509
- const ext = (0, import_path5.extname)(path).toLowerCase();
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 CliError("--memory must look like 512m, 1g or 2048");
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 CliError("--memory must be a whole number of megabytes");
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 CliError("--lane must be serverless or always_on");
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 CliError(`${name} must be a positive integer`);
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 CliError("Missing engine id");
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 str = (name) => typeof flags[name] === "string" ? flags[name] : void 0;
796
- const path = (id, suffix = "") => `/v1/instances/${encodeURIComponent(id)}${suffix}`;
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 CliError("Engine must be one of: meilisearch, elasticsearch, typesense");
1103
+ throw new UsageError("Engine must be one of: meilisearch, elasticsearch, typesense, visor");
819
1104
  }
820
1105
  const body = { engine };
821
- const name = str("--name");
822
- const lane = str("--lane");
823
- const memory = str("--memory");
824
- const region = str("--region");
825
- const idle = str("--idle-timeout");
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(path(id));
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(path(id, "/wake"));
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(path(id, "/park"));
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(path(id));
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 = str("--client");
894
- if (which && !CLIENTS.includes(which)) throw new CliError("--client must be node or curl");
895
- const res = await client.get(path(id));
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 CliError("Unknown instances command");
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 path = (0, import_path6.join)(dir, fileName);
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)(path, JSON.stringify(file, null, 2));
990
- return path;
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 path = (0, import_path6.join)(dir, entry);
1004
- const raw = (0, import_fs5.readFileSync)(path, "utf-8");
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: ${path}`);
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 isRecord(v) {
1448
+ function isRecord2(v) {
1106
1449
  return typeof v === "object" && v !== null;
1107
1450
  }
1108
1451
  function parsePartialFailureItem(raw) {
1109
- const item = isRecord(raw) ? raw : {};
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 isRecord(result) && "partialFailure" in result ? { partialFailure: result.partialFailure } : {};
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, path, body, opts = {}) {
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}${path}`, {
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 path = (0, import_path7.join)(dir, APPLIED_MARKER_FILE);
1234
- if (!(0, import_fs6.existsSync)(path)) return 0;
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)(path, "utf-8"));
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 runSchemaDiff(deps) {
1589
+ async function planSchemaDiff(deps) {
1247
1590
  const applied = await deps.client.getSchemaState();
1248
- const ops = planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
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 path = generateMigrationFile(
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 ${path}`);
1611
+ deps.io.stdout(`Wrote ${path2}`);
1271
1612
  printOpsTable(deps.io, ops);
1272
- return path;
1613
+ return path2;
1273
1614
  }
1274
1615
  function isConflictError(err) {
1275
1616
  if (!(err instanceof Error)) return false;
1276
- const shaped = err;
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 path = (0, import_path8.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
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)(path, `${ddl}
2246
+ (0, import_fs7.writeFileSync)(path2, `${ddl}
1906
2247
  `);
1907
- deps.io.stdout(`Wrote ${path}`);
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 path = (0, import_path8.join)(deps.cwd, DOCKERFILE_NAME);
2031
- if ((0, import_fs7.existsSync)(path) && !deps.force) {
2032
- throw new CliError(`${DOCKERFILE_NAME} already exists at ${path}. Pass --force to overwrite.`);
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)(path, DOCKERFILE_CONTENT);
2035
- deps.io.stdout(`Wrote ${path}`);
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 path;
2379
+ return path2;
2039
2380
  }
2040
2381
 
2041
- // src/index.ts
2042
- var ENGINES2 = /* @__PURE__ */ new Set(["meilisearch", "typesense", "elasticsearch"]);
2043
- var KEY_TYPES = /* @__PURE__ */ new Set(["admin", "search"]);
2044
- var VALUE_FLAGS = /* @__PURE__ */ new Set([
2045
- "--url",
2046
- "--engine",
2047
- "--primary-key",
2048
- "--file",
2049
- "--index",
2050
- "--limit",
2051
- "--filter",
2052
- "--type",
2053
- "--project",
2054
- "--dir",
2055
- "--slug",
2056
- "--table",
2057
- "--scaffold",
2058
- "--budget-ms",
2059
- "--batch-size",
2060
- "--reconcile-every",
2061
- "--name",
2062
- "--lane",
2063
- "--memory",
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 stringFlag(flags, name) {
2112
- const value = flags[name];
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 boolFlag(flags, name) {
2116
- return flags[name] === true;
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 requiredFlag(flags, name) {
2119
- const value = stringFlag(flags, name);
2120
- if (!value) throw new CliError(`Missing required option: ${name}`);
2121
- return value;
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 parseLimit(flags) {
2124
- const raw = stringFlag(flags, "--limit");
2125
- if (!raw) return void 0;
2126
- const limit = Number(raw);
2127
- if (!Number.isInteger(limit) || limit <= 0) {
2128
- throw new CliError("--limit must be a positive integer");
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
- return limit;
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
- function validateEngine(engine) {
2133
- if (!ENGINES2.has(engine)) {
2134
- throw new CliError("Engine must be one of: meilisearch, typesense, elasticsearch");
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 validateKeyType(type) {
2139
- if (!KEY_TYPES.has(type)) {
2140
- throw new CliError("Key type must be one of: admin, search");
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
- return type;
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 encode(value) {
2145
- return encodeURIComponent(value);
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 clientFromFlags(flags) {
2148
- const { url, profile } = getProfile(stringFlag(flags, "--url"));
2149
- return createApiClient(url, profile);
2536
+ function looksLikePooler(host, port) {
2537
+ return POOLER_HOST_RE.test(host) || POOLER_PORTS.has(port);
2150
2538
  }
2151
- async function confirmOrSkip(message, flags) {
2152
- if (boolFlag(flags, "--yes")) return true;
2153
- const answer = await (0, import_prompts2.default)({
2154
- type: "confirm",
2155
- name: "confirmed",
2156
- message,
2157
- initial: false
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
- return answer.confirmed === true;
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 [--url <url>] [--yes]
2171
- whoami [--url <url>] [--json]
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 delete <id> [--yes]
2187
- instances connect <id> [--client <node|curl>]
2188
- docs push <file> --index <uid>
2189
- docs delete <docId> --index <uid>
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 [--url <url>]
2194
- schema generate [--slug <slug>] [--dir <dir>] [--url <url>]
2195
- schema apply [--allow-destructive] [--dir <dir>] [--yes] [--url <url>]
2196
- schema status [--dir <dir>] [--url <url>]
2197
- sync init [--table <name>] [--write]
2198
- sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--url <url>]
2199
- sync worker --scaffold docker [--force]
2200
- sync reconcile [--fix] [--url <url>]
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
- --url <url> Select profile URL (default: stored single profile, else ${DEFAULT_URL})
2205
- --json Print machine-readable JSON for supported commands
2206
- --yes Skip confirmation prompts
2207
- --help Show help`);
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 [--url <url>] [--yes]",
2213
- whoami: "Usage: headband whoami [--url <url>] [--json]",
2214
- projects: "Usage:\n headband projects list [--json]\n headband projects create <name> --engine <meilisearch|typesense|elasticsearch>",
2215
- keys: "Usage:\n headband keys list [--json]\n headband keys create <name> --type <admin|search> [--project <id>] [--json]\n headband keys revoke <id> [--yes]",
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>\n headband docs delete <docId> --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
- await login(stringFlag(flags, "--url") || DEFAULT_URL, io);
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 { url: selectedUrl, profile } = getProfile(stringFlag(flags, "--url"));
2243
- io.stdout(
2244
- `Removing local credentials for ${selectedUrl}. Revoke this key with headband keys revoke ${profile.keyId} or in the dashboard if it should stop working.`
2245
- );
2246
- if (!boolFlag(flags, "--yes")) {
2247
- const shouldRevoke = await confirmOrSkip(
2248
- "Revoke this CLI key server-side before logging out?",
2249
- flags
2250
- );
2251
- if (shouldRevoke) {
2252
- const client = createApiClient(selectedUrl, profile);
2253
- await client.delete(`/v1/keys/${encode(profile.keyId)}`);
2254
- io.stdout("Revoked CLI key.");
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(selectedUrl);
2258
- io.stdout(`Logged out of ${selectedUrl}.`);
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 { url, profile } = getProfile(stringFlag(flags, "--url"));
2262
- const client = createApiClient(url, profile);
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
- orgName: profile.orgName,
2267
- orgId: profile.orgId,
2268
- keyPrefix: profile.keyPrefix,
2269
- keyId: profile.keyId,
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(io, [info], ["url", "orgName", "keyPrefix", "keyId", "valid"]);
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 CliError("Unknown projects command");
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
- async function runKeys(args, flags, io) {
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 (!id) throw new CliError("Missing key id");
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 CliError("Unknown keys command");
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 (!uid) throw new CliError("Missing index uid");
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, import_fs8.readFileSync)(file, "utf-8"));
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 CliError("Unknown indexes command");
4033
+ throw new UsageError("Unknown indexes command");
2442
4034
  }
2443
- async function runDocs(args, flags, io) {
2444
- const action = args[0];
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
- io.stdout(`Pushed ${count} documents.`);
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 CliError("Unknown docs command");
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 (!index) throw new CliError("Missing search index");
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 printJson(io, response);
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 CliError("Unknown tasks command");
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 { url, profile } = getProfile(stringFlag(flags, "--url"));
2513
- const client = new HeadbandClient({ url, adminKey: profile.apiKey });
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 code = await runSchemaDiff({ config, client, io, allowDestructive });
2518
- if (code !== 0) process.exitCode = code;
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 confirmOrSkip("Apply pending schema migrations?", flags)) {
2539
- io.stdout("Cancelled.");
2540
- return;
4191
+ if (!await confirm("Apply pending schema migrations?")) {
4192
+ return cancelled(io, flags);
2541
4193
  }
2542
- const result = await runSchemaApply({ config, adapter, client, dir, allowDestructive, io });
2543
- io.stdout(
2544
- `Applied ${result.appliedOps} op(s) across ${result.appliedFiles} migration file(s).`
2545
- );
2546
- return;
2547
- }
2548
- if (action === "status") {
2549
- await runSchemaStatus({ config, client, dir, io });
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
- printCommandHelp("schema", io);
2553
- throw new CliError("Unknown schema command");
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 CliError(`${name} must be a positive integer`);
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
- if (action === "init") {
2567
- runSyncInit({
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: boolFlag(flags, "--write"),
2571
- cwd: process.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 CliError(`Unknown scaffold target: ${scaffold}`);
2579
- runSyncScaffoldDocker({ cwd: process.cwd(), io, force: boolFlag(flags, "--force") });
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 { url, profile } = getProfile(stringFlag(flags, "--url"));
2583
- const client = new HeadbandClient({ url, adminKey: profile.apiKey });
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 { url, profile } = getProfile(stringFlag(flags, "--url"));
2625
- const client = new HeadbandClient({ url, adminKey: profile.apiKey });
2626
- const apiClient = clientFromFlags(flags);
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 CliError("Unknown sync command");
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 && command !== "help") printCommandHelp(command, context.io);
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.io);
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 CliError(`Unknown command: ${command}`);
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
- run(process.argv.slice(2)).catch((error) => {
2723
- const message = error instanceof ApiError || error instanceof CliError ? error.message : String(error);
2724
- defaultIO.stderr(message);
2725
- process.exit(1);
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
- run
4463
+ parseExpires,
4464
+ parsePermissions,
4465
+ run,
4466
+ stdinIsInteractive
2732
4467
  });