@headband/cli 0.1.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 +412 -24
  2. package/dist/index.js +4279 -71
  3. package/package.json +8 -4
package/dist/index.js CHANGED
@@ -6,6 +6,10 @@ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
6
  var __getOwnPropNames = Object.getOwnPropertyNames;
7
7
  var __getProtoOf = Object.getPrototypeOf;
8
8
  var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __export = (target, all) => {
10
+ for (var name in all)
11
+ __defProp(target, name, { get: all[name], enumerable: true });
12
+ };
9
13
  var __copyProps = (to, from, except, desc) => {
10
14
  if (from && typeof from === "object" || typeof from === "function") {
11
15
  for (let key of __getOwnPropNames(from))
@@ -22,6 +26,25 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
22
26
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
23
27
  mod
24
28
  ));
29
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
30
+
31
+ // src/index.ts
32
+ var index_exports = {};
33
+ __export(index_exports, {
34
+ CLI_VERSION: () => CLI_VERSION,
35
+ main: () => main,
36
+ parseArgv: () => parseArgv,
37
+ parseExpires: () => parseExpires,
38
+ parsePermissions: () => parsePermissions,
39
+ run: () => run,
40
+ stdinIsInteractive: () => stdinIsInteractive
41
+ });
42
+ module.exports = __toCommonJS(index_exports);
43
+ var import_fs10 = require("fs");
44
+ var import_fs11 = require("fs");
45
+ var import_path9 = require("path");
46
+ var import_prompts2 = __toESM(require("prompts"));
47
+ var import_picocolors3 = __toESM(require("picocolors"));
25
48
 
26
49
  // src/init.ts
27
50
  var import_prompts = __toESM(require("prompts"));
@@ -32,6 +55,19 @@ var import_path2 = require("path");
32
55
  var import_fs = require("fs");
33
56
  var import_child_process = require("child_process");
34
57
  var import_path = require("path");
58
+ function isPublicSearchKey(value) {
59
+ return /^hb_src_[A-Za-z0-9_-]+$/.test(value.trim());
60
+ }
61
+ function validatePublicSearchKey(value) {
62
+ if (!value.trim()) return "Search API key is required";
63
+ if (value.startsWith("hb_adm_")) {
64
+ return "Admin keys must stay server-side. Create or paste an hb_src_ search key for browser config.";
65
+ }
66
+ if (!isPublicSearchKey(value)) {
67
+ return "Search key must start with hb_src_";
68
+ }
69
+ return true;
70
+ }
35
71
  function detectPackageManager(cwd) {
36
72
  if ((0, import_fs.existsSync)((0, import_path.join)(cwd, "pnpm-lock.yaml"))) return "pnpm";
37
73
  if ((0, import_fs.existsSync)((0, import_path.join)(cwd, "yarn.lock"))) return "yarn";
@@ -50,8 +86,8 @@ function getInstallCommand(pm, pkg) {
50
86
  return `npm install ${pkg}`;
51
87
  }
52
88
  }
