@headband/cli 0.1.0 → 0.2.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 +130 -9
  2. package/dist/index.js +2543 -70
  3. package/package.json +6 -2
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,18 @@ 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
+ parseArgv: () => parseArgv,
35
+ run: () => run
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+ var import_fs8 = require("fs");
39
+ var import_prompts2 = __toESM(require("prompts"));
40
+ var import_picocolors3 = __toESM(require("picocolors"));
25
41
 
26
42
  // src/init.ts
27
43
  var import_prompts = __toESM(require("prompts"));
@@ -32,6 +48,19 @@ var import_path2 = require("path");
32
48
  var import_fs = require("fs");
33
49
  var import_child_process = require("child_process");
34
50
  var import_path = require("path");
51
+ function isPublicSearchKey(value) {
52
+ return /^hb_src_[A-Za-z0-9_-]+$/.test(value.trim());
53
+ }
54
+ function validatePublicSearchKey(value) {
55
+ if (!value.trim()) return "Search API key is required";
56
+ if (value.startsWith("hb_adm_")) {
57
+ return "Admin keys must stay server-side. Create or paste an hb_src_ search key for browser config.";
58
+ }
59
+ if (!isPublicSearchKey(value)) {
60
+ return "Search key must start with hb_src_";
61
+ }
62
+ return true;
63
+ }
35
64
  function detectPackageManager(cwd) {
36
65
  if ((0, import_fs.existsSync)((0, import_path.join)(cwd, "pnpm-lock.yaml"))) return "pnpm";
37
66
  if ((0, import_fs.existsSync)((0, import_path.join)(cwd, "yarn.lock"))) return "yarn";
@@ -50,8 +79,8 @@ function getInstallCommand(pm, pkg) {
50
79
  return `npm install ${pkg}`;
51
80
  }
52
81
  }