53
- function runCommand(command2, cwd) {
54
- (0, import_child_process.execSync)(command2, { cwd, stdio: "inherit" });
89
+ function runCommand(command, cwd) {
90
+ (0, import_child_process.execSync)(command, { cwd, stdio: "inherit" });
55
91
  }
56
92
  function hasSrcDir(cwd) {
57
93
  return (0, import_fs.existsSync)((0, import_path.join)(cwd, "src"));
@@ -64,6 +100,10 @@ function writeFileSafe(filePath, content) {
64
100
  (0, import_fs.writeFileSync)(filePath, content, "utf-8");
65
101
  }
66
102
  function generateConfig(opts) {
103
+ const keyValidation = validatePublicSearchKey(opts.searchKey);
104
+ if (keyValidation !== true) {
105
+ throw new Error(keyValidation);
106
+ }
67
107
  return `export const headbandConfig = {
68
108
  url: "${opts.url}",
69
109
  searchKey: process.env.NEXT_PUBLIC_HEADBAND_KEY || "${opts.searchKey}",
@@ -127,15 +167,9 @@ async function init(options = {}) {
127
167
  {
128
168
  type: "text",
129
169
  name: "searchKey",
130
- message: "Paste your search API key",
131
- hint: "hb_src_... or hb_adm_...",
132
- validate: (v) => {
133
- if (!v) return "API key is required";
134
- if (!v.startsWith("hb_src_") && !v.startsWith("hb_adm_")) {
135
- return "Key must start with hb_src_ or hb_adm_";
136
- }
137
- return true;
138
- }
170
+ message: "Paste your browser-safe search API key",
171
+ hint: "hb_src_... only. Keep hb_adm_ admin keys server-side.",
172
+ validate: validatePublicSearchKey
139
173
  },
140
174
  {
141
175
  type: "text",
@@ -154,9 +188,7 @@ async function init(options = {}) {
154
188
  }
155
189
  const pm = detectPackageManager(cwd);
156
190
  console.log();
157
- console.log(
158
- import_picocolors.default.dim(` Detected package manager: ${import_picocolors.default.bold(pm)}`)
159
- );
191
+ console.log(import_picocolors.default.dim(` Detected package manager: ${import_picocolors.default.bold(pm)}`));
160
192
  console.log();
161
193
  console.log(import_picocolors.default.cyan(" Installing @headband/react..."));
162
194
  console.log();
@@ -165,21 +197,13 @@ async function init(options = {}) {
165
197
  runCommand(installCmd, cwd);
166
198
  } catch {
167
199
  console.log();
168
- console.log(
169
- import_picocolors.default.yellow(
170
- " Could not install @headband/react automatically."
171
- )
172
- );
173
- console.log(
174
- import_picocolors.default.dim(
175
- ` Run manually: ${getInstallCommand(pm, "@headband/react")}`
176
- )
177
- );
200
+ console.log(import_picocolors.default.yellow(" Could not install @headband/react automatically."));
201
+ console.log(import_picocolors.default.dim(` Run manually: ${getInstallCommand(pm, "@headband/react")}`));
178
202
  }
179
203
  const useSrc = hasSrcDir(cwd);
180
204
  const componentsDir = useSrc ? (0, import_path2.join)(cwd, "src", "components") : (0, import_path2.join)(cwd, "components");
181
205
  const configImportPath = useSrc ? "../headband.config" : "../headband.config";
182
- const configPath = (0, import_path2.join)(cwd, "headband.config.ts");
206
+ const configPath = useSrc ? (0, import_path2.join)(cwd, "src", "headband.config.ts") : (0, import_path2.join)(cwd, "headband.config.ts");
183
207
  const configContent = generateConfig({ url, searchKey, defaultIndex });
184
208
  writeFileSafe(configPath, configContent);
185
209
  const componentPath = (0, import_path2.join)(componentsDir, "headband-search.tsx");
@@ -192,68 +216,4252 @@ async function init(options = {}) {
192
216
  console.log(import_picocolors.default.green(import_picocolors.default.bold(" Headband is ready!")));
193
217
  console.log();
194
218
  console.log(import_picocolors.default.dim(" Created files:"));
195
- console.log(` ${import_picocolors.default.cyan("headband.config.ts")} \u2014 search configuration`);
219
+ console.log(
220
+ ` ${import_picocolors.default.cyan(useSrc ? "src/headband.config.ts" : "headband.config.ts")} \u2014 search configuration`
221
+ );
196
222
  console.log(
197
223
  ` ${import_picocolors.default.cyan(
198
224
  useSrc ? "src/components/headband-search.tsx" : "components/headband-search.tsx"
199
225
  )} \u2014 example search component`
200
226
  );
201
227
  console.log();
202
- console.log(import_picocolors.default.dim(" Recommended: add your API key to .env.local:"));
228
+ console.log(import_picocolors.default.dim(" Recommended: add your browser-safe search key to .env.local:"));
203
229
  console.log();
204
- console.log(
205
- import_picocolors.default.yellow(` NEXT_PUBLIC_HEADBAND_KEY=${searchKey}`)
206
- );
230
+ console.log(import_picocolors.default.yellow(` NEXT_PUBLIC_HEADBAND_KEY=${searchKey}`));
207
231
  console.log();
208
232
  console.log(import_picocolors.default.dim(" Next steps:"));
209
233
  console.log(` 1. Import ${import_picocolors.default.cyan("<HeadbandSearch />")} into a page`);
210
- console.log(
211
- ` 2. Move your API key to ${import_picocolors.default.cyan(".env.local")} for safety`
234
+ console.log(` 2. Keep admin keys server-side; only expose ${import_picocolors.default.cyan("hb_src_")} keys here`);
235
+ console.log(` 3. Check the docs at ${import_picocolors.default.cyan("https://headband.dev/docs")}`);
236
+ console.log();
237
+ }
238
+
239
+ // src/auth.ts
240
+ var import_child_process2 = require("child_process");
241
+ var import_os2 = require("os");
242
+ var import_picocolors2 = __toESM(require("picocolors"));
243
+
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
+ }
267
+ var CliError = class extends Error {
268
+ constructor(message, options = {}) {
269
+ super(message);
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";
285
+ }
286
+ };
287
+ var ApiError = class extends CliError {
288
+ constructor(message, status, code, details) {
289
+ super(message, { exitCode: exitCodeForStatus(status), code });
290
+ this.name = "ApiError";
291
+ this.status = status;
292
+ this.details = details;
293
+ }
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
+ }
318
+ function partialFailureSummary(failure) {
319
+ const first = failure.errors[0];
320
+ if (!first) return failure.message;
321
+ const id = first.id === null ? "a document" : `document "${first.id}"`;
322
+ return `${failure.message} (first: ${id}${first.reason ? `: ${first.reason}` : ""})`;
323
+ }
324
+
325
+ // src/config-store.ts
326
+ var import_fs2 = require("fs");
327
+ var import_path3 = require("path");
328
+ var import_os = require("os");
329
+ var DEFAULT_URL = "https://headband.dev";
330
+ var DEFAULT_PROFILE = "default";
331
+ function normalizeUrl(url) {
332
+ return url.replace(/\/+$/, "");
333
+ }
334
+ function getConfigDir() {
335
+ return process.env.XDG_CONFIG_HOME ? (0, import_path3.join)(process.env.XDG_CONFIG_HOME, "headband") : (0, import_path3.join)((0, import_os.homedir)(), ".config", "headband");
336
+ }
337
+ function getCredentialsPath() {
338
+ return (0, import_path3.join)(getConfigDir(), "credentials.json");
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
+ }
391
+ function readCredentials() {
392
+ const path2 = getCredentialsPath();
393
+ if (!(0, import_fs2.existsSync)(path2)) {
394
+ return { version: 2, profiles: {} };
395
+ }
396
+ try {
397
+ return migrateCredentials(JSON.parse((0, import_fs2.readFileSync)(path2, "utf-8")));
398
+ } catch {
399
+ throw new CliError(`Could not read credentials at ${path2}`);
400
+ }
401
+ }
402
+ function writeCredentials(credentials) {
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)}
410
+ `, {
411
+ encoding: "utf-8",
412
+ mode: 384
413
+ });
414
+ (0, import_fs2.chmodSync)(path2, 384);
415
+ }
416
+ function saveProfile(name, profile) {
417
+ validateProfileName(name);
418
+ const credentials = readCredentials();
419
+ credentials.profiles[name] = { ...profile, url: normalizeUrl(profile.url) };
420
+ credentials.current = name;
421
+ writeCredentials(credentials);
422
+ }
423
+ function removeProfile(name) {
424
+ const credentials = readCredentials();
425
+ const existing = credentials.profiles[name];
426
+ delete credentials.profiles[name];
427
+ if (credentials.current === name) delete credentials.current;
428
+ writeCredentials(credentials);
429
+ return existing;
430
+ }
431
+ function setCurrentProfile(name) {
432
+ const credentials = readCredentials();
433
+ const profile = credentials.profiles[name];
434
+ if (!profile) {
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.`
493
+ );
494
+ }
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
+ };
502
+ }
503
+
504
+ // src/api.ts
505
+ async function readJson(response) {
506
+ const text = await response.text();
507
+ if (!text) return {};
508
+ try {
509
+ return JSON.parse(text);
510
+ } catch {
511
+ return text;
512
+ }
513
+ }
514
+ async function apiRequest(baseUrl, path2, init2 = {}) {
515
+ const headers = new Headers(init2.headers);
516
+ headers.set("accept", "application/json");
517
+ if (init2.body !== void 0 && !headers.has("content-type")) {
518
+ headers.set("content-type", "application/json");
519
+ }
520
+ if (init2.apiKey) {
521
+ headers.set("authorization", `Bearer ${init2.apiKey}`);
522
+ }
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
+ }
539
+ const body = await readJson(response);
540
+ if (!response.ok) {
541
+ const errorBody = body && typeof body === "object" ? body : {};
542
+ throw new ApiError(
543
+ errorBody.error || `Request failed with status ${response.status}`,
544
+ response.status,
545
+ errorBody.code,
546
+ body
547
+ );
548
+ }
549
+ return body;
550
+ }
551
+ function createApiClient(url, profile) {
552
+ const request = (path2, init2 = {}) => apiRequest(url, path2, { ...init2, apiKey: profile.apiKey });
553
+ return {
554
+ url: normalizeUrl(url),
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" })
559
+ };
560
+ }
561
+
562
+ // src/auth.ts
563
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
564
+ function openBrowser(url) {
565
+ try {
566
+ if (process.platform === "darwin") {
567
+ (0, import_child_process2.spawn)("open", [url], { detached: true, stdio: "ignore" }).unref();
568
+ } else if (process.platform === "win32") {
569
+ (0, import_child_process2.spawn)("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
570
+ } else {
571
+ (0, import_child_process2.spawn)("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
572
+ }
573
+ } catch {
574
+ }
575
+ }
576
+ async function runDeviceFlow(url, io, options = {}) {
577
+ const baseUrl = normalizeUrl(url);
578
+ const sleep = options.sleep || defaultSleep;
579
+ const now = options.now || Date.now;
580
+ const device = await apiRequest(baseUrl, "/api/cli/device", {
581
+ method: "POST",
582
+ body: { name: options.deviceName || (0, import_os2.hostname)() }
583
+ });
584
+ io.stdout();
585
+ io.stdout(import_picocolors2.default.bold("Headband login"));
586
+ io.stdout(`Code: ${import_picocolors2.default.bold(import_picocolors2.default.cyan(device.data.userCode))}`);
587
+ io.stdout(`Open: ${device.data.verificationUriComplete}`);
588
+ io.stdout();
589
+ (options.openBrowser || openBrowser)(device.data.verificationUriComplete);
590
+ let interval = device.data.interval;
591
+ const deadline = now() + device.data.expiresIn * 1e3;
592
+ while (now() < deadline) {
593
+ await sleep(interval * 1e3);
594
+ try {
595
+ const token = await apiRequest(baseUrl, "/api/cli/token", {
596
+ method: "POST",
597
+ body: { deviceCode: device.data.deviceCode }
598
+ });
599
+ return token.data;
600
+ } catch (error) {
601
+ if (!(error instanceof ApiError)) throw error;
602
+ if (error.code === "authorization_pending") continue;
603
+ if (error.code === "slow_down") {
604
+ interval += 5;
605
+ continue;
606
+ }
607
+ if (error.code === "access_denied") {
608
+ throw new CliError("Login denied.");
609
+ }
610
+ if (error.code === "expired_token") {
611
+ throw new CliError("Login expired. Re-run headband login.");
612
+ }
613
+ if (error.code === "invalid_device_code") {
614
+ throw new CliError("Invalid device code. Re-run headband login.");
615
+ }
616
+ throw error;
617
+ }
618
+ }
619
+ throw new CliError("Login expired. Re-run headband login.");
620
+ }
621
+ async function login(url, io, options = {}) {
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,
627
+ apiKey: token.apiKey,
628
+ keyId: token.keyId,
629
+ keyPrefix: token.keyPrefix,
630
+ orgId: token.orgId,
631
+ orgName: token.orgName,
632
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
633
+ });
634
+ io.stdout(
635
+ import_picocolors2.default.green(`Logged in to ${baseUrl} as ${token.orgName} (${token.keyPrefix}), profile ${profile}`)
212
636
  );
213
- console.log(
214
- ` 3. Check the docs at ${import_picocolors.default.cyan("https://headband.dev/docs")}`
637
+ return {
638
+ profile,
639
+ url: baseUrl,
640
+ orgId: token.orgId,
641
+ orgName: token.orgName,
642
+ keyId: token.keyId,
643
+ keyPrefix: token.keyPrefix
644
+ };
645
+ }
646
+
647
+ // src/config-load.ts
648
+ var import_fs3 = require("fs");
649
+ var import_path4 = require("path");
650
+ var import_url = require("url");
651
+ var import_jiti = require("jiti");
652
+ var CONFIG_FILENAMES = [
653
+ "headband.config.ts",
654
+ "headband.config.mts",
655
+ "headband.config.js",
656
+ "headband.config.mjs",
657
+ "headband.config.cjs"
658
+ ];
659
+ function findConfigFile(cwd) {
660
+ for (const name of CONFIG_FILENAMES) {
661
+ const path2 = (0, import_path4.join)(cwd, name);
662
+ if ((0, import_fs3.existsSync)(path2)) return path2;
663
+ }
664
+ return void 0;
665
+ }
666
+ function isSyncConfig(value) {
667
+ return typeof value === "object" && value !== null && Array.isArray(value.indexes);
668
+ }
669
+ async function loadHeadbandConfig(cwd) {
670
+ const path2 = findConfigFile(cwd);
671
+ if (!path2) {
672
+ throw new CliError(
673
+ `No headband.config.ts found in ${cwd}. Run "headband init" or create one that exports a Headband sync config.`
674
+ );
675
+ }
676
+ const jiti = (0, import_jiti.createJiti)((0, import_url.pathToFileURL)((0, import_path4.join)(cwd, "headband-cli.js")).href, {
677
+ interopDefault: true,
678
+ moduleCache: false,
679
+ fsCache: false
680
+ });
681
+ let mod;
682
+ try {
683
+ mod = await jiti.import(path2);
684
+ } catch (err) {
685
+ throw new CliError(
686
+ `Failed to load ${path2}: ${err instanceof Error ? err.message : String(err)}`
687
+ );
688
+ }
689
+ const candidate = mod.default ?? mod;
690
+ if (!isSyncConfig(candidate)) {
691
+ throw new CliError(`${path2} does not export a valid Headband sync config (missing "indexes").`);
692
+ }
693
+ const adapter = mod.adapter;
694
+ return { config: candidate, adapter, path: path2 };
695
+ }
696
+
697
+ // src/docs.ts
698
+ var import_path5 = require("path");
699
+ var import_fs4 = require("fs");
700
+ var DOCS_BATCH_SIZE = 1e3;
701
+ function parseDocumentsFile(path2) {
702
+ const content = (0, import_fs4.readFileSync)(path2, "utf-8");
703
+ const ext = (0, import_path5.extname)(path2).toLowerCase();
704
+ if (ext === ".jsonl") {
705
+ return content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
706
+ }
707
+ if (ext === ".json") {
708
+ const parsed = JSON.parse(content);
709
+ if (!Array.isArray(parsed)) {
710
+ throw new CliError("JSON document file must contain an array.");
711
+ }
712
+ return parsed;
713
+ }
714
+ throw new CliError("Document file must be .json or .jsonl.");
715
+ }
716
+ async function pushDocuments(client, index, documents, io) {
717
+ let pushed = 0;
718
+ for (let offset = 0; offset < documents.length; offset += DOCS_BATCH_SIZE) {
719
+ const batch = documents.slice(offset, offset + DOCS_BATCH_SIZE);
720
+ await client.post("/v1/bulk", { index, documents: batch });
721
+ pushed += batch.length;
722
+ io.stdout(`Pushed ${pushed}/${documents.length} documents`);
723
+ }
724
+ return pushed;
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
+ }
769
+
770
+ // ../../src/lib/density/catalog-constants.ts
771
+ function formatMemory(mb) {
772
+ return mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
773
+ }
774
+
775
+ // ../../src/lib/density/snippets.ts
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
+ }
817
+ function connectSnippets(engine, endpoint) {
818
+ if (engine === "visor") return visorSnippets(endpoint);
819
+ if (engine === "elasticsearch") {
820
+ return {
821
+ packageName: "@elastic/elasticsearch",
822
+ // Client 9 sends a version 9 Accept header, which an 8.x server refuses.
823
+ install: "npm install @elastic/elasticsearch@8",
824
+ node: [
825
+ 'import { Client } from "@elastic/elasticsearch";',
826
+ "",
827
+ "const client = new Client({",
828
+ ` node: "${endpoint}",`,
829
+ " auth: { apiKey: process.env.HEADBAND_API_KEY },",
830
+ "});",
831
+ "",
832
+ `// ${ADMIN_NOTE}`,
833
+ 'if (!(await client.indices.exists({ index: "movies" }))) {',
834
+ ' await client.indices.create({ index: "movies" });',
835
+ "}",
836
+ 'await client.index({ index: "movies", id: "1", document: { title: "The Matrix" }, refresh: true });',
837
+ "",
838
+ 'const results = await client.search({ index: "movies", query: { match: { title: "matrix" } } });',
839
+ "console.log(results.hits.hits);"
840
+ ].join("\n"),
841
+ curl: [
842
+ `# ${ADMIN_NOTE}`,
843
+ "# On a second run this answers that the index already exists. That is fine.",
844
+ `curl -X PUT "${endpoint}/movies" \\`,
845
+ ' -H "Authorization: ApiKey $HEADBAND_API_KEY"',
846
+ "",
847
+ `curl -X PUT "${endpoint}/movies/_doc/1?refresh=true" \\`,
848
+ ' -H "Authorization: ApiKey $HEADBAND_API_KEY" \\',
849
+ ' -H "Content-Type: application/json" \\',
850
+ ` -d '{"title":"The Matrix"}'`,
851
+ "",
852
+ `curl "${endpoint}/movies/_search" \\`,
853
+ ' -H "Authorization: ApiKey $HEADBAND_API_KEY" \\',
854
+ ' -H "Content-Type: application/json" \\',
855
+ ` -d '{"query":{"match":{"title":"matrix"}}}'`
856
+ ].join("\n")
857
+ };
858
+ }
859
+ if (engine === "typesense") {
860
+ let url;
861
+ try {
862
+ url = new URL(endpoint);
863
+ } catch {
864
+ url = { hostname: endpoint, protocol: "https:", port: "" };
865
+ }
866
+ const protocol = url.protocol.replace(":", "");
867
+ const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
868
+ return {
869
+ packageName: "typesense",
870
+ install: "npm install typesense",
871
+ node: [
872
+ 'import Typesense from "typesense";',
873
+ "",
874
+ "const client = new Typesense.Client({",
875
+ ` nodes: [{ host: "${url.hostname}", port: ${port}, protocol: "${protocol}" }],`,
876
+ " apiKey: process.env.HEADBAND_API_KEY,",
877
+ "});",
878
+ "",
879
+ `// ${ADMIN_NOTE}`,
880
+ "// A 409 means the collection exists from an earlier run.",
881
+ "await client.collections().create({",
882
+ ' name: "movies",',
883
+ ' fields: [{ name: "title", type: "string" }],',
884
+ "}).catch((err) => {",
885
+ " if (err.httpStatus !== 409) throw err;",
886
+ "});",
887
+ 'await client.collections("movies").documents().upsert({ id: "1", title: "The Matrix" });',
888
+ "",
889
+ 'const results = await client.collections("movies").documents().search({ q: "matrix", query_by: "title" });',
890
+ "console.log(results.hits);"
891
+ ].join("\n"),
892
+ curl: [
893
+ `# ${ADMIN_NOTE}`,
894
+ "# On a second run this answers that the collection already exists. That is fine.",
895
+ `curl "${endpoint}/collections" \\`,
896
+ ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY" \\',
897
+ ' -H "Content-Type: application/json" \\',
898
+ ` -d '{"name":"movies","fields":[{"name":"title","type":"string"}]}'`,
899
+ "",
900
+ `curl "${endpoint}/collections/movies/documents?action=upsert" \\`,
901
+ ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY" \\',
902
+ ' -H "Content-Type: application/json" \\',
903
+ ` -d '{"id":"1","title":"The Matrix"}'`,
904
+ "",
905
+ `curl "${endpoint}/collections/movies/documents/search?q=matrix&query_by=title" \\`,
906
+ ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY"'
907
+ ].join("\n")
908
+ };
909
+ }
910
+ return {
911
+ packageName: "meilisearch",
912
+ install: "npm install meilisearch",
913
+ node: [
914
+ 'import { Meilisearch } from "meilisearch";',
915
+ "",
916
+ "const client = new Meilisearch({",
917
+ ` host: "${endpoint}",`,
918
+ " apiKey: process.env.HEADBAND_API_KEY,",
919
+ "});",
920
+ "",
921
+ `// ${ADMIN_NOTE}`,
922
+ "// waitTask() waits until Meilisearch has applied the change. On a second run",
923
+ "// the create task fails because the index exists, which is fine.",
924
+ 'await client.createIndex("movies", { primaryKey: "id" }).waitTask();',
925
+ 'await client.index("movies").addDocuments([{ id: 1, title: "The Matrix" }]).waitTask();',
926
+ "",
927
+ 'const results = await client.index("movies").search("matrix");',
928
+ "console.log(results.hits);"
929
+ ].join("\n"),
930
+ curl: [
931
+ `# ${ADMIN_NOTE}`,
932
+ "# On a second run this task fails because the index already exists. That is fine.",
933
+ `curl "${endpoint}/indexes" \\`,
934
+ ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
935
+ ' -H "Content-Type: application/json" \\',
936
+ ` -d '{"uid":"movies","primaryKey":"id"}'`,
937
+ "",
938
+ `curl "${endpoint}/indexes/movies/documents" \\`,
939
+ ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
940
+ ' -H "Content-Type: application/json" \\',
941
+ ` -d '[{"id":1,"title":"The Matrix"}]'`,
942
+ "",
943
+ "# Meilisearch applies writes in the background. Give it a moment.",
944
+ "sleep 1",
945
+ "",
946
+ `curl "${endpoint}/indexes/movies/search" \\`,
947
+ ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
948
+ ' -H "Content-Type: application/json" \\',
949
+ ` -d '{"q":"matrix"}'`
950
+ ].join("\n")
951
+ };
952
+ }
953
+
954
+ // src/output.ts
955
+ var defaultIO = {
956
+ stdout: (message = "") => console.log(message),
957
+ stderr: (message = "") => console.error(message)
958
+ };
959
+ function printJson(io, value) {
960
+ io.stdout(JSON.stringify(value, null, 2));
961
+ }
962
+ function formatTable(rows, columns) {
963
+ if (rows.length === 0) return "No results.";
964
+ const selectedColumns = columns || Object.keys(rows[0]);
965
+ const widths = selectedColumns.map(
966
+ (column) => Math.max(
967
+ column.length,
968
+ ...rows.map(
969
+ (row) => String(row[column] === null || row[column] === void 0 ? "" : row[column]).length
970
+ )
971
+ )
215
972
  );
216
- console.log();
973
+ const renderRow = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd();
974
+ return [
975
+ renderRow(selectedColumns),
976
+ renderRow(widths.map((width) => "-".repeat(width))),
977
+ ...rows.map(
978
+ (row) => renderRow(
979
+ selectedColumns.map(
980
+ (column) => String(row[column] === null || row[column] === void 0 ? "" : row[column])
981
+ )
982
+ )
983
+ )
984
+ ].join("\n");
985
+ }
986
+ function printTable(io, rows, columns) {
987
+ io.stdout(formatTable(rows, columns));
988
+ }
989
+ function summarizeHit(hit) {
990
+ const result = {};
991
+ for (const key of Object.keys(hit).slice(0, 5)) {
992
+ const value = hit[key];
993
+ result[key] = value && typeof value === "object" ? JSON.stringify(value) : value;
994
+ }
995
+ return result;
217
996
  }
218
997
 
219
- // src/index.ts
220
- var args = process.argv.slice(2);
221
- var flags = new Set(args.filter((a) => a.startsWith("-")));
222
- var positional = args.filter((a) => !a.startsWith("-"));
223
- var command = positional[0];
224
- function printHelp() {
225
- console.log(`
226
- @headband/cli \u2014 Set up Headband search in your project
227
-
228
- Usage:
229
- npx @headband/cli init [options]
230
-
231
- Commands:
232
- init Set up Headband in the current project
233
-
234
- Options:
235
- --yes Skip prompts and use defaults
236
- --help Show this help message
237
-
238
- Examples:
239
- npx @headband/cli init
240
- npx @headband/cli init --yes
241
- `);
998
+ // src/instances.ts
999
+ var ENGINES = ["meilisearch", "elasticsearch", "typesense", "visor"];
1000
+ var CLIENTS = ["node", "curl"];
1001
+ var COLUMNS = ["id", "name", "engine", "lane", "memory", "region", "state"];
1002
+ var INSTANCES_USAGE = [
1003
+ "Usage:",
1004
+ " headband instances list [--json]",
1005
+ " headband instances create <meilisearch|elasticsearch|typesense|visor> [--name <name>] [--lane <serverless|always_on>]",
1006
+ " [--memory <512m|1g|...>] [--region <region>] [--idle-timeout <seconds>] [--json]",
1007
+ " headband instances show <id> [--json]",
1008
+ " headband instances wake <id> [--json]",
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]",
1013
+ " headband instances delete <id> [--yes] [--json]",
1014
+ " headband instances connect <id> [--client <node|curl>] [--json]"
1015
+ ].join("\n");
1016
+ function parseMemory(raw) {
1017
+ const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(raw.trim());
1018
+ if (!m) throw new UsageError("--memory must look like 512m, 1g or 2048");
1019
+ const value = Number(m[1]);
1020
+ const mb = (m[2] ?? "m").toLowerCase().startsWith("g") ? value * 1024 : value;
1021
+ if (!Number.isInteger(mb) || mb <= 0)
1022
+ throw new UsageError("--memory must be a whole number of megabytes");
1023
+ return mb;
242
1024
  }