53
- function runCommand(command2, cwd) {
54
- (0, import_child_process.execSync)(command2, { cwd, stdio: "inherit" });
82
+ function runCommand(command, cwd) {
83
+ (0, import_child_process.execSync)(command, { cwd, stdio: "inherit" });
55
84
  }
56
85
  function hasSrcDir(cwd) {
57
86
  return (0, import_fs.existsSync)((0, import_path.join)(cwd, "src"));
@@ -64,6 +93,10 @@ function writeFileSafe(filePath, content) {
64
93
  (0, import_fs.writeFileSync)(filePath, content, "utf-8");
65
94
  }
66
95
  function generateConfig(opts) {
96
+ const keyValidation = validatePublicSearchKey(opts.searchKey);
97
+ if (keyValidation !== true) {
98
+ throw new Error(keyValidation);
99
+ }
67
100
  return `export const headbandConfig = {
68
101
  url: "${opts.url}",
69
102
  searchKey: process.env.NEXT_PUBLIC_HEADBAND_KEY || "${opts.searchKey}",
@@ -127,15 +160,9 @@ async function init(options = {}) {
127
160
  {
128
161
  type: "text",
129
162
  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
- }
163
+ message: "Paste your browser-safe search API key",
164
+ hint: "hb_src_... only. Keep hb_adm_ admin keys server-side.",
165
+ validate: validatePublicSearchKey
139
166
  },
140
167
  {
141
168
  type: "text",
@@ -154,9 +181,7 @@ async function init(options = {}) {
154
181
  }
155
182
  const pm = detectPackageManager(cwd);
156
183
  console.log();
157
- console.log(
158
- import_picocolors.default.dim(` Detected package manager: ${import_picocolors.default.bold(pm)}`)
159
- );
184
+ console.log(import_picocolors.default.dim(` Detected package manager: ${import_picocolors.default.bold(pm)}`));
160
185
  console.log();
161
186
  console.log(import_picocolors.default.cyan(" Installing @headband/react..."));
162
187
  console.log();
@@ -165,21 +190,13 @@ async function init(options = {}) {
165
190
  runCommand(installCmd, cwd);
166
191
  } catch {
167
192
  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
- );
193
+ console.log(import_picocolors.default.yellow(" Could not install @headband/react automatically."));
194
+ console.log(import_picocolors.default.dim(` Run manually: ${getInstallCommand(pm, "@headband/react")}`));
178
195
  }
179
196
  const useSrc = hasSrcDir(cwd);
180
197
  const componentsDir = useSrc ? (0, import_path2.join)(cwd, "src", "components") : (0, import_path2.join)(cwd, "components");
181
198
  const configImportPath = useSrc ? "../headband.config" : "../headband.config";
182
- const configPath = (0, import_path2.join)(cwd, "headband.config.ts");
199
+ const configPath = useSrc ? (0, import_path2.join)(cwd, "src", "headband.config.ts") : (0, import_path2.join)(cwd, "headband.config.ts");
183
200
  const configContent = generateConfig({ url, searchKey, defaultIndex });
184
201
  writeFileSafe(configPath, configContent);
185
202
  const componentPath = (0, import_path2.join)(componentsDir, "headband-search.tsx");
@@ -192,68 +209,2524 @@ async function init(options = {}) {
192
209
  console.log(import_picocolors.default.green(import_picocolors.default.bold(" Headband is ready!")));
193
210
  console.log();
194
211
  console.log(import_picocolors.default.dim(" Created files:"));
195
- console.log(` ${import_picocolors.default.cyan("headband.config.ts")} \u2014 search configuration`);
212
+ console.log(
213
+ ` ${import_picocolors.default.cyan(useSrc ? "src/headband.config.ts" : "headband.config.ts")} \u2014 search configuration`
214
+ );
196
215
  console.log(
197
216
  ` ${import_picocolors.default.cyan(
198
217
  useSrc ? "src/components/headband-search.tsx" : "components/headband-search.tsx"
199
218
  )} \u2014 example search component`
200
219
  );
201
220
  console.log();
202
- console.log(import_picocolors.default.dim(" Recommended: add your API key to .env.local:"));
221
+ console.log(import_picocolors.default.dim(" Recommended: add your browser-safe search key to .env.local:"));
203
222
  console.log();
204
- console.log(
205
- import_picocolors.default.yellow(` NEXT_PUBLIC_HEADBAND_KEY=${searchKey}`)
206
- );
223
+ console.log(import_picocolors.default.yellow(` NEXT_PUBLIC_HEADBAND_KEY=${searchKey}`));
207
224
  console.log();
208
225
  console.log(import_picocolors.default.dim(" Next steps:"));
209
226
  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`
227
+ console.log(` 2. Keep admin keys server-side; only expose ${import_picocolors.default.cyan("hb_src_")} keys here`);
228
+ console.log(` 3. Check the docs at ${import_picocolors.default.cyan("https://headband.dev/docs")}`);
229
+ console.log();
230
+ }
231
+
232
+ // src/auth.ts
233
+ var import_child_process2 = require("child_process");
234
+ var import_os2 = require("os");
235
+ var import_picocolors2 = __toESM(require("picocolors"));
236
+
237
+ // src/errors.ts
238
+ var CliError = class extends Error {
239
+ constructor(message) {
240
+ super(message);
241
+ this.name = "CliError";
242
+ }
243
+ };
244
+ var ApiError = class extends CliError {
245
+ constructor(message, status, code) {
246
+ super(message);
247
+ this.name = "ApiError";
248
+ this.status = status;
249
+ this.code = code;
250
+ }
251
+ };
252
+ function partialFailureSummary(failure) {
253
+ const first = failure.errors[0];
254
+ if (!first) return failure.message;
255
+ const id = first.id === null ? "a document" : `document "${first.id}"`;
256
+ return `${failure.message} (first: ${id}${first.reason ? `: ${first.reason}` : ""})`;
257
+ }
258
+
259
+ // src/config-store.ts
260
+ var import_fs2 = require("fs");
261
+ var import_path3 = require("path");
262
+ var import_os = require("os");
263
+ var DEFAULT_URL = "https://headband.dev";
264
+ function normalizeUrl(url) {
265
+ return url.replace(/\/+$/, "");
266
+ }
267
+ function getConfigDir() {
268
+ 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");
269
+ }
270
+ function getCredentialsPath() {
271
+ return (0, import_path3.join)(getConfigDir(), "credentials.json");
272
+ }
273
+ function readCredentials() {
274
+ const path = getCredentialsPath();
275
+ if (!(0, import_fs2.existsSync)(path)) {
276
+ return { profiles: {} };
277
+ }
278
+ try {
279
+ const parsed = JSON.parse((0, import_fs2.readFileSync)(path, "utf-8"));
280
+ return {
281
+ profiles: parsed.profiles && typeof parsed.profiles === "object" ? parsed.profiles : {}
282
+ };
283
+ } catch {
284
+ throw new CliError(`Could not read credentials at ${path}`);
285
+ }
286
+ }
287
+ function writeCredentials(credentials) {
288
+ const path = getCredentialsPath();
289
+ (0, import_fs2.mkdirSync)((0, import_path3.dirname)(path), { recursive: true, mode: 448 });
290
+ (0, import_fs2.writeFileSync)(path, `${JSON.stringify(credentials, null, 2)}
291
+ `, {
292
+ encoding: "utf-8",
293
+ mode: 384
294
+ });
295
+ (0, import_fs2.chmodSync)(path, 384);
296
+ }
297
+ function saveProfile(url, profile) {
298
+ const credentials = readCredentials();
299
+ credentials.profiles[normalizeUrl(url)] = profile;
300
+ writeCredentials(credentials);
301
+ }
302
+ function removeProfile(url) {
303
+ const credentials = readCredentials();
304
+ const normalized = normalizeUrl(url);
305
+ const existing = credentials.profiles[normalized];
306
+ delete credentials.profiles[normalized];
307
+ if (Object.keys(credentials.profiles).length === 0) {
308
+ const path = getCredentialsPath();
309
+ if ((0, import_fs2.existsSync)(path)) {
310
+ (0, import_fs2.rmSync)(path);
311
+ }
312
+ return existing;
313
+ }
314
+ writeCredentials(credentials);
315
+ return existing;
316
+ }
317
+ function resolveUrl(explicitUrl) {
318
+ if (explicitUrl) return normalizeUrl(explicitUrl);
319
+ const credentials = readCredentials();
320
+ const urls = Object.keys(credentials.profiles);
321
+ if (urls.length === 1) return urls[0];
322
+ return DEFAULT_URL;
323
+ }
324
+ function getProfile(url) {
325
+ const resolvedUrl = resolveUrl(url);
326
+ const profile = readCredentials().profiles[resolvedUrl];
327
+ if (!profile) {
328
+ throw new CliError(
329
+ `Not logged in for ${resolvedUrl}. Run: headband login --url ${resolvedUrl}`
330
+ );
331
+ }
332
+ return { url: resolvedUrl, profile };
333
+ }
334
+
335
+ // src/api.ts
336
+ async function readJson(response) {
337
+ const text = await response.text();
338
+ if (!text) return {};
339
+ try {
340
+ return JSON.parse(text);
341
+ } catch {
342
+ return text;
343
+ }
344
+ }
345
+ async function apiRequest(baseUrl, path, init2 = {}) {
346
+ const headers = new Headers(init2.headers);
347
+ headers.set("accept", "application/json");
348
+ if (init2.body !== void 0 && !headers.has("content-type")) {
349
+ headers.set("content-type", "application/json");
350
+ }
351
+ if (init2.apiKey) {
352
+ headers.set("authorization", `Bearer ${init2.apiKey}`);
353
+ }
354
+ const response = await fetch(`${normalizeUrl(baseUrl)}${path}`, {
355
+ ...init2,
356
+ headers,
357
+ body: init2.body === void 0 || typeof init2.body === "string" ? init2.body : JSON.stringify(init2.body)
358
+ });
359
+ const body = await readJson(response);
360
+ if (!response.ok) {
361
+ const errorBody = body && typeof body === "object" ? body : {};
362
+ throw new ApiError(
363
+ errorBody.error || `Request failed with status ${response.status}`,
364
+ response.status,
365
+ errorBody.code
366
+ );
367
+ }
368
+ return body;
369
+ }
370
+ function createApiClient(url, profile) {
371
+ const request = (path, init2 = {}) => apiRequest(url, path, { ...init2, apiKey: profile.apiKey });
372
+ return {
373
+ url: normalizeUrl(url),
374
+ get: (path) => request(path, { method: "GET" }),
375
+ post: (path, body) => request(path, { method: "POST", body }),
376
+ patch: (path, body) => request(path, { method: "PATCH", body }),
377
+ delete: (path) => request(path, { method: "DELETE" })
378
+ };
379
+ }
380
+
381
+ // src/auth.ts
382
+ var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
383
+ function openBrowser(url) {
384
+ try {
385
+ if (process.platform === "darwin") {
386
+ (0, import_child_process2.spawn)("open", [url], { detached: true, stdio: "ignore" }).unref();
387
+ } else if (process.platform === "win32") {
388
+ (0, import_child_process2.spawn)("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
389
+ } else {
390
+ (0, import_child_process2.spawn)("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
391
+ }
392
+ } catch {
393
+ }
394
+ }
395
+ async function runDeviceFlow(url, io, options = {}) {
396
+ const baseUrl = normalizeUrl(url);
397
+ const sleep = options.sleep || defaultSleep;
398
+ const now = options.now || Date.now;
399
+ const device = await apiRequest(baseUrl, "/api/cli/device", {
400
+ method: "POST",
401
+ body: { name: options.deviceName || (0, import_os2.hostname)() }
402
+ });
403
+ io.stdout();
404
+ io.stdout(import_picocolors2.default.bold("Headband login"));
405
+ io.stdout(`Code: ${import_picocolors2.default.bold(import_picocolors2.default.cyan(device.data.userCode))}`);
406
+ io.stdout(`Open: ${device.data.verificationUriComplete}`);
407
+ io.stdout();
408
+ (options.openBrowser || openBrowser)(device.data.verificationUriComplete);
409
+ let interval = device.data.interval;
410
+ const deadline = now() + device.data.expiresIn * 1e3;
411
+ while (now() < deadline) {
412
+ await sleep(interval * 1e3);
413
+ try {
414
+ const token = await apiRequest(baseUrl, "/api/cli/token", {
415
+ method: "POST",
416
+ body: { deviceCode: device.data.deviceCode }
417
+ });
418
+ return token.data;
419
+ } catch (error) {
420
+ if (!(error instanceof ApiError)) throw error;
421
+ if (error.code === "authorization_pending") continue;
422
+ if (error.code === "slow_down") {
423
+ interval += 5;
424
+ continue;
425
+ }
426
+ if (error.code === "access_denied") {
427
+ throw new CliError("Login denied.");
428
+ }
429
+ if (error.code === "expired_token") {
430
+ throw new CliError("Login expired. Re-run headband login.");
431
+ }
432
+ if (error.code === "invalid_device_code") {
433
+ throw new CliError("Invalid device code. Re-run headband login.");
434
+ }
435
+ throw error;
436
+ }
437
+ }
438
+ throw new CliError("Login expired. Re-run headband login.");
439
+ }
440
+ async function login(url, io, options = {}) {
441
+ const token = await runDeviceFlow(url, io, options);
442
+ saveProfile(url, {
443
+ apiKey: token.apiKey,
444
+ keyId: token.keyId,
445
+ keyPrefix: token.keyPrefix,
446
+ orgId: token.orgId,
447
+ orgName: token.orgName,
448
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
449
+ });
450
+ io.stdout(import_picocolors2.default.green(`Logged in to ${normalizeUrl(url)} as ${token.orgName} (${token.keyPrefix})`));
451
+ }
452
+
453
+ // src/config-load.ts
454
+ var import_fs3 = require("fs");
455
+ var import_path4 = require("path");
456
+ var import_url = require("url");
457
+ var import_jiti = require("jiti");
458
+ var CONFIG_FILENAMES = [
459
+ "headband.config.ts",
460
+ "headband.config.mts",
461
+ "headband.config.js",
462
+ "headband.config.mjs",
463
+ "headband.config.cjs"
464
+ ];
465
+ function findConfigFile(cwd) {
466
+ for (const name of CONFIG_FILENAMES) {
467
+ const path = (0, import_path4.join)(cwd, name);
468
+ if ((0, import_fs3.existsSync)(path)) return path;
469
+ }
470
+ return void 0;
471
+ }
472
+ function isSyncConfig(value) {
473
+ return typeof value === "object" && value !== null && Array.isArray(value.indexes);
474
+ }
475
+ async function loadHeadbandConfig(cwd) {
476
+ const path = findConfigFile(cwd);
477
+ if (!path) {
478
+ throw new CliError(
479
+ `No headband.config.ts found in ${cwd}. Run "headband init" or create one that exports a Headband sync config.`
480
+ );
481
+ }
482
+ const jiti = (0, import_jiti.createJiti)((0, import_url.pathToFileURL)((0, import_path4.join)(cwd, "headband-cli.js")).href, {
483
+ interopDefault: true,
484
+ moduleCache: false,
485
+ fsCache: false
486
+ });
487
+ let mod;
488
+ try {
489
+ mod = await jiti.import(path);
490
+ } catch (err) {
491
+ throw new CliError(
492
+ `Failed to load ${path}: ${err instanceof Error ? err.message : String(err)}`
493
+ );
494
+ }
495
+ const candidate = mod.default ?? mod;
496
+ if (!isSyncConfig(candidate)) {
497
+ throw new CliError(`${path} does not export a valid Headband sync config (missing "indexes").`);
498
+ }
499
+ const adapter = mod.adapter;
500
+ return { config: candidate, adapter, path };
501
+ }
502
+
503
+ // src/docs.ts
504
+ var import_path5 = require("path");
505
+ var import_fs4 = require("fs");
506
+ var DOCS_BATCH_SIZE = 1e3;
507
+ function parseDocumentsFile(path) {
508
+ const content = (0, import_fs4.readFileSync)(path, "utf-8");
509
+ const ext = (0, import_path5.extname)(path).toLowerCase();
510
+ if (ext === ".jsonl") {
511
+ return content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
512
+ }
513
+ if (ext === ".json") {
514
+ const parsed = JSON.parse(content);
515
+ if (!Array.isArray(parsed)) {
516
+ throw new CliError("JSON document file must contain an array.");
517
+ }
518
+ return parsed;
519
+ }
520
+ throw new CliError("Document file must be .json or .jsonl.");
521
+ }
522
+ async function pushDocuments(client, index, documents, io) {
523
+ let pushed = 0;
524
+ for (let offset = 0; offset < documents.length; offset += DOCS_BATCH_SIZE) {
525
+ const batch = documents.slice(offset, offset + DOCS_BATCH_SIZE);
526
+ await client.post("/v1/bulk", { index, documents: batch });
527
+ pushed += batch.length;
528
+ io.stdout(`Pushed ${pushed}/${documents.length} documents`);
529
+ }
530
+ return pushed;
531
+ }
532
+
533
+ // ../../src/lib/density/catalog-constants.ts
534
+ function formatMemory(mb) {
535
+ return mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
536
+ }
537
+
538
+ // ../../src/lib/density/snippets.ts
539
+ var ADMIN_NOTE = "Creating an index and adding a document need an admin key. A search key can only search.";
540
+ function connectSnippets(engine, endpoint) {
541
+ if (engine === "elasticsearch") {
542
+ return {
543
+ packageName: "@elastic/elasticsearch",
544
+ // Client 9 sends a version 9 Accept header, which an 8.x server refuses.
545
+ install: "npm install @elastic/elasticsearch@8",
546
+ node: [
547
+ 'import { Client } from "@elastic/elasticsearch";',
548
+ "",
549
+ "const client = new Client({",
550
+ ` node: "${endpoint}",`,
551
+ " auth: { apiKey: process.env.HEADBAND_API_KEY },",
552
+ "});",
553
+ "",
554
+ `// ${ADMIN_NOTE}`,
555
+ 'if (!(await client.indices.exists({ index: "movies" }))) {',
556
+ ' await client.indices.create({ index: "movies" });',
557
+ "}",
558
+ 'await client.index({ index: "movies", id: "1", document: { title: "The Matrix" }, refresh: true });',
559
+ "",
560
+ 'const results = await client.search({ index: "movies", query: { match: { title: "matrix" } } });',
561
+ "console.log(results.hits.hits);"
562
+ ].join("\n"),
563
+ curl: [
564
+ `# ${ADMIN_NOTE}`,
565
+ "# On a second run this answers that the index already exists. That is fine.",
566
+ `curl -X PUT "${endpoint}/movies" \\`,
567
+ ' -H "Authorization: ApiKey $HEADBAND_API_KEY"',
568
+ "",
569
+ `curl -X PUT "${endpoint}/movies/_doc/1?refresh=true" \\`,
570
+ ' -H "Authorization: ApiKey $HEADBAND_API_KEY" \\',
571
+ ' -H "Content-Type: application/json" \\',
572
+ ` -d '{"title":"The Matrix"}'`,
573
+ "",
574
+ `curl "${endpoint}/movies/_search" \\`,
575
+ ' -H "Authorization: ApiKey $HEADBAND_API_KEY" \\',
576
+ ' -H "Content-Type: application/json" \\',
577
+ ` -d '{"query":{"match":{"title":"matrix"}}}'`
578
+ ].join("\n")
579
+ };
580
+ }
581
+ if (engine === "typesense") {
582
+ let url;
583
+ try {
584
+ url = new URL(endpoint);
585
+ } catch {
586
+ url = { hostname: endpoint, protocol: "https:", port: "" };
587
+ }
588
+ const protocol = url.protocol.replace(":", "");
589
+ const port = url.port ? Number(url.port) : protocol === "https" ? 443 : 80;
590
+ return {
591
+ packageName: "typesense",
592
+ install: "npm install typesense",
593
+ node: [
594
+ 'import Typesense from "typesense";',
595
+ "",
596
+ "const client = new Typesense.Client({",
597
+ ` nodes: [{ host: "${url.hostname}", port: ${port}, protocol: "${protocol}" }],`,
598
+ " apiKey: process.env.HEADBAND_API_KEY,",
599
+ "});",
600
+ "",
601
+ `// ${ADMIN_NOTE}`,
602
+ "// A 409 means the collection exists from an earlier run.",
603
+ "await client.collections().create({",
604
+ ' name: "movies",',
605
+ ' fields: [{ name: "title", type: "string" }],',
606
+ "}).catch((err) => {",
607
+ " if (err.httpStatus !== 409) throw err;",
608
+ "});",
609
+ 'await client.collections("movies").documents().upsert({ id: "1", title: "The Matrix" });',
610
+ "",
611
+ 'const results = await client.collections("movies").documents().search({ q: "matrix", query_by: "title" });',
612
+ "console.log(results.hits);"
613
+ ].join("\n"),
614
+ curl: [
615
+ `# ${ADMIN_NOTE}`,
616
+ "# On a second run this answers that the collection already exists. That is fine.",
617
+ `curl "${endpoint}/collections" \\`,
618
+ ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY" \\',
619
+ ' -H "Content-Type: application/json" \\',
620
+ ` -d '{"name":"movies","fields":[{"name":"title","type":"string"}]}'`,
621
+ "",
622
+ `curl "${endpoint}/collections/movies/documents?action=upsert" \\`,
623
+ ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY" \\',
624
+ ' -H "Content-Type: application/json" \\',
625
+ ` -d '{"id":"1","title":"The Matrix"}'`,
626
+ "",
627
+ `curl "${endpoint}/collections/movies/documents/search?q=matrix&query_by=title" \\`,
628
+ ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY"'
629
+ ].join("\n")
630
+ };
631
+ }
632
+ return {
633
+ packageName: "meilisearch",
634
+ install: "npm install meilisearch",
635
+ node: [
636
+ 'import { Meilisearch } from "meilisearch";',
637
+ "",
638
+ "const client = new Meilisearch({",
639
+ ` host: "${endpoint}",`,
640
+ " apiKey: process.env.HEADBAND_API_KEY,",
641
+ "});",
642
+ "",
643
+ `// ${ADMIN_NOTE}`,
644
+ "// waitTask() waits until Meilisearch has applied the change. On a second run",
645
+ "// the create task fails because the index exists, which is fine.",
646
+ 'await client.createIndex("movies", { primaryKey: "id" }).waitTask();',
647
+ 'await client.index("movies").addDocuments([{ id: 1, title: "The Matrix" }]).waitTask();',
648
+ "",
649
+ 'const results = await client.index("movies").search("matrix");',
650
+ "console.log(results.hits);"
651
+ ].join("\n"),
652
+ curl: [
653
+ `# ${ADMIN_NOTE}`,
654
+ "# On a second run this task fails because the index already exists. That is fine.",
655
+ `curl "${endpoint}/indexes" \\`,
656
+ ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
657
+ ' -H "Content-Type: application/json" \\',
658
+ ` -d '{"uid":"movies","primaryKey":"id"}'`,
659
+ "",
660
+ `curl "${endpoint}/indexes/movies/documents" \\`,
661
+ ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
662
+ ' -H "Content-Type: application/json" \\',
663
+ ` -d '[{"id":1,"title":"The Matrix"}]'`,
664
+ "",
665
+ "# Meilisearch applies writes in the background. Give it a moment.",
666
+ "sleep 1",
667
+ "",
668
+ `curl "${endpoint}/indexes/movies/search" \\`,
669
+ ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
670
+ ' -H "Content-Type: application/json" \\',
671
+ ` -d '{"q":"matrix"}'`
672
+ ].join("\n")
673
+ };
674
+ }
675
+
676
+ // src/output.ts
677
+ var defaultIO = {
678
+ stdout: (message = "") => console.log(message),
679
+ stderr: (message = "") => console.error(message)
680
+ };
681
+ function printJson(io, value) {
682
+ io.stdout(JSON.stringify(value, null, 2));
683
+ }
684
+ function formatTable(rows, columns) {
685
+ if (rows.length === 0) return "No results.";
686
+ const selectedColumns = columns || Object.keys(rows[0]);
687
+ const widths = selectedColumns.map(
688
+ (column) => Math.max(
689
+ column.length,
690
+ ...rows.map(
691
+ (row) => String(row[column] === null || row[column] === void 0 ? "" : row[column]).length
692
+ )
693
+ )
212
694
  );
213
- console.log(
214
- ` 3. Check the docs at ${import_picocolors.default.cyan("https://headband.dev/docs")}`
695
+ const renderRow = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd();
696
+ return [
697
+ renderRow(selectedColumns),
698
+ renderRow(widths.map((width) => "-".repeat(width))),
699
+ ...rows.map(
700
+ (row) => renderRow(
701
+ selectedColumns.map(
702
+ (column) => String(row[column] === null || row[column] === void 0 ? "" : row[column])
703
+ )
704
+ )
705
+ )
706
+ ].join("\n");
707
+ }
708
+ function printTable(io, rows, columns) {
709
+ io.stdout(formatTable(rows, columns));
710
+ }
711
+ function summarizeHit(hit) {
712
+ const result = {};
713
+ for (const key of Object.keys(hit).slice(0, 5)) {
714
+ const value = hit[key];
715
+ result[key] = value && typeof value === "object" ? JSON.stringify(value) : value;
716
+ }
717
+ return result;
718
+ }
719
+
720
+ // src/instances.ts
721
+ var ENGINES = ["meilisearch", "elasticsearch", "typesense"];
722
+ var CLIENTS = ["node", "curl"];
723
+ var COLUMNS = ["id", "name", "engine", "lane", "memory", "region", "state"];
724
+ var INSTANCES_USAGE = [
725
+ "Usage:",
726
+ " headband instances list [--json]",
727
+ " headband instances create <meilisearch|elasticsearch|typesense> [--name <name>] [--lane <serverless|always_on>]",
728
+ " [--memory <512m|1g|...>] [--region <region>] [--idle-timeout <seconds>] [--json]",
729
+ " headband instances show <id> [--json]",
730
+ " headband instances wake <id> [--json]",
731
+ " headband instances park <id> [--json]",
732
+ " headband instances delete <id> [--yes] [--json]",
733
+ " headband instances connect <id> [--client <node|curl>]"
734
+ ].join("\n");
735
+ function parseMemory(raw) {
736
+ const m = /^(\d+(?:\.\d+)?)\s*(m|mb|g|gb)?$/i.exec(raw.trim());
737
+ if (!m) throw new CliError("--memory must look like 512m, 1g or 2048");
738
+ const value = Number(m[1]);
739
+ const mb = (m[2] ?? "m").toLowerCase().startsWith("g") ? value * 1024 : value;
740
+ if (!Number.isInteger(mb) || mb <= 0)
741
+ throw new CliError("--memory must be a whole number of megabytes");
742
+ return mb;
743
+ }
744
+ function formatMemoryMb(mb) {
745
+ return mb % 512 === 0 ? formatMemory(mb) : `${mb} MB`;
746
+ }
747
+ function parseLane(raw) {
748
+ const lane = raw.replace(/-/g, "_");
749
+ if (lane !== "serverless" && lane !== "always_on")
750
+ throw new CliError("--lane must be serverless or always_on");
751
+ return lane;
752
+ }
753
+ function parsePositiveInt(raw, name) {
754
+ const value = Number(raw);
755
+ if (!Number.isInteger(value) || value <= 0)
756
+ throw new CliError(`${name} must be a positive integer`);
757
+ return value;
758
+ }
759
+ function stateLabel(i) {
760
+ if (i.changeRejected) return `${i.state} (change rejected)`;
761
+ return i.pendingChanges ? `${i.state} (applying changes)` : i.state;
762
+ }
763
+ function summaryRow(i) {
764
+ return {
765
+ id: i.id,
766
+ name: i.name,
767
+ engine: i.engine,
768
+ lane: i.lane,
769
+ memory: formatMemoryMb(i.memoryMb),
770
+ region: i.region,
771
+ state: stateLabel(i)
772
+ };
773
+ }
774
+ function requireId(args) {
775
+ const id = args[1];
776
+ if (!id) throw new CliError("Missing engine id");
777
+ return id;
778
+ }
779
+ function friendly(err) {
780
+ if (err instanceof ApiError) {
781
+ if (err.status === 404 && err.code === "not_found") {
782
+ throw new CliError("Dedicated engines are not enabled for this organization yet.");
783
+ }
784
+ if (err.status === 403 && err.code === "permission_denied") {
785
+ throw new CliError(
786
+ "This CLI key cannot manage engines. Run headband login again to get a key that can."
787
+ );
788
+ }
789
+ }
790
+ throw err;
791
+ }
792
+ async function runInstances(args, flags, io, client, confirm) {
793
+ const action = args[0];
794
+ const json = flags["--json"] === true;
795
+ const str = (name) => typeof flags[name] === "string" ? flags[name] : void 0;
796
+ const path = (id, suffix = "") => `/v1/instances/${encodeURIComponent(id)}${suffix}`;
797
+ try {
798
+ if (action === "list") {
799
+ const res = await client.get("/v1/instances");
800
+ if (json) return printJson(io, res);
801
+ if (res.data.length === 0) {
802
+ io.stdout("No engines yet. Create one with: headband instances create meilisearch");
803
+ return;
804
+ }
805
+ printTable(
806
+ io,
807
+ res.data.map((i) => ({
808
+ ...summaryRow(i),
809
+ "wake p95": i.wakeP95Ms === null || i.wakeP95Ms === void 0 ? "" : `${Math.round(i.wakeP95Ms)} ms`
810
+ })),
811
+ [...COLUMNS, "wake p95"]
812
+ );
813
+ return;
814
+ }
815
+ if (action === "create") {
816
+ const engine = args[1];
817
+ if (!engine || !ENGINES.includes(engine)) {
818
+ throw new CliError("Engine must be one of: meilisearch, elasticsearch, typesense");
819
+ }
820
+ const body = { engine };
821
+ const name = str("--name");
822
+ const lane = str("--lane");
823
+ const memory = str("--memory");
824
+ const region = str("--region");
825
+ const idle = str("--idle-timeout");
826
+ if (name) body.name = name;
827
+ if (lane) body.lane = parseLane(lane);
828
+ if (memory) body.memoryMb = parseMemory(memory);
829
+ if (region) body.region = region;
830
+ if (idle) body.idleTimeoutS = parsePositiveInt(idle, "--idle-timeout");
831
+ const res = await client.post("/v1/instances", body);
832
+ if (json) return printJson(io, res);
833
+ printTable(io, [summaryRow(res.data)], COLUMNS);
834
+ io.stdout(`Endpoint: ${res.data.endpoint}`);
835
+ io.stdout(`Connect a client: headband instances connect ${res.data.id}`);
836
+ return;
837
+ }
838
+ if (action === "show") {
839
+ const id = requireId(args);
840
+ const res = await client.get(path(id));
841
+ if (json) return printJson(io, res);
842
+ const i = res.data;
843
+ const lines = [
844
+ ["id", i.id],
845
+ ["name", i.name],
846
+ ["engine", `${i.engine} ${i.engineVersion}`],
847
+ ["lane", i.lane],
848
+ ["memory", formatMemoryMb(i.memoryMb)],
849
+ ["region", i.region],
850
+ ["state", stateLabel(i)],
851
+ ["endpoint", i.endpoint],
852
+ ["idle timeout", i.lane === "serverless" ? `${i.idleTimeoutS} s` : "n/a"],
853
+ ["default", i.isDefault ? "yes" : "no"],
854
+ ["last backup", i.lastBackupAt ?? "never"],
855
+ ["last wake", i.lastWakeAt ?? "never"]
856
+ ];
857
+ const width = Math.max(...lines.map(([k]) => k.length));
858
+ for (const [k, v] of lines) io.stdout(`${k.padEnd(width)} ${v}`);
859
+ return;
860
+ }
861
+ if (action === "wake") {
862
+ const id = requireId(args);
863
+ const res = await client.post(path(id, "/wake"));
864
+ if (json) return printJson(io, res);
865
+ const { wake } = res.data;
866
+ io.stdout(
867
+ wake.via === "running" ? `Engine ${id} is already running.` : `Engine ${id} is ${wake.state} (woke via ${wake.via.replace(/_/g, " ")} in ${wake.ms} ms).`
868
+ );
869
+ return;
870
+ }
871
+ if (action === "park") {
872
+ const id = requireId(args);
873
+ const res = await client.post(path(id, "/park"));
874
+ if (json) return printJson(io, res);
875
+ io.stdout(`Engine ${id} is ${res.data.state}.`);
876
+ return;
877
+ }
878
+ if (action === "delete") {
879
+ const id = requireId(args);
880
+ if (!await confirm(`Delete engine ${id}? This removes its data and backups.`)) {
881
+ io.stdout("Cancelled.");
882
+ return;
883
+ }
884
+ const res = await client.delete(path(id));
885
+ if (json) return printJson(io, res);
886
+ io.stdout(
887
+ res.data.deleted ? `Deleted engine ${id}.` : `Engine ${id} is being deleted. It finishes in the background.`
888
+ );
889
+ return;
890
+ }
891
+ if (action === "connect") {
892
+ const id = requireId(args);
893
+ const which = str("--client");
894
+ if (which && !CLIENTS.includes(which)) throw new CliError("--client must be node or curl");
895
+ const res = await client.get(path(id));
896
+ const s = connectSnippets(res.data.engine, res.data.endpoint);
897
+ io.stdout(`Endpoint: ${res.data.endpoint}`);
898
+ io.stdout("");
899
+ io.stdout(
900
+ "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."
901
+ );
902
+ if (!which || which === "node") {
903
+ io.stdout("");
904
+ io.stdout(`Node (${s.install}):`);
905
+ io.stdout(s.node);
906
+ }
907
+ if (!which || which === "curl") {
908
+ io.stdout("");
909
+ io.stdout("curl:");
910
+ io.stdout(s.curl);
911
+ }
912
+ return;
913
+ }
914
+ io.stdout(INSTANCES_USAGE);
915
+ throw new CliError("Unknown instances command");
916
+ } catch (err) {
917
+ friendly(err);
918
+ }
919
+ }
920
+
921
+ // src/schema.ts
922
+ var import_fs6 = require("fs");
923
+ var import_path7 = require("path");
924
+
925
+ // ../sync/src/hash.ts
926
+ var import_node_crypto = require("crypto");
927
+ function stableStringify(value) {
928
+ return JSON.stringify(normalize(value));
929
+ }
930
+ function normalize(value) {
931
+ if (Array.isArray(value)) {
932
+ return value.map(normalize);
933
+ }
934
+ if (value !== null && typeof value === "object") {
935
+ const sortedKeys = Object.keys(value).sort();
936
+ const result = {};
937
+ for (const key of sortedKeys) {
938
+ result[key] = normalize(value[key]);
939
+ }
940
+ return result;
941
+ }
942
+ return value;
943
+ }
944
+ function normalizeFieldSpec(spec) {
945
+ if (typeof spec === "function") {
946
+ return spec.toString();
947
+ }
948
+ return spec;
949
+ }
950
+ function docShapeHash(def) {
951
+ const normalizedFields = {};
952
+ for (const [name, spec] of Object.entries(def.fields)) {
953
+ normalizedFields[name] = normalizeFieldSpec(spec);
954
+ }
955
+ const shape = {
956
+ source: def.source,
957
+ primaryKey: def.primaryKey,
958
+ shouldIndex: def.shouldIndex?.toString() ?? null,
959
+ fields: normalizedFields
960
+ };
961
+ return (0, import_node_crypto.createHash)("sha256").update(stableStringify(shape)).digest("hex");
962
+ }
963
+ function settingsHash(def) {
964
+ const settings = def.settings ?? {};
965
+ return (0, import_node_crypto.createHash)("sha256").update(stableStringify(settings)).digest("hex");
966
+ }
967
+
968
+ // ../sync/src/migrations.ts
969
+ var import_fs5 = require("fs");
970
+ var import_path6 = require("path");
971
+ var FILE_NAME_RE = /^(\d{4})_(.+)\.json$/;
972
+ function sanitizeSlug(slug) {
973
+ const sanitized = slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
974
+ return sanitized || "migration";
975
+ }
976
+ function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new Date()) {
977
+ (0, import_fs5.mkdirSync)(dir, { recursive: true });
978
+ const existing = readMigrations(dir);
979
+ const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
980
+ const safeSlug = sanitizeSlug(meta.slug);
981
+ const fileName = `${String(nextVersion).padStart(4, "0")}_${safeSlug}.json`;
982
+ const path = (0, import_path6.join)(dir, fileName);
983
+ const file = {
984
+ version: nextVersion,
985
+ createdAt: now().toISOString(),
986
+ parentHashes: meta.parentHashes,
987
+ ops
988
+ };
989
+ (0, import_fs5.writeFileSync)(path, JSON.stringify(file, null, 2));
990
+ return path;
991
+ }
992
+ function readMigrations(dir) {
993
+ let entries;
994
+ try {
995
+ entries = (0, import_fs5.readdirSync)(dir);
996
+ } catch {
997
+ return [];
998
+ }
999
+ const files = [];
1000
+ for (const entry of entries) {
1001
+ const match = FILE_NAME_RE.exec(entry);
1002
+ if (!match) continue;
1003
+ const path = (0, import_path6.join)(dir, entry);
1004
+ const raw = (0, import_fs5.readFileSync)(path, "utf-8");
1005
+ let parsed;
1006
+ try {
1007
+ parsed = JSON.parse(raw);
1008
+ } catch {
1009
+ throw new Error(`Malformed migration file: ${path}`);
1010
+ }
1011
+ files.push({ ...parsed, path });
1012
+ }
1013
+ files.sort((a, b) => a.version - b.version);
1014
+ for (let i = 0; i < files.length; i++) {
1015
+ const expected = i + 1;
1016
+ if (files[i].version === expected) continue;
1017
+ if (files.some((f, idx) => idx !== i && f.version === files[i].version)) {
1018
+ throw new Error(`Duplicate migration version: ${files[i].version}`);
1019
+ }
1020
+ throw new Error(
1021
+ `Migration version gap: expected version ${expected}, found ${files[i].version}`
1022
+ );
1023
+ }
1024
+ return files;
1025
+ }
1026
+ function pendingMigrations(files, appliedVersion) {
1027
+ return files.filter((f) => f.version > appliedVersion);
1028
+ }
1029
+
1030
+ // ../sync/src/planner.ts
1031
+ function planMigration(config, applied, opts) {
1032
+ const ops = [];
1033
+ const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1034
+ for (const def of config.indexes) {
1035
+ const existing = appliedByUid.get(def.name);
1036
+ const settings = def.settings ?? {};
1037
+ if (!existing) {
1038
+ ops.push({
1039
+ type: "create-index",
1040
+ index: def.name,
1041
+ physicalUid: def.name,
1042
+ settings
1043
+ });
1044
+ continue;
1045
+ }
1046
+ if (existing.status === "failed") {
1047
+ ops.push({
1048
+ type: "reindex",
1049
+ index: def.name,
1050
+ fromPhysicalUid: existing.physicalUid,
1051
+ toPhysicalUid: `${def.name}__v${existing.version + 1}`,
1052
+ settings
1053
+ });
1054
+ continue;
1055
+ }
1056
+ const schemaDrifted = docShapeHash(def) !== existing.schemaHash;
1057
+ const settingsDrifted = settingsHash(def) !== existing.settingsHash;
1058
+ if (schemaDrifted) {
1059
+ ops.push({
1060
+ type: "reindex",
1061
+ index: def.name,
1062
+ fromPhysicalUid: existing.physicalUid,
1063
+ toPhysicalUid: `${def.name}__v${existing.version + 1}`,
1064
+ settings
1065
+ });
1066
+ } else if (settingsDrifted) {
1067
+ ops.push({
1068
+ type: "patch-settings",
1069
+ index: def.name,
1070
+ settings
1071
+ });
1072
+ }
1073
+ }
1074
+ if (opts?.allowDestructive) {
1075
+ const configuredNames = new Set(config.indexes.map((def) => def.name));
1076
+ for (const state of applied) {
1077
+ if (!configuredNames.has(state.indexUid)) {
1078
+ ops.push({ type: "remove-index", index: state.indexUid });
1079
+ }
1080
+ }
1081
+ }
1082
+ return ops;
1083
+ }
1084
+
1085
+ // ../sync/src/client.ts
1086
+ var BASE_BACKOFF_MS = 500;
1087
+ var DEFAULT_MAX_RETRIES = 5;
1088
+ function defaultSleep2(ms) {
1089
+ return new Promise((resolve) => setTimeout(resolve, ms));
1090
+ }
1091
+ var SchemaCasError = class extends Error {
1092
+ constructor(message) {
1093
+ super(message);
1094
+ this.name = "SchemaCasError";
1095
+ }
1096
+ };
1097
+ var HttpError = class extends Error {
1098
+ constructor(message, status, code) {
1099
+ super(message);
1100
+ this.status = status;
1101
+ this.code = code;
1102
+ this.name = "HttpError";
1103
+ }
1104
+ };
1105
+ function isRecord(v) {
1106
+ return typeof v === "object" && v !== null;
1107
+ }
1108
+ function parsePartialFailureItem(raw) {
1109
+ const item = isRecord(raw) ? raw : {};
1110
+ const status = typeof item.status === "number" ? item.status : void 0;
1111
+ return {
1112
+ id: typeof item.id === "string" || typeof item.id === "number" ? String(item.id) : null,
1113
+ ...status !== void 0 ? { status } : {},
1114
+ ...typeof item.type === "string" ? { type: item.type } : {},
1115
+ ...typeof item.reason === "string" ? { reason: item.reason } : {},
1116
+ // Servers before per-item `retryable` still sent the status.
1117
+ retryable: typeof item.retryable === "boolean" ? item.retryable : status !== void 0 && (status === 429 || status >= 500)
1118
+ };
1119
+ }
1120
+ function parsePartialFailure(payload, message) {
1121
+ const errors = Array.isArray(payload.errors) ? payload.errors.map(parsePartialFailureItem) : [];
1122
+ const failed = typeof payload.failed === "number" ? payload.failed : errors.length;
1123
+ const everyListedRetryable = failed > 0 && errors.length === failed && errors.every((e) => e.retryable);
1124
+ return { message, failed, errors, retryable: payload.retryable === true || everyListedRetryable };
1125
+ }
1126
+ var HeadbandClient = class {
1127
+ constructor(opts) {
1128
+ this.baseUrl = opts.url.replace(/\/+$/, "");
1129
+ this.adminKey = opts.adminKey;
1130
+ this.fetchImpl = opts.fetchImpl ?? fetch;
1131
+ this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
1132
+ this.sleep = opts.sleep ?? defaultSleep2;
1133
+ }
1134
+ async bulkPush(index, documents, primaryKey) {
1135
+ const body = { index, documents };
1136
+ if (primaryKey !== void 0) body.primaryKey = primaryKey;
1137
+ const result = await this.request("POST", "/v1/bulk", body, {
1138
+ allowPartialFailure: true
1139
+ });
1140
+ if ("partialFailure" in result) return { tasks: [], partialFailure: result.partialFailure };
1141
+ return { ...result, tasks: result.tasks ?? [] };
1142
+ }
1143
+ async deleteDocs(index, ids) {
1144
+ const result = await this.request(
1145
+ "DELETE",
1146
+ `/v1/documents?index=${encodeURIComponent(index)}`,
1147
+ { ids },
1148
+ { allowPartialFailure: true }
1149
+ );
1150
+ return isRecord(result) && "partialFailure" in result ? { partialFailure: result.partialFailure } : {};
1151
+ }
1152
+ async createIndex(uid, opts) {
1153
+ const body = { uid };
1154
+ if (opts?.primaryKey !== void 0) body.primaryKey = opts.primaryKey;
1155
+ await this.request("POST", "/v1/indexes", body);
1156
+ }
1157
+ async patchSettings(uid, settings) {
1158
+ await this.request("PATCH", `/v1/indexes/${uid}/settings`, settings);
1159
+ }
1160
+ async getSchemaState() {
1161
+ return this.request("GET", "/v1/schema/state");
1162
+ }
1163
+ async postSchemaState(state) {
1164
+ await this.request("POST", "/v1/schema/state", state, { mapConflictToCasError: true });
1165
+ }
1166
+ async patchAlias(index, physicalUid) {
1167
+ await this.request("PATCH", "/v1/schema/alias", { indexUid: index, physicalUid });
1168
+ }
1169
+ async reportRun(run2) {
1170
+ await this.request("POST", "/v1/connectors/runs", run2);
1171
+ }
1172
+ async request(method, path, body, opts = {}) {
1173
+ let attempt = 0;
1174
+ for (; ; ) {
1175
+ attempt += 1;
1176
+ const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
1177
+ method,
1178
+ headers: {
1179
+ Authorization: `Bearer ${this.adminKey}`,
1180
+ "Content-Type": "application/json"
1181
+ },
1182
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {}
1183
+ });
1184
+ if (response.ok) {
1185
+ if (response.status === 204) return void 0;
1186
+ return await response.json();
1187
+ }
1188
+ const payload = await response.json().catch(() => ({ error: `Request failed with status ${response.status}` }));
1189
+ const message = payload?.error ?? `Request failed with status ${response.status}`;
1190
+ const code = payload?.code;
1191
+ if (opts.mapConflictToCasError && response.status === 409) {
1192
+ throw new SchemaCasError(message);
1193
+ }
1194
+ if (opts.allowPartialFailure && response.status === 422 && code === "partial_failure") {
1195
+ return { partialFailure: parsePartialFailure(payload, message) };
1196
+ }
1197
+ const retryable = response.status === 429 || response.status >= 500;
1198
+ if (!retryable || attempt >= this.maxRetries) {
1199
+ throw new HttpError(message, response.status, code);
1200
+ }
1201
+ const delay = BASE_BACKOFF_MS * 2 ** (attempt - 1);
1202
+ await this.sleep(delay);
1203
+ }
1204
+ }
1205
+ };
1206
+
1207
+ // src/schema.ts
1208
+ var DEFAULT_BACKFILL_BATCH_SIZE = 500;
1209
+ var APPLIED_MARKER_FILE = ".applied.json";
1210
+ function opDetail(op) {
1211
+ switch (op.type) {
1212
+ case "create-index":
1213
+ return `physicalUid=${op.physicalUid}`;
1214
+ case "patch-settings":
1215
+ return "settings drift";
1216
+ case "reindex":
1217
+ return `${op.fromPhysicalUid} -> ${op.toPhysicalUid}`;
1218
+ case "remove-index":
1219
+ return "not present in config";
1220
+ }
1221
+ }
1222
+ function opRows(ops) {
1223
+ return ops.map((op) => ({ op: op.type, index: op.index, detail: opDetail(op) }));
1224
+ }
1225
+ function printOpsTable(io, ops) {
1226
+ if (ops.length === 0) {
1227
+ io.stdout("Schema is up to date. No pending changes.");
1228
+ return;
1229
+ }
1230
+ printTable(io, opRows(ops), ["op", "index", "detail"]);
1231
+ }
1232
+ function readAppliedMarker(dir) {
1233
+ const path = (0, import_path7.join)(dir, APPLIED_MARKER_FILE);
1234
+ if (!(0, import_fs6.existsSync)(path)) return 0;
1235
+ try {
1236
+ const parsed = JSON.parse((0, import_fs6.readFileSync)(path, "utf-8"));
1237
+ return typeof parsed.version === "number" ? parsed.version : 0;
1238
+ } catch {
1239
+ return 0;
1240
+ }
1241
+ }
1242
+ function writeAppliedMarker(dir, version) {
1243
+ (0, import_fs6.mkdirSync)(dir, { recursive: true });
1244
+ (0, import_fs6.writeFileSync)((0, import_path7.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
1245
+ }
1246
+ async function runSchemaDiff(deps) {
1247
+ const applied = await deps.client.getSchemaState();
1248
+ const ops = planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
1249
+ printOpsTable(deps.io, ops);
1250
+ return ops.length > 0 ? 1 : 0;
1251
+ }
1252
+ async function runSchemaGenerate(deps) {
1253
+ const applied = await deps.client.getSchemaState();
1254
+ const ops = planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
1255
+ if (ops.length === 0) {
1256
+ deps.io.stdout("No schema changes to generate.");
1257
+ return void 0;
1258
+ }
1259
+ const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1260
+ const parentHashes = {};
1261
+ for (const op of ops) {
1262
+ parentHashes[op.index] = appliedByUid.get(op.index)?.schemaHash ?? "";
1263
+ }
1264
+ const path = generateMigrationFile(
1265
+ deps.dir,
1266
+ ops,
1267
+ { parentHashes, slug: deps.slug ?? "migration" },
1268
+ deps.now
215
1269
  );
216
- console.log();
1270
+ deps.io.stdout(`Wrote ${path}`);
1271
+ printOpsTable(deps.io, ops);
1272
+ return path;
1273
+ }
1274
+ function isConflictError(err) {
1275
+ if (!(err instanceof Error)) return false;
1276
+ const shaped = err;
1277
+ return shaped.status === 409 || shaped.code === "index_already_exists";
1278
+ }
1279
+ async function createIndexIdempotent(ctx, uid, opts) {
1280
+ try {
1281
+ await ctx.client.createIndex(uid, opts);
1282
+ } catch (err) {
1283
+ if (!isConflictError(err)) throw err;
1284
+ ctx.io.stdout(`Index "${uid}" already exists \u2014 continuing.`);
1285
+ }
1286
+ }
1287
+ function postState(client, state) {
1288
+ return client.postSchemaState(state);
1289
+ }
1290
+ async function applyReindex(op, ctx, existing, expectedParentHash) {
1291
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
1292
+ if (!def) {
1293
+ throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
1294
+ }
1295
+ await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
1296
+ await ctx.client.patchSettings(op.toPhysicalUid, op.settings);
1297
+ const version = (existing?.version ?? 0) + 1;
1298
+ const schemaHashValue = docShapeHash(def);
1299
+ const settingsHashValue = settingsHash(def);
1300
+ let parentHash = expectedParentHash;
1301
+ const writeState = async (status, cursor2) => {
1302
+ await postState(ctx.client, {
1303
+ indexUid: op.index,
1304
+ version,
1305
+ schemaHash: schemaHashValue,
1306
+ settingsHash: settingsHashValue,
1307
+ physicalUid: op.toPhysicalUid,
1308
+ status,
1309
+ // The `/v1/schema/state` route only accepts a string|null
1310
+ // backfillCursor (see route.ts) — `Id` can be numeric, so it must be
1311
+ // stringified before crossing the wire.
1312
+ backfillCursor: cursor2 === null ? null : String(cursor2),
1313
+ expectedParentHash: parentHash
1314
+ });
1315
+ parentHash = schemaHashValue;
1316
+ };
1317
+ await writeState("migrating", null);
1318
+ let cursor = null;
1319
+ let pushed = 0;
1320
+ let excluded = 0;
1321
+ let total = 0;
1322
+ for (; ; ) {
1323
+ const page = await ctx.adapter.listIds(def.source, cursor, ctx.backfillBatchSize);
1324
+ if (page.ids.length === 0) break;
1325
+ total += page.ids.length;
1326
+ const docs = await ctx.adapter.resolveDocs(def.name, page.ids);
1327
+ const toPush = [];
1328
+ for (const doc of docs) {
1329
+ const sourceRow = doc.__source ?? doc;
1330
+ delete doc.__source;
1331
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) {
1332
+ excluded += 1;
1333
+ continue;
1334
+ }
1335
+ toPush.push(doc);
1336
+ }
1337
+ if (toPush.length > 0) {
1338
+ const result = await ctx.client.bulkPush(op.toPhysicalUid, toPush, def.primaryKey);
1339
+ if (result?.partialFailure) {
1340
+ throw new CliError(
1341
+ `Backfill of "${op.index}" stopped: ${partialFailureSummary(result.partialFailure)}. Aborting before the alias flip. Fix the rejected documents, then re-run "headband schema apply".`
1342
+ );
1343
+ }
1344
+ pushed += toPush.length;
1345
+ }
1346
+ cursor = page.nextCursor;
1347
+ await writeState("migrating", cursor);
1348
+ if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
1349
+ }
1350
+ if (pushed + excluded !== total) {
1351
+ throw new CliError(
1352
+ `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.`
1353
+ );
1354
+ }
1355
+ await ctx.client.patchAlias(op.index, op.toPhysicalUid);
1356
+ await writeState("applied", null);
1357
+ return {
1358
+ indexUid: op.index,
1359
+ version,
1360
+ schemaHash: schemaHashValue,
1361
+ settingsHash: settingsHashValue,
1362
+ physicalUid: op.toPhysicalUid,
1363
+ status: "applied"
1364
+ };
1365
+ }
1366
+ async function applyOp(op, ctx, existing, expectedParentHash) {
1367
+ if (op.type === "create-index") {
1368
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
1369
+ await createIndexIdempotent(
1370
+ ctx,
1371
+ op.index,
1372
+ def?.primaryKey ? { primaryKey: def.primaryKey } : void 0
1373
+ );
1374
+ await ctx.client.patchSettings(op.index, op.settings);
1375
+ const next = {
1376
+ indexUid: op.index,
1377
+ version: 1,
1378
+ schemaHash: def ? docShapeHash(def) : "",
1379
+ settingsHash: def ? settingsHash(def) : "",
1380
+ physicalUid: op.physicalUid,
1381
+ status: "applied"
1382
+ };
1383
+ await postState(ctx.client, { ...next, expectedParentHash });
1384
+ return next;
1385
+ }
1386
+ if (op.type === "patch-settings") {
1387
+ if (!existing) {
1388
+ throw new CliError(`Cannot patch settings for "${op.index}": no applied schema state found.`);
1389
+ }
1390
+ await ctx.client.patchSettings(op.index, op.settings);
1391
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
1392
+ const next = {
1393
+ ...existing,
1394
+ settingsHash: def ? settingsHash(def) : existing.settingsHash,
1395
+ status: "applied"
1396
+ };
1397
+ await postState(ctx.client, { ...next, expectedParentHash });
1398
+ return next;
1399
+ }
1400
+ if (op.type === "reindex") {
1401
+ return applyReindex(op, ctx, existing, expectedParentHash);
1402
+ }
1403
+ ctx.io.stdout(
1404
+ `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.`
1405
+ );
1406
+ return void 0;
1407
+ }
1408
+ async function runSchemaApply(deps) {
1409
+ const files = readMigrations(deps.dir);
1410
+ const appliedVersion = readAppliedMarker(deps.dir);
1411
+ const pending = pendingMigrations(files, appliedVersion);
1412
+ if (pending.length === 0) {
1413
+ deps.io.stdout("No pending migrations to apply.");
1414
+ return { appliedFiles: 0, appliedOps: 0 };
1415
+ }
1416
+ const applied = await deps.client.getSchemaState();
1417
+ const existingByIndex = new Map(applied.map((a) => [a.indexUid, a]));
1418
+ const ctx = {
1419
+ config: deps.config,
1420
+ adapter: deps.adapter,
1421
+ client: deps.client,
1422
+ backfillBatchSize: deps.backfillBatchSize ?? DEFAULT_BACKFILL_BATCH_SIZE,
1423
+ io: deps.io
1424
+ };
1425
+ let appliedOps = 0;
1426
+ let appliedFiles = 0;
1427
+ for (const file of pending) {
1428
+ for (const op of file.ops) {
1429
+ if (op.type === "remove-index" && !deps.allowDestructive) {
1430
+ throw new CliError(
1431
+ `Migration ${file.version} contains a destructive "remove-index" op for "${op.index}". Re-run with --allow-destructive to proceed.`
1432
+ );
1433
+ }
1434
+ const existing = existingByIndex.get(op.index);
1435
+ const hasParentHash = Object.prototype.hasOwnProperty.call(file.parentHashes, op.index);
1436
+ const rawParentHash = hasParentHash ? file.parentHashes[op.index] : null;
1437
+ const currentServerHash = existing?.schemaHash ?? "";
1438
+ if (hasParentHash && currentServerHash !== rawParentHash) {
1439
+ throw new CliError(
1440
+ `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.`
1441
+ );
1442
+ }
1443
+ const expectedParentHash = rawParentHash === "" ? null : rawParentHash;
1444
+ try {
1445
+ const next = await applyOp(op, ctx, existing, expectedParentHash);
1446
+ if (next) existingByIndex.set(op.index, next);
1447
+ appliedOps += 1;
1448
+ } catch (err) {
1449
+ if (err instanceof SchemaCasError) {
1450
+ throw new CliError(
1451
+ `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})`
1452
+ );
1453
+ }
1454
+ throw err;
1455
+ }
1456
+ }
1457
+ writeAppliedMarker(deps.dir, file.version);
1458
+ appliedFiles += 1;
1459
+ deps.io.stdout(`Applied migration ${file.version}${file.path ? ` (${file.path})` : ""}.`);
1460
+ }
1461
+ return { appliedFiles, appliedOps };
1462
+ }
1463
+ async function runSchemaStatus(deps) {
1464
+ const applied = await deps.client.getSchemaState();
1465
+ const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1466
+ const rows = deps.config.indexes.map((def) => {
1467
+ const existing = appliedByUid.get(def.name);
1468
+ if (!existing) {
1469
+ return { index: def.name, status: "missing", version: 0, drift: "missing" };
1470
+ }
1471
+ const schemaDrifted = docShapeHash(def) !== existing.schemaHash;
1472
+ const settingsDrifted = settingsHash(def) !== existing.settingsHash;
1473
+ return {
1474
+ index: def.name,
1475
+ status: existing.status,
1476
+ version: existing.version,
1477
+ drift: schemaDrifted ? "schema" : settingsDrifted ? "settings" : "none"
1478
+ };
1479
+ });
1480
+ printTable(deps.io, rows, ["index", "status", "version", "drift"]);
1481
+ const files = readMigrations(deps.dir);
1482
+ const localVersion = readAppliedMarker(deps.dir);
1483
+ const localHead = files.length > 0 ? files[files.length - 1].version : 0;
1484
+ deps.io.stdout(`Local migrations applied: ${localVersion}/${localHead}`);
217
1485
  }
218
1486
 
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
1487
+ // src/sync.ts
1488
+ var import_fs7 = require("fs");
1489
+ var import_path8 = require("path");
1490
+ var import_crypto = require("crypto");
1491
+ var import_pg = require("pg");
1492
+
1493
+ // ../sync/src/outbox.ts
1494
+ var SAFE_IDENTIFIER = /^[a-zA-Z0-9_]+$/;
1495
+ function assertSafeTable(table) {
1496
+ if (!SAFE_IDENTIFIER.test(table)) {
1497
+ throw new Error(`Invalid outbox table name "${table}": must match ${SAFE_IDENTIFIER.source}`);
1498
+ }
1499
+ }
1500
+ function outboxDDL(table = "headband_outbox") {
1501
+ assertSafeTable(table);
1502
+ return `
1503
+ CREATE TABLE IF NOT EXISTS ${table} (
1504
+ id BIGSERIAL PRIMARY KEY,
1505
+ model TEXT NOT NULL,
1506
+ op TEXT NOT NULL,
1507
+ doc_ids JSONB NOT NULL,
1508
+ snapshot JSONB,
1509
+ status TEXT NOT NULL DEFAULT 'pending',
1510
+ attempts INTEGER NOT NULL DEFAULT 0,
1511
+ claimed_at TIMESTAMPTZ,
1512
+ last_error TEXT,
1513
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
1514
+ );
1515
+ CREATE INDEX IF NOT EXISTS ${table}_status_idx ON ${table} (status);
1516
+ `.trim();
1517
+ }
1518
+ function mapRow(row) {
1519
+ const docIds = typeof row.doc_ids === "string" ? JSON.parse(row.doc_ids) : row.doc_ids;
1520
+ const snapshot = row.snapshot == null ? void 0 : typeof row.snapshot === "string" ? JSON.parse(row.snapshot) : row.snapshot;
1521
+ return {
1522
+ id: row.id,
1523
+ model: row.model,
1524
+ op: row.op,
1525
+ docIds,
1526
+ attempts: row.attempts,
1527
+ snapshot
1528
+ };
1529
+ }
1530
+ function pgOutboxDriver(exec, opts) {
1531
+ const table = opts?.table ?? "headband_outbox";
1532
+ assertSafeTable(table);
1533
+ const maxAttempts = opts?.maxAttempts ?? 5;
1534
+ return {
1535
+ async claim(limit) {
1536
+ const { rows } = await exec(
1537
+ `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 *`,
1538
+ [limit]
1539
+ );
1540
+ return rows.map(mapRow);
1541
+ },
1542
+ async markDone(ids) {
1543
+ await exec(`UPDATE ${table} SET status = 'done' WHERE id = ANY($1)`, [ids]);
1544
+ },
1545
+ async deadLetter(id, error) {
1546
+ await exec(
1547
+ `UPDATE ${table} SET attempts = attempts + 1, last_error = $2, status = 'dead_letter', claimed_at = NULL WHERE id = $1`,
1548
+ [id, error]
1549
+ );
1550
+ },
1551
+ async markFailed(id, error) {
1552
+ await exec(
1553
+ `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`,
1554
+ [id, error, maxAttempts]
1555
+ );
1556
+ },
1557
+ async deadLetters(limit) {
1558
+ const { rows } = await exec(
1559
+ `SELECT * FROM ${table} WHERE status = 'dead_letter' ORDER BY id LIMIT $1`,
1560
+ [limit]
1561
+ );
1562
+ return rows.map(mapRow);
1563
+ },
1564
+ async requeue(ids) {
1565
+ await exec(`UPDATE ${table} SET status = 'pending', claimed_at = NULL WHERE id = ANY($1)`, [
1566
+ ids
1567
+ ]);
1568
+ },
1569
+ async depth() {
1570
+ const { rows } = await exec(
1571
+ `SELECT count(*) AS count FROM ${table} WHERE status = 'pending'`
1572
+ );
1573
+ return Number(rows[0]?.count ?? 0);
1574
+ }
1575
+ };
1576
+ }
1577
+
1578
+ // ../sync/src/resolver.ts
1579
+ var toKey = (id) => String(id);
1580
+ var idKey = toKey;
1581
+ async function resolveDirtyDocs(adapter, config, changes) {
1582
+ const upserts = /* @__PURE__ */ new Map();
1583
+ const deletes = /* @__PURE__ */ new Map();
1584
+ const origins = /* @__PURE__ */ new Map();
1585
+ const addOrigin = (index, id, changeIndex) => {
1586
+ let byId = origins.get(index);
1587
+ if (!byId) {
1588
+ byId = /* @__PURE__ */ new Map();
1589
+ origins.set(index, byId);
1590
+ }
1591
+ const key = toKey(id);
1592
+ const set = byId.get(key);
1593
+ if (set) set.add(changeIndex);
1594
+ else byId.set(key, /* @__PURE__ */ new Set([changeIndex]));
1595
+ };
1596
+ const indexByName = new Map(config.indexes.map((def) => [def.name, def]));
1597
+ const toResolve = /* @__PURE__ */ new Map();
1598
+ const addDelete = (index, id) => {
1599
+ const list = deletes.get(index);
1600
+ if (list) {
1601
+ if (!list.some((x) => toKey(x) === toKey(id))) list.push(id);
1602
+ } else {
1603
+ deletes.set(index, [id]);
1604
+ }
1605
+ };
1606
+ for (const [changeIndex, change] of changes.entries()) {
1607
+ const dirtyDocs = await adapter.fanOut(change);
1608
+ for (const doc of dirtyDocs) {
1609
+ addOrigin(doc.index, doc.id, changeIndex);
1610
+ const def = indexByName.get(doc.index);
1611
+ const isSourceModelDelete = change.op === "delete" && def !== void 0 && def.source === change.model;
1612
+ if (isSourceModelDelete) {
1613
+ addDelete(doc.index, doc.id);
1614
+ continue;
1615
+ }
1616
+ let ids = toResolve.get(doc.index);
1617
+ if (!ids) {
1618
+ ids = /* @__PURE__ */ new Map();
1619
+ toResolve.set(doc.index, ids);
1620
+ }
1621
+ if (!ids.has(toKey(doc.id))) ids.set(toKey(doc.id), doc.id);
1622
+ }
1623
+ }
1624
+ for (const [index, idSet] of toResolve) {
1625
+ const ids = [...idSet.values()];
1626
+ const def = indexByName.get(index);
1627
+ const primaryKey = def?.primaryKey ?? "id";
1628
+ const rows = await adapter.resolveDocs(index, ids);
1629
+ const resolvedKeys = /* @__PURE__ */ new Set();
1630
+ rows.forEach((row, i) => {
1631
+ const rawId = row[primaryKey];
1632
+ if (rawId === void 0) {
1633
+ const contextId = ids[i];
1634
+ throw new Error(
1635
+ `resolveDocs for index '${index}' returned a doc missing primaryKey '${primaryKey}' (id: ${String(contextId)})`
1636
+ );
1637
+ }
1638
+ const id = rawId;
1639
+ resolvedKeys.add(toKey(id));
1640
+ const sourceRow = row.__source ?? row;
1641
+ delete row.__source;
1642
+ if (def?.shouldIndex && !def.shouldIndex(sourceRow)) {
1643
+ addDelete(index, id);
1644
+ return;
1645
+ }
1646
+ const list = upserts.get(index);
1647
+ if (list) {
1648
+ list.push(row);
1649
+ } else {
1650
+ upserts.set(index, [row]);
1651
+ }
1652
+ });
1653
+ for (const id of ids) {
1654
+ if (!resolvedKeys.has(toKey(id))) {
1655
+ addDelete(index, id);
1656
+ }
1657
+ }
1658
+ }
1659
+ return { upserts, deletes, origins };
1660
+ }
1661
+
1662
+ // ../sync/src/worker.ts
1663
+ var DEFAULT_BATCH_SIZE = 100;
1664
+ var DEFAULT_IDLE_DELAY_MS = 1e3;
1665
+ var MAX_FAILURE_DELAY_MS = 3e4;
1666
+ var MAX_PARTIAL_RETRIES = 3;
1667
+ async function writeWithPartialRetries(items, keyOf, send, backoff) {
1668
+ const failures = /* @__PURE__ */ new Map();
1669
+ let pending = items;
1670
+ for (let attempt = 0; pending.length > 0; attempt++) {
1671
+ const partial = await send(pending);
1672
+ if (!partial) return failures;
1673
+ const named = new Map(
1674
+ partial.errors.flatMap((e) => e.id === null ? [] : [[e.id, e]])
1675
+ );
1676
+ const pendingKeys = new Set(pending.map(keyOf));
1677
+ const namedHere = [...named.keys()].filter((k) => pendingKeys.has(k)).length;
1678
+ const complete = namedHere >= partial.failed;
1679
+ const retry = [];
1680
+ for (const item of pending) {
1681
+ const key = keyOf(item);
1682
+ const err = named.get(key);
1683
+ if (err) {
1684
+ const message = err.reason ?? partial.message;
1685
+ if (err.retryable && attempt < MAX_PARTIAL_RETRIES) retry.push(item);
1686
+ else failures.set(key, { retryable: err.retryable, message });
1687
+ } else if (!complete) {
1688
+ failures.set(key, {
1689
+ retryable: true,
1690
+ message: `${partial.message} (this document's outcome was not reported)`
1691
+ });
1692
+ }
1693
+ }
1694
+ if (retry.length === 0) return failures;
1695
+ await backoff(attempt + 1);
1696
+ pending = retry;
1697
+ }
1698
+ return failures;
1699
+ }
1700
+ async function pushResolved(resolved, client, primaryKeyByIndex, backoff) {
1701
+ const failures = /* @__PURE__ */ new Map();
1702
+ let pushed = 0;
1703
+ let deleted = 0;
1704
+ const record = (index, failed) => {
1705
+ if (failed.size === 0) return;
1706
+ const existing = failures.get(index) ?? /* @__PURE__ */ new Map();
1707
+ for (const [key, f] of failed) existing.set(key, f);
1708
+ failures.set(index, existing);
1709
+ };
1710
+ for (const [index, docs] of resolved.upserts) {
1711
+ const primaryKey = primaryKeyByIndex.get(index);
1712
+ const failed = await writeWithPartialRetries(
1713
+ docs,
1714
+ (doc) => idKey(doc[primaryKey ?? "id"]),
1715
+ async (batch) => (await client.bulkPush(index, batch, primaryKey))?.partialFailure,
1716
+ backoff
1717
+ );
1718
+ record(index, failed);
1719
+ pushed += docs.length - failed.size;
1720
+ }
1721
+ for (const [index, ids] of resolved.deletes) {
1722
+ const failed = await writeWithPartialRetries(
1723
+ ids,
1724
+ idKey,
1725
+ async (batch) => (await client.deleteDocs(index, batch))?.partialFailure,
1726
+ backoff
1727
+ );
1728
+ record(index, failed);
1729
+ deleted += ids.length - failed.size;
1730
+ }
1731
+ return { failures, pushed, deleted };
1732
+ }
1733
+ function rowOutcomes(rows, resolved, failures) {
1734
+ const failuresByRow = /* @__PURE__ */ new Map();
1735
+ for (const [index, failed] of failures) {
1736
+ for (const [key, failure] of failed) {
1737
+ for (const changeIndex of resolved.origins.get(index)?.get(key) ?? []) {
1738
+ const list = failuresByRow.get(changeIndex) ?? [];
1739
+ list.push(failure);
1740
+ failuresByRow.set(changeIndex, list);
1741
+ }
1742
+ }
1743
+ }
1744
+ const done = [];
1745
+ const retry = [];
1746
+ const deadLetter = [];
1747
+ rows.forEach((row, changeIndex) => {
1748
+ const rowFailures = failuresByRow.get(changeIndex);
1749
+ if (!rowFailures) {
1750
+ done.push(row.id);
1751
+ return;
1752
+ }
1753
+ const permanent = rowFailures.find((f) => !f.retryable);
1754
+ if (permanent) deadLetter.push([row.id, permanent.message]);
1755
+ else retry.push([row.id, rowFailures[0].message]);
1756
+ });
1757
+ return { done, retry, deadLetter };
1758
+ }
1759
+ function defaultSleep3(ms) {
1760
+ return new Promise((resolve) => setTimeout(resolve, ms));
1761
+ }
1762
+ async function runSyncWorker(opts) {
1763
+ const {
1764
+ config,
1765
+ adapter,
1766
+ client,
1767
+ outbox,
1768
+ budgetMs,
1769
+ loop = false,
1770
+ idleDelayMs = DEFAULT_IDLE_DELAY_MS,
1771
+ failureDelayMs = idleDelayMs,
1772
+ batchSize = DEFAULT_BATCH_SIZE,
1773
+ stopSignal,
1774
+ now = Date.now,
1775
+ sleep = defaultSleep3,
1776
+ onHeartbeat
1777
+ } = opts;
1778
+ const primaryKeyByIndex = new Map(config.indexes.map((def) => [def.name, def.primaryKey]));
1779
+ const report = {
1780
+ drained: 0,
1781
+ pushed: 0,
1782
+ deleted: 0,
1783
+ deadLettered: 0,
1784
+ outboxDepth: 0
1785
+ };
1786
+ const start = now();
1787
+ const budgetExceeded = () => budgetMs !== void 0 && now() - start >= budgetMs;
1788
+ let consecutiveFailures = 0;
1789
+ for (; ; ) {
1790
+ if (budgetExceeded()) break;
1791
+ if (stopSignal && stopSignal()) break;
1792
+ const rows = await outbox.claim(batchSize);
1793
+ if (rows.length === 0) {
1794
+ if (!loop) break;
1795
+ await sleep(idleDelayMs);
1796
+ continue;
1797
+ }
1798
+ const changes = rows.map((row) => ({
1799
+ model: row.model,
1800
+ op: row.op,
1801
+ ids: row.docIds,
1802
+ snapshot: row.snapshot
1803
+ }));
1804
+ try {
1805
+ const resolved = await resolveDirtyDocs(adapter, config, changes);
1806
+ const { failures, pushed, deleted } = await pushResolved(
1807
+ resolved,
1808
+ client,
1809
+ primaryKeyByIndex,
1810
+ (attempt) => sleep(Math.min(failureDelayMs * 2 ** (attempt - 1), MAX_FAILURE_DELAY_MS))
1811
+ );
1812
+ report.pushed += pushed;
1813
+ report.deleted += deleted;
1814
+ const outcome = rowOutcomes(rows, resolved, failures);
1815
+ if (outcome.done.length > 0) await outbox.markDone(outcome.done);
1816
+ for (const [id, message] of outcome.deadLetter) {
1817
+ if (outbox.deadLetter) await outbox.deadLetter(id, message);
1818
+ else await outbox.markFailed(id, message);
1819
+ }
1820
+ for (const [id, message] of outcome.retry) {
1821
+ await outbox.markFailed(id, message);
1822
+ }
1823
+ report.drained += outcome.done.length;
1824
+ report.deadLettered += outcome.deadLetter.length + outcome.retry.length;
1825
+ if (outcome.retry.length > 0) {
1826
+ consecutiveFailures += 1;
1827
+ await sleep(
1828
+ Math.min(failureDelayMs * 2 ** (consecutiveFailures - 1), MAX_FAILURE_DELAY_MS)
1829
+ );
1830
+ } else {
1831
+ consecutiveFailures = 0;
1832
+ }
1833
+ } catch (err) {
1834
+ const message = err instanceof Error ? err.message : String(err);
1835
+ for (const row of rows) {
1836
+ await outbox.markFailed(row.id, message);
1837
+ }
1838
+ report.deadLettered += rows.length;
1839
+ consecutiveFailures += 1;
1840
+ const delay = Math.min(failureDelayMs * 2 ** (consecutiveFailures - 1), MAX_FAILURE_DELAY_MS);
1841
+ await sleep(delay);
1842
+ }
1843
+ if (onHeartbeat) {
1844
+ try {
1845
+ await onHeartbeat({ ...report });
1846
+ } catch (err) {
1847
+ console.error("[headband] heartbeat failed:", err);
1848
+ }
1849
+ }
1850
+ }
1851
+ report.outboxDepth = await outbox.depth();
1852
+ if (onHeartbeat) {
1853
+ try {
1854
+ await onHeartbeat({ ...report });
1855
+ } catch (err) {
1856
+ console.error("[headband] heartbeat failed:", err);
1857
+ }
1858
+ }
1859
+ return report;
1860
+ }
1861
+
1862
+ // src/sync.ts
1863
+ var WORKER_VERSION = "1";
1864
+ var DEFAULT_DEAD_LETTER_LIMIT = 1e3;
1865
+ var RECONCILE_PAGE_SIZE = 500;
1866
+ var OUTBOX_SQL_RELATIVE_PATH = (0, import_path8.join)("headband", "outbox.sql");
1867
+ var DOCKERFILE_NAME = "Dockerfile.headband-sync";
1868
+ var DOCKERFILE_CONTENT = `FROM node:22-slim
1869
+ WORKDIR /app
1870
+ COPY . .
1871
+ RUN npm ci
1872
+ CMD ["npx", "headband", "sync", "worker"]
1873
+ `;
1874
+ var COMPOSE_SNIPPET = `services:
1875
+ headband-sync:
1876
+ build:
1877
+ context: .
1878
+ dockerfile: ${DOCKERFILE_NAME}
1879
+ env_file: .env
1880
+ restart: unless-stopped
1881
+ `;
1882
+ function resolveDatabaseUrl(env = process.env) {
1883
+ const url = env.DATABASE_URL || env.HEADBAND_DATABASE_URL;
1884
+ if (!url) {
1885
+ throw new CliError(
1886
+ "No database connection found for the sync outbox. Set DATABASE_URL (or HEADBAND_DATABASE_URL) in your environment."
1887
+ );
1888
+ }
1889
+ return url;
1890
+ }
1891
+ function buildPgOutbox(connectionString, opts) {
1892
+ const pool = new import_pg.Pool({ connectionString });
1893
+ const exec = async (sqlText, params) => {
1894
+ const result = await pool.query(sqlText, params);
1895
+ return { rows: result.rows };
1896
+ };
1897
+ return { outbox: pgOutboxDriver(exec, opts), close: () => pool.end() };
1898
+ }
1899
+ function runSyncInit(deps) {
1900
+ const ddl = outboxDDL(deps.table);
1901
+ deps.io.stdout(ddl);
1902
+ if (deps.write) {
1903
+ const path = (0, import_path8.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
1904
+ (0, import_fs7.mkdirSync)((0, import_path8.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
1905
+ (0, import_fs7.writeFileSync)(path, `${ddl}
241
1906
  `);
1907
+ deps.io.stdout(`Wrote ${path}`);
1908
+ }
1909
+ return ddl;
242
1910
  }
243
- async function main() {
244
- if (flags.has("--help") || flags.has("-h") || command === "help") {
245
- printHelp();
246
- process.exit(0);
1911
+ var SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"];
1912
+ async function runSyncWorkerCmd(deps) {
1913
+ const schemaHashes = Object.fromEntries(
1914
+ deps.config.indexes.map((def) => [def.name, docShapeHash(def)])
1915
+ );
1916
+ const workerVersion = deps.workerVersion ?? WORKER_VERSION;
1917
+ let heartbeats = 0;
1918
+ const proc = deps.process ?? process;
1919
+ let stopRequested = false;
1920
+ const handleShutdown = () => {
1921
+ if (stopRequested) return;
1922
+ stopRequested = true;
1923
+ deps.io.stderr("shutting down after current batch\u2026");
1924
+ };
1925
+ for (const signal of SHUTDOWN_SIGNALS) {
1926
+ proc.once(signal, handleShutdown);
247
1927
  }
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);
1928
+ try {
1929
+ const report = await runSyncWorker({
1930
+ config: deps.config,
1931
+ adapter: deps.adapter,
1932
+ client: deps.client,
1933
+ outbox: deps.outbox,
1934
+ loop: deps.loop ?? !deps.budgetMs,
1935
+ budgetMs: deps.budgetMs,
1936
+ batchSize: deps.batchSize,
1937
+ idleDelayMs: deps.idleDelayMs,
1938
+ failureDelayMs: deps.failureDelayMs,
1939
+ now: deps.now,
1940
+ sleep: deps.sleep,
1941
+ stopSignal: () => stopRequested,
1942
+ onHeartbeat: async (r) => {
1943
+ heartbeats += 1;
1944
+ await deps.client.reportRun({
1945
+ connectorSlug: "orm-sync",
1946
+ status: r.deadLettered > 0 ? "error" : "success",
1947
+ count: r.pushed + r.deleted,
1948
+ externalRunId: (0, import_crypto.randomUUID)(),
1949
+ details: { outboxDepth: r.outboxDepth, workerVersion, schemaHashes }
1950
+ });
1951
+ if (deps.reconcileEvery && deps.onReconcileTick && heartbeats % deps.reconcileEvery === 0) {
1952
+ await deps.onReconcileTick();
1953
+ }
1954
+ }
1955
+ });
1956
+ deps.io.stdout(
1957
+ `Drained ${report.drained} row(s): pushed ${report.pushed}, deleted ${report.deleted}, dead-lettered ${report.deadLettered}. Outbox depth: ${report.outboxDepth}.`
1958
+ );
1959
+ return report;
1960
+ } finally {
1961
+ for (const signal of SHUTDOWN_SIGNALS) {
1962
+ proc.removeListener(signal, handleShutdown);
1963
+ }
1964
+ }
1965
+ }
1966
+ async function collectAllIds(adapter, model) {
1967
+ const ids = [];
1968
+ let cursor = null;
1969
+ for (; ; ) {
1970
+ const page = await adapter.listIds(model, cursor, RECONCILE_PAGE_SIZE);
1971
+ ids.push(...page.ids);
1972
+ if (page.nextCursor === null || page.ids.length === 0) break;
1973
+ cursor = page.nextCursor;
1974
+ }
1975
+ return ids;
1976
+ }
1977
+ async function runSyncReconcile(deps) {
1978
+ const ormSchema = deps.adapter.introspect();
1979
+ const rows = [];
1980
+ for (const def of deps.config.indexes) {
1981
+ const ids = await collectAllIds(deps.adapter, def.source);
1982
+ const sourceCount = ids.length;
1983
+ let remoteCount = -1;
1984
+ try {
1985
+ const stats = await deps.getStats(def.name);
1986
+ remoteCount = stats.numDocuments ?? 0;
1987
+ } catch (err) {
1988
+ deps.io.stderr(
1989
+ `Could not fetch stats for "${def.name}": ${err instanceof Error ? err.message : String(err)}`
1990
+ );
1991
+ }
1992
+ const cursorAware = Boolean(ormSchema.models[def.source]?.columns?.updatedAt);
1993
+ const drift = remoteCount === -1 ? 0 : sourceCount - remoteCount;
1994
+ rows.push({ index: def.name, sourceCount, remoteCount, drift, cursorAware });
1995
+ if (deps.fix && drift !== 0) {
1996
+ const docs = await deps.adapter.resolveDocs(def.name, ids);
1997
+ const toPush = docs.filter((doc) => {
1998
+ const sourceRow = doc.__source ?? doc;
1999
+ return !def.shouldIndex || def.shouldIndex(sourceRow);
2000
+ });
2001
+ for (const doc of toPush) delete doc.__source;
2002
+ if (toPush.length > 0) {
2003
+ const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
2004
+ if (result?.partialFailure) {
2005
+ throw new CliError(
2006
+ `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
2007
+ );
2008
+ }
2009
+ }
2010
+ }
2011
+ }
2012
+ printTable(
2013
+ deps.io,
2014
+ rows.map((r) => ({ ...r, remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount })),
2015
+ ["index", "sourceCount", "remoteCount", "drift", "cursorAware"]
2016
+ );
2017
+ return rows;
2018
+ }
2019
+ async function runSyncRetryDeadLetters(deps) {
2020
+ const rows = await deps.outbox.deadLetters(deps.limit ?? DEFAULT_DEAD_LETTER_LIMIT);
2021
+ if (rows.length === 0) {
2022
+ deps.io.stdout("No dead-lettered rows to retry.");
2023
+ return 0;
2024
+ }
2025
+ await deps.outbox.requeue(rows.map((row) => row.id));
2026
+ deps.io.stdout(`Requeued ${rows.length} dead-lettered row(s).`);
2027
+ return rows.length;
2028
+ }
2029
+ function runSyncScaffoldDocker(deps) {
2030
+ const path = (0, import_path8.join)(deps.cwd, DOCKERFILE_NAME);
2031
+ if ((0, import_fs7.existsSync)(path) && !deps.force) {
2032
+ throw new CliError(`${DOCKERFILE_NAME} already exists at ${path}. Pass --force to overwrite.`);
2033
+ }
2034
+ (0, import_fs7.writeFileSync)(path, DOCKERFILE_CONTENT);
2035
+ deps.io.stdout(`Wrote ${path}`);
2036
+ deps.io.stdout("Add this service to your docker-compose.yml:");
2037
+ deps.io.stdout(COMPOSE_SNIPPET);
2038
+ return path;
2039
+ }
2040
+
2041
+ // src/index.ts
2042
+ var ENGINES2 = /* @__PURE__ */ new Set(["meilisearch", "typesense", "elasticsearch"]);
2043
+ var KEY_TYPES = /* @__PURE__ */ new Set(["admin", "search"]);
2044
+ var VALUE_FLAGS = /* @__PURE__ */ new Set([
2045
+ "--url",
2046
+ "--engine",
2047
+ "--primary-key",
2048
+ "--file",
2049
+ "--index",
2050
+ "--limit",
2051
+ "--filter",
2052
+ "--type",
2053
+ "--project",
2054
+ "--dir",
2055
+ "--slug",
2056
+ "--table",
2057
+ "--scaffold",
2058
+ "--budget-ms",
2059
+ "--batch-size",
2060
+ "--reconcile-every",
2061
+ "--name",
2062
+ "--lane",
2063
+ "--memory",
2064
+ "--region",
2065
+ "--idle-timeout",
2066
+ "--client",
2067
+ "--instance"
2068
+ ]);
2069
+ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
2070
+ "--json",
2071
+ "--yes",
2072
+ "-y",
2073
+ "--help",
2074
+ "-h",
2075
+ "--allow-destructive",
2076
+ "--write",
2077
+ "--force",
2078
+ "--fix",
2079
+ "--loop"
2080
+ ]);
2081
+ var DEFAULT_MIGRATIONS_DIR = "headband/migrations";
2082
+ function parseArgv(argv) {
2083
+ const flags = {};
2084
+ const positional = [];
2085
+ for (let index = 0; index < argv.length; index += 1) {
2086
+ const arg = argv[index];
2087
+ if (!arg.startsWith("-")) {
2088
+ positional.push(arg);
2089
+ continue;
2090
+ }
2091
+ const equalsIndex = arg.indexOf("=");
2092
+ const flagName = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
2093
+ const inlineValue = equalsIndex === -1 ? void 0 : arg.slice(equalsIndex + 1);
2094
+ if (VALUE_FLAGS.has(flagName)) {
2095
+ const value = inlineValue ?? argv[index + 1];
2096
+ if (!value || value.startsWith("-")) {
2097
+ throw new CliError(`Missing value for ${flagName}`);
2098
+ }
2099
+ flags[flagName] = value;
2100
+ if (inlineValue === void 0) index += 1;
2101
+ continue;
2102
+ }
2103
+ if (BOOLEAN_FLAGS.has(flagName)) {
2104
+ flags[flagName === "-y" ? "--yes" : flagName === "-h" ? "--help" : flagName] = true;
2105
+ continue;
2106
+ }
2107
+ throw new CliError(`Unknown option: ${arg}`);
2108
+ }
2109
+ return { flags, positional };
2110
+ }
2111
+ function stringFlag(flags, name) {
2112
+ const value = flags[name];
2113
+ return typeof value === "string" ? value : void 0;
2114
+ }
2115
+ function boolFlag(flags, name) {
2116
+ return flags[name] === true;
2117
+ }
2118
+ function requiredFlag(flags, name) {
2119
+ const value = stringFlag(flags, name);
2120
+ if (!value) throw new CliError(`Missing required option: ${name}`);
2121
+ return value;
2122
+ }
2123
+ function parseLimit(flags) {
2124
+ const raw = stringFlag(flags, "--limit");
2125
+ if (!raw) return void 0;
2126
+ const limit = Number(raw);
2127
+ if (!Number.isInteger(limit) || limit <= 0) {
2128
+ throw new CliError("--limit must be a positive integer");
2129
+ }
2130
+ return limit;
2131
+ }
2132
+ function validateEngine(engine) {
2133
+ if (!ENGINES2.has(engine)) {
2134
+ throw new CliError("Engine must be one of: meilisearch, typesense, elasticsearch");
2135
+ }
2136
+ return engine;
2137
+ }
2138
+ function validateKeyType(type) {
2139
+ if (!KEY_TYPES.has(type)) {
2140
+ throw new CliError("Key type must be one of: admin, search");
254
2141
  }
2142
+ return type;
2143
+ }
2144
+ function encode(value) {
2145
+ return encodeURIComponent(value);
2146
+ }
2147
+ function clientFromFlags(flags) {
2148
+ const { url, profile } = getProfile(stringFlag(flags, "--url"));
2149
+ return createApiClient(url, profile);
2150
+ }
2151
+ async function confirmOrSkip(message, flags) {
2152
+ if (boolFlag(flags, "--yes")) return true;
2153
+ const answer = await (0, import_prompts2.default)({
2154
+ type: "confirm",
2155
+ name: "confirmed",
2156
+ message,
2157
+ initial: false
2158
+ });
2159
+ return answer.confirmed === true;
2160
+ }
2161
+ function printHelp(io) {
2162
+ io.stdout(`@headband/cli
2163
+
2164
+ Usage:
2165
+ headband <command> [options]
2166
+
2167
+ Commands:
2168
+ init [--yes]
2169
+ login [--url <url>]
2170
+ logout [--url <url>] [--yes]
2171
+ whoami [--url <url>] [--json]
2172
+ projects list [--json]
2173
+ projects create <name> --engine <meilisearch|typesense|elasticsearch>
2174
+ keys list [--json]
2175
+ keys create <name> --type <admin|search> [--project <id>] [--json]
2176
+ keys revoke <id> [--yes]
2177
+ indexes list [--json]
2178
+ indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
2179
+ indexes delete <uid> [--yes]
2180
+ indexes settings get <uid> [--json]
2181
+ indexes settings set <uid> --file <settings.json> [--json]
2182
+ indexes stats <uid> [--json]
2183
+ instances list [--json]
2184
+ instances create <engine> [--name <name>] [--lane <lane>] [--memory <size>] [--region <region>] [--idle-timeout <s>]
2185
+ instances show|wake|park <id> [--json]
2186
+ instances delete <id> [--yes]
2187
+ instances connect <id> [--client <node|curl>]
2188
+ docs push <file> --index <uid>
2189
+ docs delete <docId> --index <uid>
2190
+ search <index> <query> [--limit N] [--filter <expr>] [--json]
2191
+ tasks list [--json]
2192
+ tasks get <id> [--json]
2193
+ schema diff [--url <url>]
2194
+ schema generate [--slug <slug>] [--dir <dir>] [--url <url>]
2195
+ schema apply [--allow-destructive] [--dir <dir>] [--yes] [--url <url>]
2196
+ schema status [--dir <dir>] [--url <url>]
2197
+ sync init [--table <name>] [--write]
2198
+ sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--url <url>]
2199
+ sync worker --scaffold docker [--force]
2200
+ sync reconcile [--fix] [--url <url>]
2201
+ sync retry-dead-letters
2202
+
2203
+ Global options:
2204
+ --url <url> Select profile URL (default: stored single profile, else ${DEFAULT_URL})
2205
+ --json Print machine-readable JSON for supported commands
2206
+ --yes Skip confirmation prompts
2207
+ --help Show help`);
2208
+ }
2209
+ function printCommandHelp(command, io) {
2210
+ const help = {
2211
+ login: "Usage: headband login [--url <url>]",
2212
+ logout: "Usage: headband logout [--url <url>] [--yes]",
2213
+ whoami: "Usage: headband whoami [--url <url>] [--json]",
2214
+ projects: "Usage:\n headband projects list [--json]\n headband projects create <name> --engine <meilisearch|typesense|elasticsearch>",
2215
+ keys: "Usage:\n headband keys list [--json]\n headband keys create <name> --type <admin|search> [--project <id>] [--json]\n headband keys revoke <id> [--yes]",
2216
+ indexes: [
2217
+ "Usage:",
2218
+ " headband indexes list [--json]",
2219
+ " headband indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]",
2220
+ " headband indexes delete <uid> [--yes]",
2221
+ " headband indexes settings get <uid> [--json]",
2222
+ " headband indexes settings set <uid> --file <settings.json> [--json]",
2223
+ " headband indexes stats <uid> [--json]",
2224
+ "",
2225
+ "Options for create:",
2226
+ " --instance <id> Create the index on one of your dedicated engines (ids from headband instances list).",
2227
+ " The index takes that engine's type; --engine, if given, must match it."
2228
+ ].join("\n"),
2229
+ instances: INSTANCES_USAGE,
2230
+ docs: "Usage:\n headband docs push <file> --index <uid>\n headband docs delete <docId> --index <uid>",
2231
+ search: "Usage: headband search <index> <query> [--limit N] [--filter <expr>] [--json]",
2232
+ tasks: "Usage:\n headband tasks list [--json]\n headband tasks get <id> [--json]",
2233
+ schema: "Usage:\n headband schema diff\n headband schema generate [--slug <slug>] [--dir <dir>]\n headband schema apply [--allow-destructive] [--dir <dir>] [--yes]\n headband schema status [--dir <dir>]",
2234
+ sync: "Usage:\n headband sync init [--table <name>] [--write]\n headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>]\n headband sync worker --scaffold docker [--force]\n headband sync reconcile [--fix]\n headband sync retry-dead-letters"
2235
+ };
2236
+ io.stdout(help[command] || "");
2237
+ }
2238
+ async function runLogin(flags, io) {
2239
+ await login(stringFlag(flags, "--url") || DEFAULT_URL, io);
2240
+ }
2241
+ async function runLogout(flags, io) {
2242
+ const { url: selectedUrl, profile } = getProfile(stringFlag(flags, "--url"));
2243
+ io.stdout(
2244
+ `Removing local credentials for ${selectedUrl}. Revoke this key with headband keys revoke ${profile.keyId} or in the dashboard if it should stop working.`
2245
+ );
2246
+ if (!boolFlag(flags, "--yes")) {
2247
+ const shouldRevoke = await confirmOrSkip(
2248
+ "Revoke this CLI key server-side before logging out?",
2249
+ flags
2250
+ );
2251
+ if (shouldRevoke) {
2252
+ const client = createApiClient(selectedUrl, profile);
2253
+ await client.delete(`/v1/keys/${encode(profile.keyId)}`);
2254
+ io.stdout("Revoked CLI key.");
2255
+ }
2256
+ }
2257
+ removeProfile(selectedUrl);
2258
+ io.stdout(`Logged out of ${selectedUrl}.`);
2259
+ }
2260
+ async function runWhoami(flags, io) {
2261
+ const { url, profile } = getProfile(stringFlag(flags, "--url"));
2262
+ const client = createApiClient(url, profile);
2263
+ await client.get("/v1/indexes");
2264
+ const info = {
2265
+ url,
2266
+ orgName: profile.orgName,
2267
+ orgId: profile.orgId,
2268
+ keyPrefix: profile.keyPrefix,
2269
+ keyId: profile.keyId,
2270
+ valid: true
2271
+ };
2272
+ if (boolFlag(flags, "--json")) {
2273
+ printJson(io, info);
2274
+ return;
2275
+ }
2276
+ printTable(io, [info], ["url", "orgName", "keyPrefix", "keyId", "valid"]);
2277
+ }
2278
+ async function runProjects(args, flags, io) {
2279
+ const action = args[0];
2280
+ const client = clientFromFlags(flags);
2281
+ if (action === "list") {
2282
+ const response = await client.get(
2283
+ "/v1/projects"
2284
+ );
2285
+ if (boolFlag(flags, "--json")) printJson(io, response);
2286
+ else printTable(io, response.data.projects, ["id", "name", "engine", "prefix", "createdAt"]);
2287
+ return;
2288
+ }
2289
+ if (action === "create") {
2290
+ const name = args[1];
2291
+ if (!name) throw new CliError("Missing project name");
2292
+ const engine = validateEngine(requiredFlag(flags, "--engine"));
2293
+ const response = await client.post(
2294
+ "/v1/projects",
2295
+ {
2296
+ name,
2297
+ engine
2298
+ }
2299
+ );
2300
+ if (boolFlag(flags, "--json")) printJson(io, response);
2301
+ else printTable(io, [response.data.project], ["id", "name", "engine", "prefix", "createdAt"]);
2302
+ return;
2303
+ }
2304
+ printCommandHelp("projects", io);
2305
+ throw new CliError("Unknown projects command");
2306
+ }
2307
+ async function runKeys(args, flags, io) {
2308
+ const action = args[0];
2309
+ const client = clientFromFlags(flags);
2310
+ if (action === "list") {
2311
+ const response = await client.get("/v1/keys");
2312
+ if (boolFlag(flags, "--json")) printJson(io, response);
2313
+ else
2314
+ printTable(io, response.data.keys, [
2315
+ "id",
2316
+ "name",
2317
+ "type",
2318
+ "keyPrefix",
2319
+ "projectId",
2320
+ "lastUsedAt"
2321
+ ]);
2322
+ return;
2323
+ }
2324
+ if (action === "create") {
2325
+ const name = args[1];
2326
+ if (!name) throw new CliError("Missing key name");
2327
+ const body = {
2328
+ name,
2329
+ type: validateKeyType(requiredFlag(flags, "--type"))
2330
+ };
2331
+ const projectId = stringFlag(flags, "--project");
2332
+ if (projectId) body.projectId = projectId;
2333
+ const response = await client.post("/v1/keys", body);
2334
+ if (boolFlag(flags, "--json")) {
2335
+ printJson(io, response);
2336
+ return;
2337
+ }
2338
+ io.stdout(import_picocolors3.default.yellow("Store this API key now. It will not be shown again."));
2339
+ io.stdout(response.data.apiKey);
2340
+ printTable(io, [response.data.key], ["id", "name", "type", "keyPrefix"]);
2341
+ return;
2342
+ }
2343
+ if (action === "revoke") {
2344
+ const id = args[1];
2345
+ if (!id) throw new CliError("Missing key id");
2346
+ if (!await confirmOrSkip(`Revoke key ${id}?`, flags)) {
2347
+ io.stdout("Cancelled.");
2348
+ return;
2349
+ }
2350
+ const response = await client.delete(`/v1/keys/${encode(id)}`);
2351
+ if (boolFlag(flags, "--json")) printJson(io, response);
2352
+ else io.stdout(`Revoked key ${id}.`);
2353
+ return;
2354
+ }
2355
+ printCommandHelp("keys", io);
2356
+ throw new CliError("Unknown keys command");
2357
+ }
2358
+ async function runIndexes(args, flags, io) {
2359
+ const action = args[0];
2360
+ const client = clientFromFlags(flags);
2361
+ if (action === "list") {
2362
+ const response = await client.get("/v1/indexes");
2363
+ if (boolFlag(flags, "--json")) printJson(io, response);
2364
+ else {
2365
+ const onInstance = response.results.some((r) => r.engineIndexUid);
2366
+ printTable(io, response.results, [
2367
+ "uid",
2368
+ "primaryKey",
2369
+ "engine",
2370
+ ...onInstance ? ["instanceId", "engineIndexUid"] : [],
2371
+ "createdAt",
2372
+ "updatedAt"
2373
+ ]);
2374
+ }
2375
+ return;
2376
+ }
2377
+ if (action === "create") {
2378
+ const uid = args[1];
2379
+ if (!uid) throw new CliError("Missing index uid");
2380
+ const body = { uid };
2381
+ const primaryKey = stringFlag(flags, "--primary-key");
2382
+ const engine = stringFlag(flags, "--engine");
2383
+ const instanceId = stringFlag(flags, "--instance");
2384
+ if (primaryKey) body.primaryKey = primaryKey;
2385
+ if (engine) body.engine = validateEngine(engine);
2386
+ if (instanceId) body.instanceId = instanceId;
2387
+ const response = await client.post("/v1/indexes", body);
2388
+ if (boolFlag(flags, "--json")) {
2389
+ printJson(io, response);
2390
+ return;
2391
+ }
2392
+ printTable(
2393
+ io,
2394
+ [response],
2395
+ response.instanceId ? ["indexUid", "engine", "instanceId", "engineIndexUid", "status"] : ["indexUid", "engine", "status"]
2396
+ );
2397
+ if (typeof response.engineIndexUid === "string") {
2398
+ io.stdout(
2399
+ `On the instance endpoint this index is named ${response.engineIndexUid}. Use that name with the engine's own client.`
2400
+ );
2401
+ }
2402
+ return;
2403
+ }
2404
+ if (action === "delete") {
2405
+ const uid = args[1];
2406
+ if (!uid) throw new CliError("Missing index uid");
2407
+ if (!await confirmOrSkip(`Delete index ${uid}?`, flags)) {
2408
+ io.stdout("Cancelled.");
2409
+ return;
2410
+ }
2411
+ const response = await client.delete(`/v1/indexes/${encode(uid)}`);
2412
+ if (boolFlag(flags, "--json")) printJson(io, response);
2413
+ else io.stdout(`Deleted index ${uid}.`);
2414
+ return;
2415
+ }
2416
+ if (action === "settings") {
2417
+ const settingsAction = args[1];
2418
+ const uid = args[2];
2419
+ if (!uid) throw new CliError("Missing index uid");
2420
+ if (settingsAction === "get") {
2421
+ const response = await client.get(`/v1/indexes/${encode(uid)}/settings`);
2422
+ printJson(io, response);
2423
+ return;
2424
+ }
2425
+ if (settingsAction === "set") {
2426
+ const file = requiredFlag(flags, "--file");
2427
+ const settings = JSON.parse((0, import_fs8.readFileSync)(file, "utf-8"));
2428
+ const response = await client.patch(`/v1/indexes/${encode(uid)}/settings`, settings);
2429
+ printJson(io, response);
2430
+ return;
2431
+ }
2432
+ }
2433
+ if (action === "stats") {
2434
+ const uid = args[1];
2435
+ if (!uid) throw new CliError("Missing index uid");
2436
+ const response = await client.get(`/v1/indexes/${encode(uid)}/stats`);
2437
+ printJson(io, response);
2438
+ return;
2439
+ }
2440
+ printCommandHelp("indexes", io);
2441
+ throw new CliError("Unknown indexes command");
2442
+ }
2443
+ async function runDocs(args, flags, io) {
2444
+ const action = args[0];
2445
+ const client = clientFromFlags(flags);
2446
+ if (action === "push") {
2447
+ const file = args[1];
2448
+ if (!file) throw new CliError("Missing documents file");
2449
+ const index = requiredFlag(flags, "--index");
2450
+ const documents = parseDocumentsFile(file);
2451
+ const count = await pushDocuments(client, index, documents, io);
2452
+ io.stdout(`Pushed ${count} documents.`);
2453
+ return;
2454
+ }
2455
+ if (action === "delete") {
2456
+ const docId = args[1];
2457
+ if (!docId) throw new CliError("Missing document id");
2458
+ const index = requiredFlag(flags, "--index");
2459
+ const response = await client.delete(`/v1/documents/${encode(docId)}?index=${encode(index)}`);
2460
+ if (boolFlag(flags, "--json")) printJson(io, response);
2461
+ else io.stdout(`Deleted document ${docId}.`);
2462
+ return;
2463
+ }
2464
+ printCommandHelp("docs", io);
2465
+ throw new CliError("Unknown docs command");
2466
+ }
2467
+ async function runSearch(args, flags, io) {
2468
+ const index = args[0];
2469
+ const query = args.slice(1).join(" ");
2470
+ if (!index) throw new CliError("Missing search index");
2471
+ if (!query) throw new CliError("Missing search query");
2472
+ const client = clientFromFlags(flags);
2473
+ const body = {
2474
+ index,
2475
+ q: query
2476
+ };
2477
+ const limit = parseLimit(flags);
2478
+ const filter = stringFlag(flags, "--filter");
2479
+ if (limit) body.limit = limit;
2480
+ if (filter) body.filter = filter;
2481
+ const response = await client.post("/v1/search", body);
2482
+ if (boolFlag(flags, "--json")) {
2483
+ printJson(io, response);
2484
+ return;
2485
+ }
2486
+ const total = response.estimatedTotalHits ?? response.nbHits ?? response.hits?.length ?? 0;
2487
+ io.stdout(`${total} hits in ${response.processingTimeMs ?? 0}ms`);
2488
+ printTable(io, (response.hits || []).map(summarizeHit));
2489
+ }
2490
+ async function runTasks(args, flags, io) {
2491
+ const action = args[0];
2492
+ const client = clientFromFlags(flags);
2493
+ if (action === "list") {
2494
+ const response = await client.get("/v1/tasks");
2495
+ if (boolFlag(flags, "--json")) printJson(io, response);
2496
+ else printJson(io, response);
2497
+ return;
2498
+ }
2499
+ if (action === "get") {
2500
+ const id = args[1];
2501
+ if (!id) throw new CliError("Missing task id");
2502
+ const response = await client.get(`/v1/tasks/${encode(id)}`);
2503
+ printJson(io, response);
2504
+ return;
2505
+ }
2506
+ printCommandHelp("tasks", io);
2507
+ throw new CliError("Unknown tasks command");
2508
+ }
2509
+ async function runSchema(args, flags, io) {
2510
+ const action = args[0];
2511
+ const dir = stringFlag(flags, "--dir") || DEFAULT_MIGRATIONS_DIR;
2512
+ const { url, profile } = getProfile(stringFlag(flags, "--url"));
2513
+ const client = new HeadbandClient({ url, adminKey: profile.apiKey });
2514
+ const { config, adapter } = await loadHeadbandConfig(process.cwd());
2515
+ const allowDestructive = boolFlag(flags, "--allow-destructive");
2516
+ if (action === "diff") {
2517
+ const code = await runSchemaDiff({ config, client, io, allowDestructive });
2518
+ if (code !== 0) process.exitCode = code;
2519
+ return;
2520
+ }
2521
+ if (action === "generate") {
2522
+ await runSchemaGenerate({
2523
+ config,
2524
+ client,
2525
+ dir,
2526
+ slug: stringFlag(flags, "--slug"),
2527
+ io,
2528
+ allowDestructive
2529
+ });
2530
+ return;
2531
+ }
2532
+ if (action === "apply") {
2533
+ if (!adapter) {
2534
+ throw new CliError(
2535
+ "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for schema apply \u2014 it's needed to backfill documents during a reindex."
2536
+ );
2537
+ }
2538
+ if (!await confirmOrSkip("Apply pending schema migrations?", flags)) {
2539
+ io.stdout("Cancelled.");
2540
+ return;
2541
+ }
2542
+ const result = await runSchemaApply({ config, adapter, client, dir, allowDestructive, io });
2543
+ io.stdout(
2544
+ `Applied ${result.appliedOps} op(s) across ${result.appliedFiles} migration file(s).`
2545
+ );
2546
+ return;
2547
+ }
2548
+ if (action === "status") {
2549
+ await runSchemaStatus({ config, client, dir, io });
2550
+ return;
2551
+ }
2552
+ printCommandHelp("schema", io);
2553
+ throw new CliError("Unknown schema command");
2554
+ }
2555
+ function parseIntFlag(flags, name) {
2556
+ const raw = stringFlag(flags, name);
2557
+ if (!raw) return void 0;
2558
+ const value = Number(raw);
2559
+ if (!Number.isInteger(value) || value <= 0) {
2560
+ throw new CliError(`${name} must be a positive integer`);
2561
+ }
2562
+ return value;
2563
+ }
2564
+ async function runSync(args, flags, io) {
2565
+ const action = args[0];
2566
+ if (action === "init") {
2567
+ runSyncInit({
2568
+ io,
2569
+ table: stringFlag(flags, "--table"),
2570
+ write: boolFlag(flags, "--write"),
2571
+ cwd: process.cwd()
2572
+ });
2573
+ return;
2574
+ }
2575
+ if (action === "worker") {
2576
+ const scaffold = stringFlag(flags, "--scaffold");
2577
+ if (scaffold) {
2578
+ if (scaffold !== "docker") throw new CliError(`Unknown scaffold target: ${scaffold}`);
2579
+ runSyncScaffoldDocker({ cwd: process.cwd(), io, force: boolFlag(flags, "--force") });
2580
+ return;
2581
+ }
2582
+ const { url, profile } = getProfile(stringFlag(flags, "--url"));
2583
+ const client = new HeadbandClient({ url, adminKey: profile.apiKey });
2584
+ const { config, adapter } = await loadHeadbandConfig(process.cwd());
2585
+ if (!adapter) {
2586
+ throw new CliError(
2587
+ "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync worker."
2588
+ );
2589
+ }
2590
+ const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
2591
+ table: config.sync?.outboxTable
2592
+ });
2593
+ try {
2594
+ await runSyncWorkerCmd({
2595
+ config,
2596
+ adapter,
2597
+ client,
2598
+ outbox,
2599
+ io,
2600
+ // Only override runSyncWorkerCmd's own default (loop unless
2601
+ // --budget-ms is set) when the caller explicitly passed --loop —
2602
+ // omitting it here keeps the bare `sync worker` default unchanged.
2603
+ loop: boolFlag(flags, "--loop") ? true : void 0,
2604
+ budgetMs: parseIntFlag(flags, "--budget-ms"),
2605
+ batchSize: parseIntFlag(flags, "--batch-size"),
2606
+ reconcileEvery: parseIntFlag(flags, "--reconcile-every"),
2607
+ onReconcileTick: async () => {
2608
+ const apiClient = clientFromFlags(flags);
2609
+ await runSyncReconcile({
2610
+ config,
2611
+ adapter,
2612
+ client,
2613
+ io,
2614
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
2615
+ });
2616
+ }
2617
+ });
2618
+ } finally {
2619
+ await close();
2620
+ }
2621
+ return;
2622
+ }
2623
+ if (action === "reconcile") {
2624
+ const { url, profile } = getProfile(stringFlag(flags, "--url"));
2625
+ const client = new HeadbandClient({ url, adminKey: profile.apiKey });
2626
+ const apiClient = clientFromFlags(flags);
2627
+ const { config, adapter } = await loadHeadbandConfig(process.cwd());
2628
+ if (!adapter) {
2629
+ throw new CliError(
2630
+ "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync reconcile."
2631
+ );
2632
+ }
2633
+ await runSyncReconcile({
2634
+ config,
2635
+ adapter,
2636
+ client,
2637
+ io,
2638
+ fix: boolFlag(flags, "--fix"),
2639
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
2640
+ });
2641
+ return;
2642
+ }
2643
+ if (action === "retry-dead-letters") {
2644
+ const { config } = await loadHeadbandConfig(process.cwd());
2645
+ const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
2646
+ table: config.sync?.outboxTable
2647
+ });
2648
+ try {
2649
+ await runSyncRetryDeadLetters({ outbox, io });
2650
+ } finally {
2651
+ await close();
2652
+ }
2653
+ return;
2654
+ }
2655
+ printCommandHelp("sync", io);
2656
+ throw new CliError("Unknown sync command");
2657
+ }
2658
+ async function run(argv, context = { io: defaultIO }) {
2659
+ const { flags, positional } = parseArgv(argv);
2660
+ const command = positional[0];
2661
+ const args = positional.slice(1);
2662
+ if (boolFlag(flags, "--help") || command === "help") {
2663
+ if (command && command !== "help") printCommandHelp(command, context.io);
2664
+ else printHelp(context.io);
2665
+ return;
2666
+ }
2667
+ switch (command || "init") {
2668
+ case "init":
2669
+ await init({ yes: boolFlag(flags, "--yes") });
2670
+ return;
2671
+ case "login":
2672
+ await runLogin(flags, context.io);
2673
+ return;
2674
+ case "logout":
2675
+ await runLogout(flags, context.io);
2676
+ return;
2677
+ case "whoami":
2678
+ await runWhoami(flags, context.io);
2679
+ return;
2680
+ case "projects":
2681
+ await runProjects(args, flags, context.io);
2682
+ return;
2683
+ case "keys":
2684
+ await runKeys(args, flags, context.io);
2685
+ return;
2686
+ case "indexes":
2687
+ await runIndexes(args, flags, context.io);
2688
+ return;
2689
+ case "docs":
2690
+ await runDocs(args, flags, context.io);
2691
+ return;
2692
+ case "search":
2693
+ await runSearch(args, flags, context.io);
2694
+ return;
2695
+ case "tasks":
2696
+ await runTasks(args, flags, context.io);
2697
+ return;
2698
+ case "schema":
2699
+ await runSchema(args, flags, context.io);
2700
+ return;
2701
+ case "sync":
2702
+ await runSync(args, flags, context.io);
2703
+ return;
2704
+ case "instances":
2705
+ await runInstances(
2706
+ args,
2707
+ flags,
2708
+ context.io,
2709
+ clientFromFlags(flags),
2710
+ (message) => confirmOrSkip(message, flags)
2711
+ );
2712
+ return;
2713
+ default:
2714
+ printHelp(context.io);
2715
+ throw new CliError(`Unknown command: ${command}`);
2716
+ }
2717
+ }
2718
+ function isDirectRun() {
2719
+ return typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
2720
+ }
2721
+ if (isDirectRun()) {
2722
+ run(process.argv.slice(2)).catch((error) => {
2723
+ const message = error instanceof ApiError || error instanceof CliError ? error.message : String(error);
2724
+ defaultIO.stderr(message);
2725
+ process.exit(1);
2726
+ });
255
2727
  }
256
- main().catch((err) => {
257
- console.error(err);
258
- process.exit(1);
2728
+ // Annotate the CommonJS export names for ESM import in node:
2729
+ 0 && (module.exports = {
2730
+ parseArgv,
2731
+ run
259
2732
  });