243
- async function main() {
244
- if (flags.has("--help") || flags.has("-h") || command === "help") {
245
- printHelp();
246
- process.exit(0);
1025
+ function formatMemoryMb(mb) {
1026
+ return mb % 512 === 0 ? formatMemory(mb) : `${mb} MB`;
1027
+ }
1028
+ function parseLane(raw) {
1029
+ const lane = raw.replace(/-/g, "_");
1030
+ if (lane !== "serverless" && lane !== "always_on")
1031
+ throw new UsageError("--lane must be serverless or always_on");
1032
+ return lane;
1033
+ }
1034
+ function parsePositiveInt(raw, name) {
1035
+ const value = Number(raw);
1036
+ if (!Number.isInteger(value) || value <= 0)
1037
+ throw new UsageError(`${name} must be a positive integer`);
1038
+ return value;
1039
+ }
1040
+ function stateLabel(i) {
1041
+ if (i.changeRejected) return `${i.state} (change rejected)`;
1042
+ return i.pendingChanges ? `${i.state} (applying changes)` : i.state;
1043
+ }
1044
+ function summaryRow(i) {
1045
+ return {
1046
+ id: i.id,
1047
+ name: i.name,
1048
+ engine: i.engine,
1049
+ lane: i.lane,
1050
+ memory: formatMemoryMb(i.memoryMb),
1051
+ region: i.region,
1052
+ state: stateLabel(i)
1053
+ };
1054
+ }
1055
+ function requireId(args) {
1056
+ const id = args[1];
1057
+ if (!id) throw new UsageError("Missing engine id");
1058
+ return id;
1059
+ }
1060
+ function friendly(err) {
1061
+ if (err instanceof ApiError) {
1062
+ if (err.status === 404 && err.code === "not_found") {
1063
+ throw new CliError("Dedicated engines are not enabled for this organization yet.", {
1064
+ exitCode: 5,
1065
+ code: "not_found"
1066
+ });
1067
+ }
1068
+ if (err.status === 403 && err.code === "permission_denied") {
1069
+ throw new CliError(
1070
+ "This CLI key cannot manage engines. Run headband login again to get a key that can.",
1071
+ { exitCode: 4, code: "permission_denied" }
1072
+ );
1073
+ }
247
1074
  }
248
- if (!command || command === "init") {
249
- await init({ yes: flags.has("--yes") || flags.has("-y") });
250
- } else {
251
- console.error(`Unknown command: ${command}`);
252
- printHelp();
253
- process.exit(1);
1075
+ throw err;
1076
+ }
1077
+ async function runInstances(args, flags, io, client, confirm) {
1078
+ const action = args[0];
1079
+ const json = flags["--json"] === true;
1080
+ const str2 = (name) => typeof flags[name] === "string" ? flags[name] : void 0;
1081
+ const path2 = (id, suffix = "") => `/v1/instances/${encodeURIComponent(id)}${suffix}`;
1082
+ try {
1083
+ if (action === "list") {
1084
+ const res = await client.get("/v1/instances");
1085
+ if (json) return printJson(io, res);
1086
+ if (res.data.length === 0) {
1087
+ io.stdout("No engines yet. Create one with: headband instances create meilisearch");
1088
+ return;
1089
+ }
1090
+ printTable(
1091
+ io,
1092
+ res.data.map((i) => ({
1093
+ ...summaryRow(i),
1094
+ "wake p95": i.wakeP95Ms === null || i.wakeP95Ms === void 0 ? "" : `${Math.round(i.wakeP95Ms)} ms`
1095
+ })),
1096
+ [...COLUMNS, "wake p95"]
1097
+ );
1098
+ return;
1099
+ }
1100
+ if (action === "create") {
1101
+ const engine = args[1];
1102
+ if (!engine || !ENGINES.includes(engine)) {
1103
+ throw new UsageError("Engine must be one of: meilisearch, elasticsearch, typesense, visor");
1104
+ }
1105
+ const body = { engine };
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");
1111
+ if (name) body.name = name;
1112
+ if (lane) body.lane = parseLane(lane);
1113
+ if (memory) body.memoryMb = parseMemory(memory);
1114
+ if (region) body.region = region;
1115
+ if (idle) body.idleTimeoutS = parsePositiveInt(idle, "--idle-timeout");
1116
+ const res = await client.post("/v1/instances", body);
1117
+ if (json) return printJson(io, res);
1118
+ printTable(io, [summaryRow(res.data)], COLUMNS);
1119
+ io.stdout(`Endpoint: ${res.data.endpoint}`);
1120
+ io.stdout(`Connect a client: headband instances connect ${res.data.id}`);
1121
+ return;
1122
+ }
1123
+ if (action === "show") {
1124
+ const id = requireId(args);
1125
+ const res = await client.get(path2(id));
1126
+ if (json) return printJson(io, res);
1127
+ const i = res.data;
1128
+ const lines = [
1129
+ ["id", i.id],
1130
+ ["name", i.name],
1131
+ ["engine", `${i.engine} ${i.engineVersion}`],
1132
+ ["lane", i.lane],
1133
+ ["memory", formatMemoryMb(i.memoryMb)],
1134
+ ["region", i.region],
1135
+ ["state", stateLabel(i)],
1136
+ ["endpoint", i.endpoint],
1137
+ ["idle timeout", i.lane === "serverless" ? `${i.idleTimeoutS} s` : "n/a"],
1138
+ ["default", i.isDefault ? "yes" : "no"],
1139
+ ["last backup", i.lastBackupAt ?? "never"],
1140
+ ["last wake", i.lastWakeAt ?? "never"]
1141
+ ];
1142
+ const width = Math.max(...lines.map(([k]) => k.length));
1143
+ for (const [k, v] of lines) io.stdout(`${k.padEnd(width)} ${v}`);
1144
+ return;
1145
+ }
1146
+ if (action === "wake") {
1147
+ const id = requireId(args);
1148
+ const res = await client.post(path2(id, "/wake"));
1149
+ if (json) return printJson(io, res);
1150
+ const { wake } = res.data;
1151
+ io.stdout(
1152
+ wake.via === "running" ? `Engine ${id} is already running.` : `Engine ${id} is ${wake.state} (woke via ${wake.via.replace(/_/g, " ")} in ${wake.ms} ms).`
1153
+ );
1154
+ return;
1155
+ }
1156
+ if (action === "park") {
1157
+ const id = requireId(args);
1158
+ const res = await client.post(path2(id, "/park"));
1159
+ if (json) return printJson(io, res);
1160
+ io.stdout(`Engine ${id} is ${res.data.state}.`);
1161
+ return;
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
+ }
1211
+ if (action === "delete") {
1212
+ const id = requireId(args);
1213
+ if (!await confirm(`Delete engine ${id}? This removes its data and backups.`)) {
1214
+ io.stdout("Cancelled.");
1215
+ return;
1216
+ }
1217
+ const res = await client.delete(path2(id));
1218
+ if (json) return printJson(io, res);
1219
+ io.stdout(
1220
+ res.data.deleted ? `Deleted engine ${id}.` : `Engine ${id} is being deleted. It finishes in the background.`
1221
+ );
1222
+ return;
1223
+ }
1224
+ if (action === "connect") {
1225
+ const id = requireId(args);
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));
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
+ }
1240
+ io.stdout(`Endpoint: ${res.data.endpoint}`);
1241
+ io.stdout("");
1242
+ io.stdout(
1243
+ "Set HEADBAND_API_KEY to an organization-wide key. The snippets create an index and add a document, which needs an admin key (headband keys create <name> --type admin). A search key can only search. Project keys do not work on engine endpoints."
1244
+ );
1245
+ if (!which || which === "node") {
1246
+ io.stdout("");
1247
+ io.stdout(`Node (${s.install}):`);
1248
+ io.stdout(s.node);
1249
+ }
1250
+ if (!which || which === "curl") {
1251
+ io.stdout("");
1252
+ io.stdout("curl:");
1253
+ io.stdout(s.curl);
1254
+ }
1255
+ return;
1256
+ }
1257
+ io.stdout(INSTANCES_USAGE);
1258
+ throw new UsageError("Unknown instances command");
1259
+ } catch (err) {
1260
+ friendly(err);
1261
+ }
1262
+ }
1263
+
1264
+ // src/schema.ts
1265
+ var import_fs6 = require("fs");
1266
+ var import_path7 = require("path");
1267
+
1268
+ // ../sync/src/hash.ts
1269
+ var import_node_crypto = require("crypto");
1270
+ function stableStringify(value) {
1271
+ return JSON.stringify(normalize(value));
1272
+ }
1273
+ function normalize(value) {
1274
+ if (Array.isArray(value)) {
1275
+ return value.map(normalize);
1276
+ }
1277
+ if (value !== null && typeof value === "object") {
1278
+ const sortedKeys = Object.keys(value).sort();
1279
+ const result = {};
1280
+ for (const key of sortedKeys) {
1281
+ result[key] = normalize(value[key]);
1282
+ }
1283
+ return result;
1284
+ }
1285
+ return value;
1286
+ }
1287
+ function normalizeFieldSpec(spec) {
1288
+ if (typeof spec === "function") {
1289
+ return spec.toString();
1290
+ }
1291
+ return spec;
1292
+ }
1293
+ function docShapeHash(def) {
1294
+ const normalizedFields = {};
1295
+ for (const [name, spec] of Object.entries(def.fields)) {
1296
+ normalizedFields[name] = normalizeFieldSpec(spec);
1297
+ }
1298
+ const shape = {
1299
+ source: def.source,
1300
+ primaryKey: def.primaryKey,
1301
+ shouldIndex: def.shouldIndex?.toString() ?? null,
1302
+ fields: normalizedFields
1303
+ };
1304
+ return (0, import_node_crypto.createHash)("sha256").update(stableStringify(shape)).digest("hex");
1305
+ }
1306
+ function settingsHash(def) {
1307
+ const settings = def.settings ?? {};
1308
+ return (0, import_node_crypto.createHash)("sha256").update(stableStringify(settings)).digest("hex");
1309
+ }
1310
+
1311
+ // ../sync/src/migrations.ts
1312
+ var import_fs5 = require("fs");
1313
+ var import_path6 = require("path");
1314
+ var FILE_NAME_RE = /^(\d{4})_(.+)\.json$/;
1315
+ function sanitizeSlug(slug) {
1316
+ const sanitized = slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
1317
+ return sanitized || "migration";
1318
+ }
1319
+ function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new Date()) {
1320
+ (0, import_fs5.mkdirSync)(dir, { recursive: true });
1321
+ const existing = readMigrations(dir);
1322
+ const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
1323
+ const safeSlug = sanitizeSlug(meta.slug);
1324
+ const fileName = `${String(nextVersion).padStart(4, "0")}_${safeSlug}.json`;
1325
+ const path2 = (0, import_path6.join)(dir, fileName);
1326
+ const file = {
1327
+ version: nextVersion,
1328
+ createdAt: now().toISOString(),
1329
+ parentHashes: meta.parentHashes,
1330
+ ops
1331
+ };
1332
+ (0, import_fs5.writeFileSync)(path2, JSON.stringify(file, null, 2));
1333
+ return path2;
1334
+ }
1335
+ function readMigrations(dir) {
1336
+ let entries;
1337
+ try {
1338
+ entries = (0, import_fs5.readdirSync)(dir);
1339
+ } catch {
1340
+ return [];
1341
+ }
1342
+ const files = [];
1343
+ for (const entry of entries) {
1344
+ const match = FILE_NAME_RE.exec(entry);
1345
+ if (!match) continue;
1346
+ const path2 = (0, import_path6.join)(dir, entry);
1347
+ const raw = (0, import_fs5.readFileSync)(path2, "utf-8");
1348
+ let parsed;
1349
+ try {
1350
+ parsed = JSON.parse(raw);
1351
+ } catch {
1352
+ throw new Error(`Malformed migration file: ${path2}`);
1353
+ }
1354
+ files.push({ ...parsed, path: path2 });
1355
+ }
1356
+ files.sort((a, b) => a.version - b.version);
1357
+ for (let i = 0; i < files.length; i++) {
1358
+ const expected = i + 1;
1359
+ if (files[i].version === expected) continue;
1360
+ if (files.some((f, idx) => idx !== i && f.version === files[i].version)) {
1361
+ throw new Error(`Duplicate migration version: ${files[i].version}`);
1362
+ }
1363
+ throw new Error(
1364
+ `Migration version gap: expected version ${expected}, found ${files[i].version}`
1365
+ );
1366
+ }
1367
+ return files;
1368
+ }
1369
+ function pendingMigrations(files, appliedVersion) {
1370
+ return files.filter((f) => f.version > appliedVersion);
1371
+ }
1372
+
1373
+ // ../sync/src/planner.ts
1374
+ function planMigration(config, applied, opts) {
1375
+ const ops = [];
1376
+ const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1377
+ for (const def of config.indexes) {
1378
+ const existing = appliedByUid.get(def.name);
1379
+ const settings = def.settings ?? {};
1380
+ if (!existing) {
1381
+ ops.push({
1382
+ type: "create-index",
1383
+ index: def.name,
1384
+ physicalUid: def.name,
1385
+ settings
1386
+ });
1387
+ continue;
1388
+ }
1389
+ if (existing.status === "failed") {
1390
+ ops.push({
1391
+ type: "reindex",
1392
+ index: def.name,
1393
+ fromPhysicalUid: existing.physicalUid,
1394
+ toPhysicalUid: `${def.name}__v${existing.version + 1}`,
1395
+ settings
1396
+ });
1397
+ continue;
1398
+ }
1399
+ const schemaDrifted = docShapeHash(def) !== existing.schemaHash;
1400
+ const settingsDrifted = settingsHash(def) !== existing.settingsHash;
1401
+ if (schemaDrifted) {
1402
+ ops.push({
1403
+ type: "reindex",
1404
+ index: def.name,
1405
+ fromPhysicalUid: existing.physicalUid,
1406
+ toPhysicalUid: `${def.name}__v${existing.version + 1}`,
1407
+ settings
1408
+ });
1409
+ } else if (settingsDrifted) {
1410
+ ops.push({
1411
+ type: "patch-settings",
1412
+ index: def.name,
1413
+ settings
1414
+ });
1415
+ }
1416
+ }
1417
+ if (opts?.allowDestructive) {
1418
+ const configuredNames = new Set(config.indexes.map((def) => def.name));
1419
+ for (const state of applied) {
1420
+ if (!configuredNames.has(state.indexUid)) {
1421
+ ops.push({ type: "remove-index", index: state.indexUid });
1422
+ }
1423
+ }
1424
+ }
1425
+ return ops;
1426
+ }
1427
+
1428
+ // ../sync/src/client.ts
1429
+ var BASE_BACKOFF_MS = 500;
1430
+ var DEFAULT_MAX_RETRIES = 5;
1431
+ function defaultSleep2(ms) {
1432
+ return new Promise((resolve) => setTimeout(resolve, ms));
1433
+ }
1434
+ var SchemaCasError = class extends Error {
1435
+ constructor(message) {
1436
+ super(message);
1437
+ this.name = "SchemaCasError";
1438
+ }
1439
+ };
1440
+ var HttpError = class extends Error {
1441
+ constructor(message, status, code) {
1442
+ super(message);
1443
+ this.status = status;
1444
+ this.code = code;
1445
+ this.name = "HttpError";
254
1446
  }
1447
+ };
1448
+ function isRecord2(v) {
1449
+ return typeof v === "object" && v !== null;
1450
+ }
1451
+ function parsePartialFailureItem(raw) {
1452
+ const item = isRecord2(raw) ? raw : {};
1453
+ const status = typeof item.status === "number" ? item.status : void 0;
1454
+ return {
1455
+ id: typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : null,
1456
+ ...status !== void 0 ? { status } : {},
1457
+ ...typeof item.type === "string" ? { type: item.type } : {},
1458
+ ...typeof item.reason === "string" ? { reason: item.reason } : {},
1459
+ // Servers before per-item `retryable` still sent the status.
1460
+ retryable: typeof item.retryable === "boolean" ? item.retryable : status !== void 0 && (status === 429 || status >= 500)
1461
+ };
1462
+ }
1463
+ function parsePartialFailure(payload, message) {
1464
+ const errors = Array.isArray(payload.errors) ? payload.errors.map(parsePartialFailureItem) : [];
1465
+ const failed = typeof payload.failed === "number" ? payload.failed : errors.length;
1466
+ const everyListedRetryable = failed > 0 && errors.length === failed && errors.every((e) => e.retryable);
1467
+ return { message, failed, errors, retryable: payload.retryable === true || everyListedRetryable };
1468
+ }
1469
+ var HeadbandClient = class {
1470
+ constructor(opts) {
1471
+ this.baseUrl = opts.url.replace(/\/+$/, "");
1472
+ this.adminKey = opts.adminKey;
1473
+ this.fetchImpl = opts.fetchImpl ?? fetch;
1474
+ this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
1475
+ this.sleep = opts.sleep ?? defaultSleep2;
1476
+ }
1477
+ async bulkPush(index, documents, primaryKey) {
1478
+ const body = { index, documents };
1479
+ if (primaryKey !== void 0) body.primaryKey = primaryKey;
1480
+ const result = await this.request("POST", "/v1/bulk", body, {
1481
+ allowPartialFailure: true
1482
+ });
1483
+ if ("partialFailure" in result) return { tasks: [], partialFailure: result.partialFailure };
1484
+ return { ...result, tasks: result.tasks ?? [] };
1485
+ }
1486
+ async deleteDocs(index, ids) {
1487
+ const result = await this.request(
1488
+ "DELETE",
1489
+ `/v1/documents?index=${encodeURIComponent(index)}`,
1490
+ { ids },
1491
+ { allowPartialFailure: true }
1492
+ );
1493
+ return isRecord2(result) && "partialFailure" in result ? { partialFailure: result.partialFailure } : {};
1494
+ }
1495
+ async createIndex(uid, opts) {
1496
+ const body = { uid };
1497
+ if (opts?.primaryKey !== void 0) body.primaryKey = opts.primaryKey;
1498
+ await this.request("POST", "/v1/indexes", body);
1499
+ }
1500
+ async patchSettings(uid, settings) {
1501
+ await this.request("PATCH", `/v1/indexes/${uid}/settings`, settings);
1502
+ }
1503
+ async getSchemaState() {
1504
+ return this.request("GET", "/v1/schema/state");
1505
+ }
1506
+ async postSchemaState(state) {
1507
+ await this.request("POST", "/v1/schema/state", state, { mapConflictToCasError: true });
1508
+ }
1509
+ async patchAlias(index, physicalUid) {
1510
+ await this.request("PATCH", "/v1/schema/alias", { indexUid: index, physicalUid });
1511
+ }
1512
+ async reportRun(run2) {
1513
+ await this.request("POST", "/v1/connectors/runs", run2);
1514
+ }
1515
+ async request(method, path2, body, opts = {}) {
1516
+ let attempt = 0;
1517
+ for (; ; ) {
1518
+ attempt += 1;
1519
+ const response = await this.fetchImpl(`${this.baseUrl}${path2}`, {
1520
+ method,
1521
+ headers: {
1522
+ Authorization: `Bearer ${this.adminKey}`,
1523
+ "Content-Type": "application/json"
1524
+ },
1525
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
1526
+ });
1527
+ if (response.ok) {
1528
+ if (response.status === 204) return void 0;
1529
+ return await response.json();
1530
+ }
1531
+ const payload = await response.json().catch(() => ({ error: `Request failed with status ${response.status}` }));
1532
+ const message = payload?.error ?? `Request failed with status ${response.status}`;
1533
+ const code = payload?.code;
1534
+ if (opts.mapConflictToCasError && response.status === 409) {
1535
+ throw new SchemaCasError(message);
1536
+ }
1537
+ if (opts.allowPartialFailure && response.status === 422 && code === "partial_failure") {
1538
+ return { partialFailure: parsePartialFailure(payload, message) };
1539
+ }
1540
+ const retryable = response.status === 429 || response.status >= 500;
1541
+ if (!retryable || attempt >= this.maxRetries) {
1542
+ throw new HttpError(message, response.status, code);
1543
+ }
1544
+ const delay = BASE_BACKOFF_MS * 2 ** (attempt - 1);
1545
+ await this.sleep(delay);
1546
+ }
1547
+ }
1548
+ };
1549
+
1550
+ // src/schema.ts
1551
+ var DEFAULT_BACKFILL_BATCH_SIZE = 500;
1552
+ var APPLIED_MARKER_FILE = ".applied.json";
1553
+ function opDetail(op) {
1554
+ switch (op.type) {
1555
+ case "create-index":
1556
+ return `physicalUid=${op.physicalUid}`;
1557
+ case "patch-settings":
1558
+ return "settings drift";
1559
+ case "reindex":
1560
+ return `${op.fromPhysicalUid} -> ${op.toPhysicalUid}`;
1561
+ case "remove-index":
1562
+ return "not present in config";
1563
+ }
1564
+ }
1565
+ function opRows(ops) {
1566
+ return ops.map((op) => ({ op: op.type, index: op.index, detail: opDetail(op) }));
1567
+ }
1568
+ function printOpsTable(io, ops) {
1569
+ if (ops.length === 0) {
1570
+ io.stdout("Schema is up to date. No pending changes.");
1571
+ return;
1572
+ }
1573
+ printTable(io, opRows(ops), ["op", "index", "detail"]);
1574
+ }
1575
+ function readAppliedMarker(dir) {
1576
+ const path2 = (0, import_path7.join)(dir, APPLIED_MARKER_FILE);
1577
+ if (!(0, import_fs6.existsSync)(path2)) return 0;
1578
+ try {
1579
+ const parsed = JSON.parse((0, import_fs6.readFileSync)(path2, "utf-8"));
1580
+ return typeof parsed.version === "number" ? parsed.version : 0;
1581
+ } catch {
1582
+ return 0;
1583
+ }
1584
+ }
1585
+ function writeAppliedMarker(dir, version) {
1586
+ (0, import_fs6.mkdirSync)(dir, { recursive: true });
1587
+ (0, import_fs6.writeFileSync)((0, import_path7.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
1588
+ }
1589
+ async function planSchemaDiff(deps) {
1590
+ const applied = await deps.client.getSchemaState();
1591
+ return planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
1592
+ }
1593
+ async function runSchemaGenerate(deps) {
1594
+ const applied = await deps.client.getSchemaState();
1595
+ const ops = planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
1596
+ if (ops.length === 0) {
1597
+ deps.io.stdout("No schema changes to generate.");
1598
+ return void 0;
1599
+ }
1600
+ const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1601
+ const parentHashes = {};
1602
+ for (const op of ops) {
1603
+ parentHashes[op.index] = appliedByUid.get(op.index)?.schemaHash ?? "";
1604
+ }
1605
+ const path2 = generateMigrationFile(
1606
+ deps.dir,
1607
+ ops,
1608
+ { parentHashes, slug: deps.slug ?? "migration" },
1609
+ deps.now
1610
+ );
1611
+ deps.io.stdout(`Wrote ${path2}`);
1612
+ printOpsTable(deps.io, ops);
1613
+ return path2;
1614
+ }
1615
+ function isConflictError(err) {
1616
+ if (!(err instanceof Error)) return false;
1617
+ return err.code === "index_already_exists";
1618
+ }
1619
+ async function createIndexIdempotent(ctx, uid, opts) {
1620
+ try {
1621
+ await ctx.client.createIndex(uid, opts);
1622
+ } catch (err) {
1623
+ if (!isConflictError(err)) throw err;
1624
+ ctx.io.stdout(`Index "${uid}" already exists \u2014 continuing.`);
1625
+ }
1626
+ }
1627
+ function postState(client, state) {
1628
+ return client.postSchemaState(state);
1629
+ }
1630
+ async function applyReindex(op, ctx, existing, expectedParentHash) {
1631
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
1632
+ if (!def) {
1633
+ throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
1634
+ }
1635
+ await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
1636
+ await ctx.client.patchSettings(op.toPhysicalUid, op.settings);
1637
+ const version = (existing?.version ?? 0) + 1;
1638
+ const schemaHashValue = docShapeHash(def);
1639
+ const settingsHashValue = settingsHash(def);
1640
+ let parentHash = expectedParentHash;
1641
+ const writeState = async (status, cursor2) => {
1642
+ await postState(ctx.client, {
1643
+ indexUid: op.index,
1644
+ version,
1645
+ schemaHash: schemaHashValue,
1646
+ settingsHash: settingsHashValue,
1647
+ physicalUid: op.toPhysicalUid,
1648
+ status,
1649
+ // The `/v1/schema/state` route only accepts a string|null
1650
+ // backfillCursor (see route.ts) — `Id` can be numeric, so it must be
1651
+ // stringified before crossing the wire.
1652
+ backfillCursor: cursor2 === null ? null : String(cursor2),
1653
+ expectedParentHash: parentHash
1654
+ });
1655
+ parentHash = schemaHashValue;
1656
+ };
1657
+ await writeState("migrating", null);
1658
+ let cursor = null;
1659
+ let pushed = 0;
1660
+ let excluded = 0;
1661
+ let total = 0;
1662
+ for (; ; ) {
1663
+ const page = await ctx.adapter.listIds(def.source, cursor, ctx.backfillBatchSize);
1664
+ if (page.ids.length === 0) break;
1665
+ total += page.ids.length;
1666
+ const docs = await ctx.adapter.resolveDocs(def.name, page.ids);
1667
+ const toPush = [];
1668
+ for (const doc of docs) {
1669
+ const sourceRow = doc.__source ?? doc;
1670
+ delete doc.__source;
1671
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) {
1672
+ excluded += 1;
1673
+ continue;
1674
+ }
1675
+ toPush.push(doc);
1676
+ }
1677
+ if (toPush.length > 0) {
1678
+ const result = await ctx.client.bulkPush(op.toPhysicalUid, toPush, def.primaryKey);
1679
+ if (result?.partialFailure) {
1680
+ throw new CliError(
1681
+ `Backfill of "${op.index}" stopped: ${partialFailureSummary(result.partialFailure)}. Aborting before the alias flip. Fix the rejected documents, then re-run "headband schema apply".`
1682
+ );
1683
+ }
1684
+ pushed += toPush.length;
1685
+ }
1686
+ cursor = page.nextCursor;
1687
+ await writeState("migrating", cursor);
1688
+ if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
1689
+ }
1690
+ if (pushed + excluded !== total) {
1691
+ throw new CliError(
1692
+ `Backfill count mismatch for "${op.index}": listed ${total} source row(s) but accounted for ${pushed + excluded} (${pushed} pushed, ${excluded} excluded by shouldIndex). Aborting before the alias flip \u2014 re-run "headband schema apply" once resolved.`
1693
+ );
1694
+ }
1695
+ await ctx.client.patchAlias(op.index, op.toPhysicalUid);
1696
+ await writeState("applied", null);
1697
+ return {
1698
+ indexUid: op.index,
1699
+ version,
1700
+ schemaHash: schemaHashValue,
1701
+ settingsHash: settingsHashValue,
1702
+ physicalUid: op.toPhysicalUid,
1703
+ status: "applied"
1704
+ };
1705
+ }
1706
+ async function applyOp(op, ctx, existing, expectedParentHash) {
1707
+ if (op.type === "create-index") {
1708
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
1709
+ await createIndexIdempotent(
1710
+ ctx,
1711
+ op.index,
1712
+ def?.primaryKey ? { primaryKey: def.primaryKey } : void 0
1713
+ );
1714
+ await ctx.client.patchSettings(op.index, op.settings);
1715
+ const next = {
1716
+ indexUid: op.index,
1717
+ version: 1,
1718
+ schemaHash: def ? docShapeHash(def) : "",
1719
+ settingsHash: def ? settingsHash(def) : "",
1720
+ physicalUid: op.physicalUid,
1721
+ status: "applied"
1722
+ };
1723
+ await postState(ctx.client, { ...next, expectedParentHash });
1724
+ return next;
1725
+ }
1726
+ if (op.type === "patch-settings") {
1727
+ if (!existing) {
1728
+ throw new CliError(`Cannot patch settings for "${op.index}": no applied schema state found.`);
1729
+ }
1730
+ await ctx.client.patchSettings(op.index, op.settings);
1731
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
1732
+ const next = {
1733
+ ...existing,
1734
+ settingsHash: def ? settingsHash(def) : existing.settingsHash,
1735
+ status: "applied"
1736
+ };
1737
+ await postState(ctx.client, { ...next, expectedParentHash });
1738
+ return next;
1739
+ }
1740
+ if (op.type === "reindex") {
1741
+ return applyReindex(op, ctx, existing, expectedParentHash);
1742
+ }
1743
+ ctx.io.stdout(
1744
+ `Skipping engine deletion for index "${op.index}" \u2014 not yet supported by the API. Remove it manually with "headband indexes delete ${op.index}" if needed.`
1745
+ );
1746
+ return void 0;
1747
+ }
1748
+ async function runSchemaApply(deps) {
1749
+ const files = readMigrations(deps.dir);
1750
+ const appliedVersion = readAppliedMarker(deps.dir);
1751
+ const pending = pendingMigrations(files, appliedVersion);
1752
+ if (pending.length === 0) {
1753
+ deps.io.stdout("No pending migrations to apply.");
1754
+ return { appliedFiles: 0, appliedOps: 0 };
1755
+ }
1756
+ const applied = await deps.client.getSchemaState();
1757
+ const existingByIndex = new Map(applied.map((a) => [a.indexUid, a]));
1758
+ const ctx = {
1759
+ config: deps.config,
1760
+ adapter: deps.adapter,
1761
+ client: deps.client,
1762
+ backfillBatchSize: deps.backfillBatchSize ?? DEFAULT_BACKFILL_BATCH_SIZE,
1763
+ io: deps.io
1764
+ };
1765
+ let appliedOps = 0;
1766
+ let appliedFiles = 0;
1767
+ for (const file of pending) {
1768
+ for (const op of file.ops) {
1769
+ if (op.type === "remove-index" && !deps.allowDestructive) {
1770
+ throw new CliError(
1771
+ `Migration ${file.version} contains a destructive "remove-index" op for "${op.index}". Re-run with --allow-destructive to proceed.`
1772
+ );
1773
+ }
1774
+ const existing = existingByIndex.get(op.index);
1775
+ const hasParentHash = Object.prototype.hasOwnProperty.call(file.parentHashes, op.index);
1776
+ const rawParentHash = hasParentHash ? file.parentHashes[op.index] : null;
1777
+ const currentServerHash = existing?.schemaHash ?? "";
1778
+ if (hasParentHash && currentServerHash !== rawParentHash) {
1779
+ throw new CliError(
1780
+ `Migration ${file.version} is stale for index "${op.index}": the server schema state has changed since this migration file was generated. Regenerate with "headband schema generate" before applying.`
1781
+ );
1782
+ }
1783
+ const expectedParentHash = rawParentHash === "" ? null : rawParentHash;
1784
+ try {
1785
+ const next = await applyOp(op, ctx, existing, expectedParentHash);
1786
+ if (next) existingByIndex.set(op.index, next);
1787
+ appliedOps += 1;
1788
+ } catch (err) {
1789
+ if (err instanceof SchemaCasError) {
1790
+ throw new CliError(
1791
+ `Schema state for "${op.index}" was changed by another machine since this migration was generated \u2014 re-run "headband schema diff" and regenerate before retrying. (${err.message})`
1792
+ );
1793
+ }
1794
+ throw err;
1795
+ }
1796
+ }
1797
+ writeAppliedMarker(deps.dir, file.version);
1798
+ appliedFiles += 1;
1799
+ deps.io.stdout(`Applied migration ${file.version}${file.path ? ` (${file.path})` : ""}.`);
1800
+ }
1801
+ return { appliedFiles, appliedOps };
1802
+ }
1803
+ async function runSchemaStatus(deps) {
1804
+ const applied = await deps.client.getSchemaState();
1805
+ const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1806
+ const rows = deps.config.indexes.map((def) => {
1807
+ const existing = appliedByUid.get(def.name);
1808
+ if (!existing) {
1809
+ return { index: def.name, status: "missing", version: 0, drift: "missing" };
1810
+ }
1811
+ const schemaDrifted = docShapeHash(def) !== existing.schemaHash;
1812
+ const settingsDrifted = settingsHash(def) !== existing.settingsHash;
1813
+ return {
1814
+ index: def.name,
1815
+ status: existing.status,
1816
+ version: existing.version,
1817
+ drift: schemaDrifted ? "schema" : settingsDrifted ? "settings" : "none"
1818
+ };
1819
+ });
1820
+ printTable(deps.io, rows, ["index", "status", "version", "drift"]);
1821
+ const files = readMigrations(deps.dir);
1822
+ const localVersion = readAppliedMarker(deps.dir);
1823
+ const localHead = files.length > 0 ? files[files.length - 1].version : 0;
1824
+ deps.io.stdout(`Local migrations applied: ${localVersion}/${localHead}`);
1825
+ return { indexes: rows, localApplied: localVersion, localHead };
1826
+ }
1827
+
1828
+ // src/sync.ts
1829
+ var import_fs7 = require("fs");
1830
+ var import_path8 = require("path");
1831
+ var import_crypto = require("crypto");
1832
+ var import_pg = require("pg");
1833
+
1834
+ // ../sync/src/outbox.ts
1835
+ var SAFE_IDENTIFIER = /^[a-zA-Z0-9_]+$/;
1836
+ function assertSafeTable(table) {
1837
+ if (!SAFE_IDENTIFIER.test(table)) {
1838
+ throw new Error(`Invalid outbox table name "${table}": must match ${SAFE_IDENTIFIER.source}`);
1839
+ }
1840
+ }
1841
+ function outboxDDL(table = "headband_outbox") {
1842
+ assertSafeTable(table);
1843
+ return `
1844
+ CREATE TABLE IF NOT EXISTS ${table} (
1845
+ id BIGSERIAL PRIMARY KEY,
1846
+ model TEXT NOT NULL,
1847
+ op TEXT NOT NULL,
1848
+ doc_ids JSONB NOT NULL,
1849
+ snapshot JSONB,
1850
+ status TEXT NOT NULL DEFAULT 'pending',
1851
+ attempts INTEGER NOT NULL DEFAULT 0,
1852
+ claimed_at TIMESTAMPTZ,
1853
+ last_error TEXT,
1854
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
1855
+ );
1856
+ CREATE INDEX IF NOT EXISTS ${table}_status_idx ON ${table} (status);
1857
+ `.trim();
1858
+ }
1859
+ function mapRow(row) {
1860
+ const docIds = typeof row.doc_ids === "string" ? JSON.parse(row.doc_ids) : row.doc_ids;
1861
+ const snapshot = row.snapshot == null ? void 0 : typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
1862
+ return {
1863
+ id: row.id,
1864
+ model: row.model,
1865
+ op: row.op,
1866
+ docIds,
1867
+ attempts: row.attempts,
1868
+ snapshot
1869
+ };
1870
+ }
1871
+ function pgOutboxDriver(exec, opts) {
1872
+ const table = opts?.table ?? "headband_outbox";
1873
+ assertSafeTable(table);
1874
+ const maxAttempts = opts?.maxAttempts ?? 5;
1875
+ return {
1876
+ async claim(limit) {
1877
+ const { rows } = await exec(
1878
+ `UPDATE ${table} SET claimed_at = now() WHERE id IN (SELECT id FROM ${table} WHERE status='pending' AND (claimed_at IS NULL OR claimed_at < now() - interval '5 minutes') ORDER BY id LIMIT $1 FOR UPDATE SKIP LOCKED) RETURNING *`,
1879
+ [limit]
1880
+ );
1881
+ return rows.map(mapRow);
1882
+ },
1883
+ async markDone(ids) {
1884
+ await exec(`UPDATE ${table} SET status = 'done' WHERE id = ANY($1)`, [ids]);
1885
+ },
1886
+ async deadLetter(id, error) {
1887
+ await exec(
1888
+ `UPDATE ${table} SET attempts = attempts + 1, last_error = $2, status = 'dead_letter', claimed_at = NULL WHERE id = $1`,
1889
+ [id, error]
1890
+ );
1891
+ },
1892
+ async markFailed(id, error) {
1893
+ await exec(
1894
+ `UPDATE ${table} SET attempts = attempts + 1, last_error = $2, status = CASE WHEN attempts + 1 >= $3 THEN 'dead_letter' ELSE 'pending' END, claimed_at = NULL WHERE id = $1`,
1895
+ [id, error, maxAttempts]
1896
+ );
1897
+ },
1898
+ async deadLetters(limit) {
1899
+ const { rows } = await exec(
1900
+ `SELECT * FROM ${table} WHERE status = 'dead_letter' ORDER BY id LIMIT $1`,
1901
+ [limit]
1902
+ );
1903
+ return rows.map(mapRow);
1904
+ },
1905
+ async requeue(ids) {
1906
+ await exec(`UPDATE ${table} SET status = 'pending', claimed_at = NULL WHERE id = ANY($1)`, [
1907
+ ids
1908
+ ]);
1909
+ },
1910
+ async depth() {
1911
+ const { rows } = await exec(
1912
+ `SELECT count(*) AS count FROM ${table} WHERE status = 'pending'`
1913
+ );
1914
+ return Number(rows[0]?.count ?? 0);
1915
+ }
1916
+ };
1917
+ }
1918
+
1919
+ // ../sync/src/resolver.ts
1920
+ var toKey = (id) => String(id);
1921
+ var idKey = toKey;
1922
+ async function resolveDirtyDocs(adapter, config, changes) {
1923
+ const upserts = /* @__PURE__ */ new Map();
1924
+ const deletes = /* @__PURE__ */ new Map();
1925
+ const origins = /* @__PURE__ */ new Map();
1926
+ const addOrigin = (index, id, changeIndex) => {
1927
+ let byId = origins.get(index);
1928
+ if (!byId) {
1929
+ byId = /* @__PURE__ */ new Map();
1930
+ origins.set(index, byId);
1931
+ }
1932
+ const key = toKey(id);
1933
+ const set = byId.get(key);
1934
+ if (set) set.add(changeIndex);
1935
+ else byId.set(key, /* @__PURE__ */ new Set([changeIndex]));
1936
+ };
1937
+ const indexByName = new Map(config.indexes.map((def) => [def.name, def]));
1938
+ const toResolve = /* @__PURE__ */ new Map();
1939
+ const addDelete = (index, id) => {
1940
+ const list = deletes.get(index);
1941
+ if (list) {
1942
+ if (!list.some((x) => toKey(x) === toKey(id))) list.push(id);
1943
+ } else {
1944
+ deletes.set(index, [id]);
1945
+ }
1946
+ };
1947
+ for (const [changeIndex, change] of changes.entries()) {
1948
+ const dirtyDocs = await adapter.fanOut(change);
1949
+ for (const doc of dirtyDocs) {
1950
+ addOrigin(doc.index, doc.id, changeIndex);
1951
+ const def = indexByName.get(doc.index);
1952
+ const isSourceModelDelete = change.op === "delete" && def !== void 0 && def.source === change.model;
1953
+ if (isSourceModelDelete) {
1954
+ addDelete(doc.index, doc.id);
1955
+ continue;
1956
+ }
1957
+ let ids = toResolve.get(doc.index);
1958
+ if (!ids) {
1959
+ ids = /* @__PURE__ */ new Map();
1960
+ toResolve.set(doc.index, ids);
1961
+ }
1962
+ if (!ids.has(toKey(doc.id))) ids.set(toKey(doc.id), doc.id);
1963
+ }
1964
+ }
1965
+ for (const [index, idSet] of toResolve) {
1966
+ const ids = [...idSet.values()];
1967
+ const def = indexByName.get(index);
1968
+ const primaryKey = def?.primaryKey ?? "id";
1969
+ const rows = await adapter.resolveDocs(index, ids);
1970
+ const resolvedKeys = /* @__PURE__ */ new Set();
1971
+ rows.forEach((row, i) => {
1972
+ const rawId = row[primaryKey];
1973
+ if (rawId === void 0) {
1974
+ const contextId = ids[i];
1975
+ throw new Error(
1976
+ `resolveDocs for index '${index}' returned a doc missing primaryKey '${primaryKey}' (id: ${String(contextId)})`
1977
+ );
1978
+ }
1979
+ const id = rawId;
1980
+ resolvedKeys.add(toKey(id));
1981
+ const sourceRow = row.__source ?? row;
1982
+ delete row.__source;
1983
+ if (def?.shouldIndex && !def.shouldIndex(sourceRow)) {
1984
+ addDelete(index, id);
1985
+ return;
1986
+ }
1987
+ const list = upserts.get(index);
1988
+ if (list) {
1989
+ list.push(row);
1990
+ } else {
1991
+ upserts.set(index, [row]);
1992
+ }
1993
+ });
1994
+ for (const id of ids) {
1995
+ if (!resolvedKeys.has(toKey(id))) {
1996
+ addDelete(index, id);
1997
+ }
1998
+ }
1999
+ }
2000
+ return { upserts, deletes, origins };
2001
+ }
2002
+
2003
+ // ../sync/src/worker.ts
2004
+ var DEFAULT_BATCH_SIZE = 100;
2005
+ var DEFAULT_IDLE_DELAY_MS = 1e3;
2006
+ var MAX_FAILURE_DELAY_MS = 3e4;
2007
+ var MAX_PARTIAL_RETRIES = 3;
2008
+ async function writeWithPartialRetries(items, keyOf, send, backoff) {
2009
+ const failures = /* @__PURE__ */ new Map();
2010
+ let pending = items;
2011
+ for (let attempt = 0; pending.length > 0; attempt++) {
2012
+ const partial = await send(pending);
2013
+ if (!partial) return failures;
2014
+ const named = new Map(
2015
+ partial.errors.flatMap((e) => e.id === null ? [] : [[e.id, e]])
2016
+ );
2017
+ const pendingKeys = new Set(pending.map(keyOf));
2018
+ const namedHere = [...named.keys()].filter((k) => pendingKeys.has(k)).length;
2019
+ const complete = namedHere >= partial.failed;
2020
+ const retry = [];
2021
+ for (const item of pending) {
2022
+ const key = keyOf(item);
2023
+ const err = named.get(key);
2024
+ if (err) {
2025
+ const message = err.reason ?? partial.message;
2026
+ if (err.retryable && attempt < MAX_PARTIAL_RETRIES) retry.push(item);
2027
+ else failures.set(key, { retryable: err.retryable, message });
2028
+ } else if (!complete) {
2029
+ failures.set(key, {
2030
+ retryable: true,
2031
+ message: `${partial.message} (this document's outcome was not reported)`
2032
+ });
2033
+ }
2034
+ }
2035
+ if (retry.length === 0) return failures;
2036
+ await backoff(attempt + 1);
2037
+ pending = retry;
2038
+ }
2039
+ return failures;
2040
+ }
2041
+ async function pushResolved(resolved, client, primaryKeyByIndex, backoff) {
2042
+ const failures = /* @__PURE__ */ new Map();
2043
+ let pushed = 0;
2044
+ let deleted = 0;
2045
+ const record = (index, failed) => {
2046
+ if (failed.size === 0) return;
2047
+ const existing = failures.get(index) ?? /* @__PURE__ */ new Map();
2048
+ for (const [key, f] of failed) existing.set(key, f);
2049
+ failures.set(index, existing);
2050
+ };
2051
+ for (const [index, docs] of resolved.upserts) {
2052
+ const primaryKey = primaryKeyByIndex.get(index);
2053
+ const failed = await writeWithPartialRetries(
2054
+ docs,
2055
+ (doc) => idKey(doc[primaryKey ?? "id"]),
2056
+ async (batch) => (await client.bulkPush(index, batch, primaryKey))?.partialFailure,
2057
+ backoff
2058
+ );
2059
+ record(index, failed);
2060
+ pushed += docs.length - failed.size;
2061
+ }
2062
+ for (const [index, ids] of resolved.deletes) {
2063
+ const failed = await writeWithPartialRetries(
2064
+ ids,
2065
+ idKey,
2066
+ async (batch) => (await client.deleteDocs(index, batch))?.partialFailure,
2067
+ backoff
2068
+ );
2069
+ record(index, failed);
2070
+ deleted += ids.length - failed.size;
2071
+ }
2072
+ return { failures, pushed, deleted };
2073
+ }
2074
+ function rowOutcomes(rows, resolved, failures) {
2075
+ const failuresByRow = /* @__PURE__ */ new Map();
2076
+ for (const [index, failed] of failures) {
2077
+ for (const [key, failure] of failed) {
2078
+ for (const changeIndex of resolved.origins.get(index)?.get(key) ?? []) {
2079
+ const list = failuresByRow.get(changeIndex) ?? [];
2080
+ list.push(failure);
2081
+ failuresByRow.set(changeIndex, list);
2082
+ }
2083
+ }
2084
+ }
2085
+ const done = [];
2086
+ const retry = [];
2087
+ const deadLetter = [];
2088
+ rows.forEach((row, changeIndex) => {
2089
+ const rowFailures = failuresByRow.get(changeIndex);
2090
+ if (!rowFailures) {
2091
+ done.push(row.id);
2092
+ return;
2093
+ }
2094
+ const permanent = rowFailures.find((f) => !f.retryable);
2095
+ if (permanent) deadLetter.push([row.id, permanent.message]);
2096
+ else retry.push([row.id, rowFailures[0].message]);
2097
+ });
2098
+ return { done, retry, deadLetter };
2099
+ }
2100
+ function defaultSleep3(ms) {
2101
+ return new Promise((resolve) => setTimeout(resolve, ms));
2102
+ }
2103
+ async function runSyncWorker(opts) {
2104
+ const {
2105
+ config,
2106
+ adapter,
2107
+ client,
2108
+ outbox,
2109
+ budgetMs,
2110
+ loop = false,
2111
+ idleDelayMs = DEFAULT_IDLE_DELAY_MS,
2112
+ failureDelayMs = idleDelayMs,
2113
+ batchSize = DEFAULT_BATCH_SIZE,
2114
+ stopSignal,
2115
+ now = Date.now,
2116
+ sleep = defaultSleep3,
2117
+ onHeartbeat
2118
+ } = opts;
2119
+ const primaryKeyByIndex = new Map(config.indexes.map((def) => [def.name, def.primaryKey]));
2120
+ const report = {
2121
+ drained: 0,
2122
+ pushed: 0,
2123
+ deleted: 0,
2124
+ deadLettered: 0,
2125
+ outboxDepth: 0
2126
+ };
2127
+ const start = now();
2128
+ const budgetExceeded = () => budgetMs !== void 0 && now() - start >= budgetMs;
2129
+ let consecutiveFailures = 0;
2130
+ for (; ; ) {
2131
+ if (budgetExceeded()) break;
2132
+ if (stopSignal && stopSignal()) break;
2133
+ const rows = await outbox.claim(batchSize);
2134
+ if (rows.length === 0) {
2135
+ if (!loop) break;
2136
+ await sleep(idleDelayMs);
2137
+ continue;
2138
+ }
2139
+ const changes = rows.map((row) => ({
2140
+ model: row.model,
2141
+ op: row.op,
2142
+ ids: row.docIds,
2143
+ snapshot: row.snapshot
2144
+ }));
2145
+ try {
2146
+ const resolved = await resolveDirtyDocs(adapter, config, changes);
2147
+ const { failures, pushed, deleted } = await pushResolved(
2148
+ resolved,
2149
+ client,
2150
+ primaryKeyByIndex,
2151
+ (attempt) => sleep(Math.min(failureDelayMs * 2 ** (attempt - 1), MAX_FAILURE_DELAY_MS))
2152
+ );
2153
+ report.pushed += pushed;
2154
+ report.deleted += deleted;
2155
+ const outcome = rowOutcomes(rows, resolved, failures);
2156
+ if (outcome.done.length > 0) await outbox.markDone(outcome.done);
2157
+ for (const [id, message] of outcome.deadLetter) {
2158
+ if (outbox.deadLetter) await outbox.deadLetter(id, message);
2159
+ else await outbox.markFailed(id, message);
2160
+ }
2161
+ for (const [id, message] of outcome.retry) {
2162
+ await outbox.markFailed(id, message);
2163
+ }
2164
+ report.drained += outcome.done.length;
2165
+ report.deadLettered += outcome.deadLetter.length + outcome.retry.length;
2166
+ if (outcome.retry.length > 0) {
2167
+ consecutiveFailures += 1;
2168
+ await sleep(
2169
+ Math.min(failureDelayMs * 2 ** (consecutiveFailures - 1), MAX_FAILURE_DELAY_MS)
2170
+ );
2171
+ } else {
2172
+ consecutiveFailures = 0;
2173
+ }
2174
+ } catch (err) {
2175
+ const message = err instanceof Error ? err.message : String(err);
2176
+ for (const row of rows) {
2177
+ await outbox.markFailed(row.id, message);
2178
+ }
2179
+ report.deadLettered += rows.length;
2180
+ consecutiveFailures += 1;
2181
+ const delay = Math.min(failureDelayMs * 2 ** (consecutiveFailures - 1), MAX_FAILURE_DELAY_MS);
2182
+ await sleep(delay);
2183
+ }
2184
+ if (onHeartbeat) {
2185
+ try {
2186
+ await onHeartbeat({ ...report });
2187
+ } catch (err) {
2188
+ console.error("[headband] heartbeat failed:", err);
2189
+ }
2190
+ }
2191
+ }
2192
+ report.outboxDepth = await outbox.depth();
2193
+ if (onHeartbeat) {
2194
+ try {
2195
+ await onHeartbeat({ ...report });
2196
+ } catch (err) {
2197
+ console.error("[headband] heartbeat failed:", err);
2198
+ }
2199
+ }
2200
+ return report;
2201
+ }
2202
+
2203
+ // src/sync.ts
2204
+ var WORKER_VERSION = "1";
2205
+ var DEFAULT_DEAD_LETTER_LIMIT = 1e3;
2206
+ var RECONCILE_PAGE_SIZE = 500;
2207
+ var OUTBOX_SQL_RELATIVE_PATH = (0, import_path8.join)("headband", "outbox.sql");
2208
+ var DOCKERFILE_NAME = "Dockerfile.headband-sync";
2209
+ var DOCKERFILE_CONTENT = `FROM node:22-slim
2210
+ WORKDIR /app
2211
+ COPY . .
2212
+ RUN npm ci
2213
+ CMD ["npx", "headband", "sync", "worker"]
2214
+ `;
2215
+ var COMPOSE_SNIPPET = `services:
2216
+ headband-sync:
2217
+ build:
2218
+ context: .
2219
+ dockerfile: ${DOCKERFILE_NAME}
2220
+ env_file: .env
2221
+ restart: unless-stopped
2222
+ `;
2223
+ function resolveDatabaseUrl(env = process.env) {
2224
+ const url = env.DATABASE_URL || env.HEADBAND_DATABASE_URL;
2225
+ if (!url) {
2226
+ throw new CliError(
2227
+ "No database connection found for the sync outbox. Set DATABASE_URL (or HEADBAND_DATABASE_URL) in your environment."
2228
+ );
2229
+ }
2230
+ return url;
2231
+ }
2232
+ function buildPgOutbox(connectionString, opts) {
2233
+ const pool = new import_pg.Pool({ connectionString });
2234
+ const exec = async (sqlText, params) => {
2235
+ const result = await pool.query(sqlText, params);
2236
+ return { rows: result.rows };
2237
+ };
2238
+ return { outbox: pgOutboxDriver(exec, opts), close: () => pool.end() };
2239
+ }
2240
+ function runSyncInit(deps) {
2241
+ const ddl = outboxDDL(deps.table);
2242
+ deps.io.stdout(ddl);
2243
+ if (deps.write) {
2244
+ const path2 = (0, import_path8.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
2245
+ (0, import_fs7.mkdirSync)((0, import_path8.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
2246
+ (0, import_fs7.writeFileSync)(path2, `${ddl}
2247
+ `);
2248
+ deps.io.stdout(`Wrote ${path2}`);
2249
+ }
2250
+ return ddl;
2251
+ }
2252
+ var SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"];
2253
+ async function runSyncWorkerCmd(deps) {
2254
+ const schemaHashes = Object.fromEntries(
2255
+ deps.config.indexes.map((def) => [def.name, docShapeHash(def)])
2256
+ );
2257
+ const workerVersion = deps.workerVersion ?? WORKER_VERSION;
2258
+ let heartbeats = 0;
2259
+ const proc = deps.process ?? process;
2260
+ let stopRequested = false;
2261
+ const handleShutdown = () => {
2262
+ if (stopRequested) return;
2263
+ stopRequested = true;
2264
+ deps.io.stderr("shutting down after current batch\u2026");
2265
+ };
2266
+ for (const signal of SHUTDOWN_SIGNALS) {
2267
+ proc.once(signal, handleShutdown);
2268
+ }
2269
+ try {
2270
+ const report = await runSyncWorker({
2271
+ config: deps.config,
2272
+ adapter: deps.adapter,
2273
+ client: deps.client,
2274
+ outbox: deps.outbox,
2275
+ loop: deps.loop ?? !deps.budgetMs,
2276
+ budgetMs: deps.budgetMs,
2277
+ batchSize: deps.batchSize,
2278
+ idleDelayMs: deps.idleDelayMs,
2279
+ failureDelayMs: deps.failureDelayMs,
2280
+ now: deps.now,
2281
+ sleep: deps.sleep,
2282
+ stopSignal: () => stopRequested,
2283
+ onHeartbeat: async (r) => {
2284
+ heartbeats += 1;
2285
+ await deps.client.reportRun({
2286
+ connectorSlug: "orm-sync",
2287
+ status: r.deadLettered > 0 ? "error" : "success",
2288
+ count: r.pushed + r.deleted,
2289
+ externalRunId: (0, import_crypto.randomUUID)(),
2290
+ details: { outboxDepth: r.outboxDepth, workerVersion, schemaHashes }
2291
+ });
2292
+ if (deps.reconcileEvery && deps.onReconcileTick && heartbeats % deps.reconcileEvery === 0) {
2293
+ await deps.onReconcileTick();
2294
+ }
2295
+ }
2296
+ });
2297
+ deps.io.stdout(
2298
+ `Drained ${report.drained} row(s): pushed ${report.pushed}, deleted ${report.deleted}, dead-lettered ${report.deadLettered}. Outbox depth: ${report.outboxDepth}.`
2299
+ );
2300
+ return report;
2301
+ } finally {
2302
+ for (const signal of SHUTDOWN_SIGNALS) {
2303
+ proc.removeListener(signal, handleShutdown);
2304
+ }
2305
+ }
2306
+ }
2307
+ async function collectAllIds(adapter, model) {
2308
+ const ids = [];
2309
+ let cursor = null;
2310
+ for (; ; ) {
2311
+ const page = await adapter.listIds(model, cursor, RECONCILE_PAGE_SIZE);
2312
+ ids.push(...page.ids);
2313
+ if (page.nextCursor === null || page.ids.length === 0) break;
2314
+ cursor = page.nextCursor;
2315
+ }
2316
+ return ids;
2317
+ }
2318
+ async function runSyncReconcile(deps) {
2319
+ const ormSchema = deps.adapter.introspect();
2320
+ const rows = [];
2321
+ for (const def of deps.config.indexes) {
2322
+ const ids = await collectAllIds(deps.adapter, def.source);
2323
+ const sourceCount = ids.length;
2324
+ let remoteCount = -1;
2325
+ try {
2326
+ const stats = await deps.getStats(def.name);
2327
+ remoteCount = stats.numDocuments ?? 0;
2328
+ } catch (err) {
2329
+ deps.io.stderr(
2330
+ `Could not fetch stats for "${def.name}": ${err instanceof Error ? err.message : String(err)}`
2331
+ );
2332
+ }
2333
+ const cursorAware = Boolean(ormSchema.models[def.source]?.columns?.updatedAt);
2334
+ const drift = remoteCount === -1 ? 0 : sourceCount - remoteCount;
2335
+ rows.push({ index: def.name, sourceCount, remoteCount, drift, cursorAware });
2336
+ if (deps.fix && drift !== 0) {
2337
+ const docs = await deps.adapter.resolveDocs(def.name, ids);
2338
+ const toPush = docs.filter((doc) => {
2339
+ const sourceRow = doc.__source ?? doc;
2340
+ return !def.shouldIndex || def.shouldIndex(sourceRow);
2341
+ });
2342
+ for (const doc of toPush) delete doc.__source;
2343
+ if (toPush.length > 0) {
2344
+ const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
2345
+ if (result?.partialFailure) {
2346
+ throw new CliError(
2347
+ `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
2348
+ );
2349
+ }
2350
+ }
2351
+ }
2352
+ }
2353
+ printTable(
2354
+ deps.io,
2355
+ rows.map((r) => ({ ...r, remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount })),
2356
+ ["index", "sourceCount", "remoteCount", "drift", "cursorAware"]
2357
+ );
2358
+ return rows;
2359
+ }
2360
+ async function runSyncRetryDeadLetters(deps) {
2361
+ const rows = await deps.outbox.deadLetters(deps.limit ?? DEFAULT_DEAD_LETTER_LIMIT);
2362
+ if (rows.length === 0) {
2363
+ deps.io.stdout("No dead-lettered rows to retry.");
2364
+ return 0;
2365
+ }
2366
+ await deps.outbox.requeue(rows.map((row) => row.id));
2367
+ deps.io.stdout(`Requeued ${rows.length} dead-lettered row(s).`);
2368
+ return rows.length;
2369
+ }
2370
+ function runSyncScaffoldDocker(deps) {
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.`);
2374
+ }
2375
+ (0, import_fs7.writeFileSync)(path2, DOCKERFILE_CONTENT);
2376
+ deps.io.stdout(`Wrote ${path2}`);
2377
+ deps.io.stdout("Add this service to your docker-compose.yml:");
2378
+ deps.io.stdout(COMPOSE_SNIPPET);
2379
+ return path2;
2380
+ }
2381
+
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")}`;
2405
+ }
2406
+ function newSyncPassword() {
2407
+ return (0, import_node_crypto2.randomBytes)(24).toString("base64url");
2408
+ }
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]}`;
2414
+ }
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);
2419
+ }
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.");
2456
+ }
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;
2463
+ }
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;
2493
+ }
2494
+ }
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.");
2501
+ }
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 };
2517
+ }
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
+ };
2535
+ }
2536
+ function looksLikePooler(host, port) {
2537
+ return POOLER_HOST_RE.test(host) || POOLER_PORTS.has(port);
2538
+ }
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")
3604
+ });
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.");
3630
+ }
3631
+ function printHelp(io) {
3632
+ io.stdout(`@headband/cli
3633
+
3634
+ Usage:
3635
+ headband <command> [options]
3636
+
3637
+ Commands:
3638
+ init [--yes]
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>
3644
+ projects list [--json]
3645
+ projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]
3646
+ projects delete <id> [--force] [--yes] [--json]
3647
+ keys list [--json]
3648
+ keys create <name> --type <admin|search> [--project <id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
3649
+ keys revoke <id> [--yes] [--json]
3650
+ indexes list [--json]
3651
+ indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
3652
+ indexes delete <uid> [--yes] [--json]
3653
+ indexes settings get <uid> [--json]
3654
+ indexes settings set <uid> --file <settings.json> [--json]
3655
+ indexes stats <uid> [--json]
3656
+ instances list [--json]
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]
3659
+ instances show|wake|park <id> [--json]
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]
3666
+ search <index> <query> [--limit N] [--filter <expr>] [--json]
3667
+ tasks list [--json]
3668
+ tasks get <id> [--json]
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]
3681
+
3682
+ Global options:
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.`);
3693
+ }
3694
+ function printCommandHelp(command, io) {
3695
+ const help = {
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]",
3702
+ indexes: [
3703
+ "Usage:",
3704
+ " headband indexes list [--json]",
3705
+ " headband indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]",
3706
+ " headband indexes delete <uid> [--yes] [--json]",
3707
+ " headband indexes settings get <uid> [--json]",
3708
+ " headband indexes settings set <uid> --file <settings.json> [--json]",
3709
+ " headband indexes stats <uid> [--json]",
3710
+ "",
3711
+ "Options for create:",
3712
+ " --instance <id> Create the index on one of your dedicated engines (ids from headband instances list).",
3713
+ " The index takes that engine's type; --engine, if given, must match it."
3714
+ ].join("\n"),
3715
+ instances: INSTANCES_USAGE,
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]",
3717
+ search: "Usage: headband search <index> <query> [--limit N] [--filter <expr>] [--json]",
3718
+ tasks: "Usage:\n headband tasks list [--json]\n headband tasks get <id> [--json]",
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]"
3721
+ };
3722
+ io.stdout(help[command] || "");
3723
+ }
3724
+ async function runLogin(flags, 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);
3730
+ }
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
+ }
3762
+ }
3763
+ }
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}).`);
3767
+ }
3768
+ async function runWhoami(flags, io) {
3769
+ const auth = authFromFlags(flags);
3770
+ const client = createApiClient(auth.url, auth);
3771
+ await client.get("/v1/indexes");
3772
+ const info = {
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,
3780
+ valid: true
3781
+ };
3782
+ if (boolFlag(flags, "--json")) {
3783
+ printJson(io, info);
3784
+ return;
3785
+ }
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");
3829
+ }
3830
+ async function runProjects(args, flags, io, confirm) {
3831
+ const action = args[0];
3832
+ const client = clientFromFlags(flags);
3833
+ if (action === "list") {
3834
+ const response = await client.get(
3835
+ "/v1/projects"
3836
+ );
3837
+ if (boolFlag(flags, "--json")) printJson(io, response);
3838
+ else printTable(io, response.data.projects, ["id", "name", "engine", "prefix", "createdAt"]);
3839
+ return;
3840
+ }
3841
+ if (action === "create") {
3842
+ const name = requiredArg(args[1], "project name");
3843
+ const engine = validateEngine(requiredFlag(flags, "--engine"));
3844
+ const response = await client.post(
3845
+ "/v1/projects",
3846
+ {
3847
+ name,
3848
+ engine
3849
+ }
3850
+ );
3851
+ if (boolFlag(flags, "--json")) printJson(io, response);
3852
+ else printTable(io, [response.data.project], ["id", "name", "engine", "prefix", "createdAt"]);
3853
+ return;
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
+ }
3865
+ printCommandHelp("projects", io);
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
+ );
3892
+ }
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) {
3907
+ const action = args[0];
3908
+ const client = clientFromFlags(flags);
3909
+ if (action === "list") {
3910
+ const response = await client.get("/v1/keys");
3911
+ if (boolFlag(flags, "--json")) printJson(io, response);
3912
+ else
3913
+ printTable(io, response.data.keys, [
3914
+ "id",
3915
+ "name",
3916
+ "type",
3917
+ "keyPrefix",
3918
+ "projectId",
3919
+ "lastUsedAt",
3920
+ "expiresAt"
3921
+ ]);
3922
+ return;
3923
+ }
3924
+ if (action === "create") {
3925
+ const name = requiredArg(args[1], "key name");
3926
+ const body = {
3927
+ name,
3928
+ type: validateKeyType(requiredFlag(flags, "--type"))
3929
+ };
3930
+ const projectId = stringFlag(flags, "--project");
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);
3936
+ const response = await client.post("/v1/keys", body);
3937
+ if (boolFlag(flags, "--json")) {
3938
+ printJson(io, response);
3939
+ return;
3940
+ }
3941
+ io.stdout(import_picocolors3.default.yellow("Store this API key now. It will not be shown again."));
3942
+ io.stdout(response.data.apiKey);
3943
+ printTable(io, [response.data.key], ["id", "name", "type", "keyPrefix", "expiresAt"]);
3944
+ return;
3945
+ }
3946
+ if (action === "revoke") {
3947
+ const id = requiredArg(args[1], "key id");
3948
+ if (!await confirm(`Revoke key ${id}?`)) return cancelled(io, flags);
3949
+ const response = await client.delete(`/v1/keys/${encode(id)}`);
3950
+ if (boolFlag(flags, "--json")) printJson(io, response);
3951
+ else io.stdout(`Revoked key ${id}.`);
3952
+ return;
3953
+ }
3954
+ printCommandHelp("keys", io);
3955
+ throw new UsageError("Unknown keys command");
3956
+ }
3957
+ async function runIndexes(args, flags, io, confirm) {
3958
+ const action = args[0];
3959
+ const client = clientFromFlags(flags);
3960
+ if (action === "list") {
3961
+ const response = await client.get("/v1/indexes");
3962
+ if (boolFlag(flags, "--json")) printJson(io, response);
3963
+ else {
3964
+ const onInstance = response.results.some((r) => r.engineIndexUid);
3965
+ printTable(io, response.results, [
3966
+ "uid",
3967
+ "primaryKey",
3968
+ "engine",
3969
+ ...onInstance ? ["instanceId", "engineIndexUid"] : [],
3970
+ "createdAt",
3971
+ "updatedAt"
3972
+ ]);
3973
+ }
3974
+ return;
3975
+ }
3976
+ if (action === "create") {
3977
+ const uid = requiredArg(args[1], "index uid");
3978
+ const body = { uid };
3979
+ const primaryKey = stringFlag(flags, "--primary-key");
3980
+ const engine = stringFlag(flags, "--engine");
3981
+ const instanceId = stringFlag(flags, "--instance");
3982
+ if (primaryKey) body.primaryKey = primaryKey;
3983
+ if (engine) body.engine = validateEngine(engine);
3984
+ if (instanceId) body.instanceId = instanceId;
3985
+ const response = await client.post("/v1/indexes", body);
3986
+ if (boolFlag(flags, "--json")) {
3987
+ printJson(io, response);
3988
+ return;
3989
+ }
3990
+ printTable(
3991
+ io,
3992
+ [response],
3993
+ response.instanceId ? ["indexUid", "engine", "instanceId", "engineIndexUid", "status"] : ["indexUid", "engine", "status"]
3994
+ );
3995
+ if (typeof response.engineIndexUid === "string") {
3996
+ io.stdout(
3997
+ `On the instance endpoint this index is named ${response.engineIndexUid}. Use that name with the engine's own client.`
3998
+ );
3999
+ }
4000
+ return;
4001
+ }
4002
+ if (action === "delete") {
4003
+ const uid = requiredArg(args[1], "index uid");
4004
+ if (!await confirm(`Delete index ${uid}?`)) return cancelled(io, flags);
4005
+ const response = await client.delete(`/v1/indexes/${encode(uid)}`);
4006
+ if (boolFlag(flags, "--json")) printJson(io, response);
4007
+ else io.stdout(`Deleted index ${uid}.`);
4008
+ return;
4009
+ }
4010
+ if (action === "settings") {
4011
+ const settingsAction = args[1];
4012
+ const uid = requiredArg(args[2], "index uid");
4013
+ if (settingsAction === "get") {
4014
+ const response = await client.get(`/v1/indexes/${encode(uid)}/settings`);
4015
+ printJson(io, response);
4016
+ return;
4017
+ }
4018
+ if (settingsAction === "set") {
4019
+ const file = requiredFlag(flags, "--file");
4020
+ const settings = JSON.parse((0, import_fs11.readFileSync)(file, "utf-8"));
4021
+ const response = await client.patch(`/v1/indexes/${encode(uid)}/settings`, settings);
4022
+ printJson(io, response);
4023
+ return;
4024
+ }
4025
+ }
4026
+ if (action === "stats") {
4027
+ const uid = requiredArg(args[1], "index uid");
4028
+ const response = await client.get(`/v1/indexes/${encode(uid)}/stats`);
4029
+ printJson(io, response);
4030
+ return;
4031
+ }
4032
+ printCommandHelp("indexes", io);
4033
+ throw new UsageError("Unknown indexes command");
4034
+ }
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);
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
+ }
4083
+ if (action === "push") {
4084
+ const file = requiredArg(args[1], "documents file");
4085
+ const index = requiredFlag(flags, "--index");
4086
+ const client = clientFromFlags(flags);
4087
+ const documents = parseDocumentsFile(file);
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.`);
4091
+ return;
4092
+ }
4093
+ if (action === "delete") {
4094
+ const docId = requiredArg(args[1], "document id");
4095
+ const index = requiredFlag(flags, "--index");
4096
+ const client = clientFromFlags(flags);
4097
+ const response = await client.delete(`/v1/documents/${encode(docId)}?index=${encode(index)}`);
4098
+ if (boolFlag(flags, "--json")) printJson(io, response);
4099
+ else io.stdout(`Deleted document ${docId}.`);
4100
+ return;
4101
+ }
4102
+ printCommandHelp("docs", io);
4103
+ throw new UsageError("Unknown docs command");
4104
+ }
4105
+ async function runSearch(args, flags, io) {
4106
+ const index = requiredArg(args[0], "search index");
4107
+ const query = args.slice(1).join(" ");
4108
+ if (!query) throw new UsageError("Missing search query");
4109
+ const client = clientFromFlags(flags);
4110
+ const body = {
4111
+ index,
4112
+ q: query
4113
+ };
4114
+ const limit = parseLimit(flags);
4115
+ const filter = stringFlag(flags, "--filter");
4116
+ if (limit) body.limit = limit;
4117
+ if (filter) body.filter = filter;
4118
+ const response = await client.post("/v1/search", body);
4119
+ if (boolFlag(flags, "--json")) {
4120
+ printJson(io, response);
4121
+ return;
4122
+ }
4123
+ const total = response.estimatedTotalHits ?? response.nbHits ?? response.hits?.length ?? 0;
4124
+ io.stdout(`${total} hits in ${response.processingTimeMs ?? 0}ms`);
4125
+ printTable(io, (response.hits || []).map(summarizeHit));
4126
+ }
4127
+ async function runTasks(args, flags, io) {
4128
+ const action = args[0];
4129
+ const client = clientFromFlags(flags);
4130
+ if (action === "list") {
4131
+ const response = await client.get("/v1/tasks");
4132
+ if (boolFlag(flags, "--json")) printJson(io, response);
4133
+ else
4134
+ printTable(io, response.results ?? [], [
4135
+ "uid",
4136
+ "indexUid",
4137
+ "type",
4138
+ "status",
4139
+ "enqueuedAt",
4140
+ "finishedAt"
4141
+ ]);
4142
+ return;
4143
+ }
4144
+ if (action === "get") {
4145
+ const id = requiredArg(args[1], "task id");
4146
+ const response = await client.get(`/v1/tasks/${encode(id)}`);
4147
+ printJson(io, response);
4148
+ return;
4149
+ }
4150
+ printCommandHelp("tasks", io);
4151
+ throw new UsageError("Unknown tasks command");
4152
+ }
4153
+ async function runSchema(args, flags, io, confirm) {
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);
4161
+ const dir = stringFlag(flags, "--dir") || DEFAULT_MIGRATIONS_DIR;
4162
+ const auth = authFromFlags(flags);
4163
+ const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4164
+ const { config, adapter } = await loadHeadbandConfig(process.cwd());
4165
+ const allowDestructive = boolFlag(flags, "--allow-destructive");
4166
+ if (action === "diff") {
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;
4171
+ return;
4172
+ }
4173
+ if (action === "generate") {
4174
+ const path2 = await runSchemaGenerate({
4175
+ config,
4176
+ client,
4177
+ dir,
4178
+ slug: stringFlag(flags, "--slug"),
4179
+ io: chatter,
4180
+ allowDestructive
4181
+ });
4182
+ if (json) printJson(io, { path: path2 ?? null });
4183
+ return;
4184
+ }
4185
+ if (action === "apply") {
4186
+ if (!adapter) {
4187
+ throw new CliError(
4188
+ "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for schema apply \u2014 it's needed to backfill documents during a reindex."
4189
+ );
4190
+ }
4191
+ if (!await confirm("Apply pending schema migrations?")) {
4192
+ return cancelled(io, flags);
4193
+ }
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
+ );
4207
+ return;
4208
+ }
4209
+ const status = await runSchemaStatus({ config, client, dir, io: chatter });
4210
+ if (json) printJson(io, status);
4211
+ }
4212
+ function parseIntFlag(flags, name) {
4213
+ const raw = stringFlag(flags, name);
4214
+ if (!raw) return void 0;
4215
+ const value = Number(raw);
4216
+ if (!Number.isInteger(value) || value <= 0) {
4217
+ throw new UsageError(`${name} must be a positive integer`);
4218
+ }
4219
+ return value;
4220
+ }
4221
+ async function runSync(args, flags, io, confirm) {
4222
+ const action = args[0];
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, {
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,
4254
+ table: stringFlag(flags, "--table"),
4255
+ write,
4256
+ cwd
4257
+ });
4258
+ if (json) printJson(io, { ddl, path: write ? (0, import_path9.join)(cwd, OUTBOX_SQL_RELATIVE_PATH) : null });
4259
+ return;
4260
+ }
4261
+ if (action === "worker") {
4262
+ const scaffold = stringFlag(flags, "--scaffold");
4263
+ if (scaffold) {
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 });
4271
+ return;
4272
+ }
4273
+ const auth = authFromFlags(flags);
4274
+ const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4275
+ const { config, adapter } = await loadHeadbandConfig(process.cwd());
4276
+ if (!adapter) {
4277
+ throw new CliError(
4278
+ "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync worker."
4279
+ );
4280
+ }
4281
+ const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
4282
+ table: config.sync?.outboxTable
4283
+ });
4284
+ try {
4285
+ const report = await runSyncWorkerCmd({
4286
+ config,
4287
+ adapter,
4288
+ client,
4289
+ outbox,
4290
+ io: chatter,
4291
+ // Only override runSyncWorkerCmd's own default (loop unless
4292
+ // --budget-ms is set) when the caller explicitly passed --loop —
4293
+ // omitting it here keeps the bare `sync worker` default unchanged.
4294
+ loop: boolFlag(flags, "--loop") ? true : void 0,
4295
+ budgetMs: parseIntFlag(flags, "--budget-ms"),
4296
+ batchSize: parseIntFlag(flags, "--batch-size"),
4297
+ reconcileEvery: parseIntFlag(flags, "--reconcile-every"),
4298
+ onReconcileTick: async () => {
4299
+ const apiClient = clientFromFlags(flags);
4300
+ await runSyncReconcile({
4301
+ config,
4302
+ adapter,
4303
+ client,
4304
+ io: chatter,
4305
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
4306
+ });
4307
+ }
4308
+ });
4309
+ if (json) printJson(io, report);
4310
+ } finally {
4311
+ await close();
4312
+ }
4313
+ return;
4314
+ }
4315
+ if (action === "reconcile") {
4316
+ const auth = authFromFlags(flags);
4317
+ const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4318
+ const apiClient = createApiClient(auth.url, auth);
4319
+ const { config, adapter } = await loadHeadbandConfig(process.cwd());
4320
+ if (!adapter) {
4321
+ throw new CliError(
4322
+ "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync reconcile."
4323
+ );
4324
+ }
4325
+ const rows = await runSyncReconcile({
4326
+ config,
4327
+ adapter,
4328
+ client,
4329
+ io: chatter,
4330
+ fix: boolFlag(flags, "--fix"),
4331
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
4332
+ });
4333
+ if (json) printJson(io, { indexes: rows });
4334
+ return;
4335
+ }
4336
+ if (action === "retry-dead-letters") {
4337
+ const { config } = await loadHeadbandConfig(process.cwd());
4338
+ const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
4339
+ table: config.sync?.outboxTable
4340
+ });
4341
+ try {
4342
+ const requeued = await runSyncRetryDeadLetters({ outbox, io: chatter });
4343
+ if (json) printJson(io, { requeued });
4344
+ } finally {
4345
+ await close();
4346
+ }
4347
+ return;
4348
+ }
4349
+ printCommandHelp("sync", io);
4350
+ throw new UsageError("Unknown sync command");
4351
+ }
4352
+ var CLI_VERSION = true ? "0.3.0" : "dev";
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
+ }
4358
+ const { flags, positional } = parseArgv(argv);
4359
+ const command = positional[0];
4360
+ const args = positional.slice(1);
4361
+ if (boolFlag(flags, "--help") || command === "help") {
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);
4365
+ else printHelp(context.io);
4366
+ return;
4367
+ }
4368
+ const confirm = (message) => confirmOrSkip(message, flags, context.ask);
4369
+ switch (command || "init") {
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
+ }
4377
+ await init({ yes: boolFlag(flags, "--yes") });
4378
+ return;
4379
+ case "login":
4380
+ await runLogin(flags, context.io);
4381
+ return;
4382
+ case "logout":
4383
+ await runLogout(flags, context.io, confirm);
4384
+ return;
4385
+ case "whoami":
4386
+ await runWhoami(flags, context.io);
4387
+ return;
4388
+ case "profiles":
4389
+ await runProfiles(args, flags, context.io);
4390
+ return;
4391
+ case "projects":
4392
+ await runProjects(args, flags, context.io, confirm);
4393
+ return;
4394
+ case "keys":
4395
+ await runKeys(args, flags, context.io, confirm);
4396
+ return;
4397
+ case "indexes":
4398
+ await runIndexes(args, flags, context.io, confirm);
4399
+ return;
4400
+ case "docs":
4401
+ await runDocs(args, flags, context);
4402
+ return;
4403
+ case "search":
4404
+ await runSearch(args, flags, context.io);
4405
+ return;
4406
+ case "tasks":
4407
+ await runTasks(args, flags, context.io);
4408
+ return;
4409
+ case "schema":
4410
+ await runSchema(args, flags, context.io, confirm);
4411
+ return;
4412
+ case "sync":
4413
+ await runSync(args, flags, context.io, confirm);
4414
+ return;
4415
+ case "instances":
4416
+ await runInstances(args, flags, context.io, clientFromFlags(flags), confirm);
4417
+ return;
4418
+ default:
4419
+ printHelp(context.io);
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);
4437
+ }
4438
+ }
4439
+ function stdoutLineWriter(line) {
4440
+ if (process.stdout.write(`${line}
4441
+ `)) return;
4442
+ return new Promise((resolve) => process.stdout.once("drain", () => resolve()));
4443
+ }
4444
+ function isDirectRun() {
4445
+ return typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
4446
+ }
4447
+ if (isDirectRun()) {
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();
4456
+ });
255
4457
  }
256
- main().catch((err) => {
257
- console.error(err);
258
- process.exit(1);
4458
+ // Annotate the CommonJS export names for ESM import in node:
4459
+ 0 && (module.exports = {
4460
+ CLI_VERSION,
4461
+ main,
4462
+ parseArgv,
4463
+ parseExpires,
4464
+ parsePermissions,
4465
+ run,
4466
+ stdinIsInteractive
259
4467
  });