nymbal 1.0.0 → 1.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.
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/index.ts
2
- import { defineCommand as defineCommand8, runMain } from "citty";
2
+ import { defineCommand as defineCommand15, runMain } from "citty";
3
3
 
4
4
  // src/utils/version.ts
5
5
  import { readFileSync } from "fs";
@@ -26,8 +26,10 @@ function getCliVersion() {
26
26
  }
27
27
 
28
28
  // src/commands/dev.ts
29
+ import { resolve as resolve4 } from "path";
29
30
  import { defineCommand as defineCommand2 } from "citty";
30
31
  import pc from "picocolors";
32
+ import { KNOWN_PROVIDERS } from "@nymbalhq/config";
31
33
 
32
34
  // src/utils/project.ts
33
35
  import { existsSync } from "fs";
@@ -43,22 +45,22 @@ async function loadProject(startDir = process.cwd()) {
43
45
  }
44
46
 
45
47
  // src/commands/dev.ts
46
- import { createApp, runSeed } from "@nymbalhq/platform";
48
+ import { createApp, createLogger, runSeed, sqliteFile } from "@nymbalhq/platform";
47
49
  import { createHttpServer } from "@nymbalhq/http";
48
50
 
49
51
  // src/utils/spawn.ts
50
52
  import { spawn } from "child_process";
51
53
  function run(command, args, options = {}) {
52
- const child = spawn(command, args, {
54
+ const child2 = spawn(command, args, {
53
55
  stdio: "inherit",
54
56
  shell: false,
55
57
  ...options
56
58
  });
57
- const done = new Promise((resolve4, reject) => {
58
- child.on("error", reject);
59
- child.on("exit", (code) => resolve4(code ?? 0));
59
+ const done = new Promise((resolve10, reject) => {
60
+ child2.on("error", reject);
61
+ child2.on("exit", (code) => resolve10(code ?? 0));
60
62
  });
61
- return { child, done };
63
+ return { child: child2, done };
62
64
  }
63
65
  async function runOrFail(command, args, options = {}) {
64
66
  const { done } = run(command, args, options);
@@ -71,16 +73,16 @@ async function runOrFail(command, args, options = {}) {
71
73
  // src/utils/port.ts
72
74
  import { createServer } from "net";
73
75
  async function isPortFree(port, host) {
74
- return new Promise((resolve4) => {
76
+ return new Promise((resolve10) => {
75
77
  const server = createServer();
76
- server.once("error", (err) => {
77
- if (err.code !== "EADDRINUSE" && err.code !== "EACCES") {
78
- console.error(`[dev] unexpected error probing port ${port}:`, err.message);
78
+ server.once("error", (err2) => {
79
+ if (err2.code !== "EADDRINUSE" && err2.code !== "EACCES") {
80
+ console.error(`[dev] unexpected error probing port ${port}:`, err2.message);
79
81
  }
80
- resolve4(false);
82
+ resolve10(false);
81
83
  });
82
84
  server.once("listening", () => {
83
- server.close(() => resolve4(true));
85
+ server.close(() => resolve10(true));
84
86
  });
85
87
  if (host) {
86
88
  server.listen(port, host);
@@ -233,16 +235,16 @@ async function runShutdown(steps, opts) {
233
235
  pending = step.name;
234
236
  try {
235
237
  await step.run();
236
- } catch (err) {
238
+ } catch (err2) {
237
239
  failed = true;
238
- opts.logger.error({ err, step: step.name }, `Shutdown step "${step.name}" failed`);
240
+ opts.logger.error({ err: err2, step: step.name }, `Shutdown step "${step.name}" failed`);
239
241
  }
240
242
  }
241
243
  pending = void 0;
242
244
  })();
243
245
  let timer;
244
- const bound = new Promise((resolve4) => {
245
- timer = setTimeout(() => resolve4("bound"), opts.boundMs);
246
+ const bound = new Promise((resolve10) => {
247
+ timer = setTimeout(() => resolve10("bound"), opts.boundMs);
246
248
  });
247
249
  const outcome = await Promise.race([work.then(() => "done"), bound]);
248
250
  clearTimeout(timer);
@@ -256,6 +258,63 @@ async function runShutdown(steps, opts) {
256
258
  return opts.exit(failed ? 1 : 0);
257
259
  }
258
260
 
261
+ // src/commands/dev-summary.ts
262
+ import { displayUrl } from "@nymbalhq/types";
263
+ var WIDTH = 100;
264
+ var LABEL = 14;
265
+ var NAME_COLUMN = 20;
266
+ var columns = (text2) => [...text2].length;
267
+ function fit(text2, width = WIDTH) {
268
+ return columns(text2) <= width ? text2 : `${[...text2].slice(0, width - 1).join("")}\u2026`;
269
+ }
270
+ function row(label, value) {
271
+ return fit(` ${label.padEnd(LABEL)} ${value}`);
272
+ }
273
+ function capabilityList(capabilities, width) {
274
+ if (capabilities.length === 0) return "\u2014";
275
+ const all = capabilities.join(", ");
276
+ if (columns(all) <= width) return all;
277
+ for (let shown = capabilities.length - 1; shown > 0; shown--) {
278
+ const text2 = `${capabilities.slice(0, shown).join(", ")}, \u2026 (+${capabilities.length - shown})`;
279
+ if (columns(text2) <= width) return text2;
280
+ }
281
+ return `\u2026 (+${capabilities.length})`;
282
+ }
283
+ function renderDevSummary(info) {
284
+ const slotWidth = Math.min(NAME_COLUMN, Math.max(...info.slots.map((s) => columns(s.slot)), 0));
285
+ const providerWidth = Math.min(
286
+ NAME_COLUMN,
287
+ Math.max(...info.slots.map((s) => columns(s.provider)), 0)
288
+ );
289
+ const slotLines = info.slots.map(({ slot, provider, capabilities }) => {
290
+ const lead = ` ${slot.padEnd(slotWidth)} ${provider.padEnd(providerWidth)} `;
291
+ return fit(`${lead}${capabilityList(capabilities, WIDTH - columns(lead))}`);
292
+ });
293
+ const commandStore = info.commandStore.kind === "sqlite" ? `sqlite ${info.commandStore.path}` : `postgres ${displayUrl(info.commandStore.url)}`;
294
+ const lines = [
295
+ fit(` ${info.store}`),
296
+ "",
297
+ row("API", info.apiUrl),
298
+ row(
299
+ "storefront",
300
+ info.storefront === null ? "not started (--api-only)" : `${info.storefront.url} (${info.storefront.template})`
301
+ ),
302
+ row(
303
+ "admin",
304
+ info.admin === null ? "not built \u2014 run pnpm --filter @nymbalhq/admin build" : info.admin.url
305
+ ),
306
+ row("command store", commandStore),
307
+ "",
308
+ ...slotLines,
309
+ "",
310
+ ...info.primitives.map(({ name, provider }) => row(name, provider)),
311
+ "",
312
+ " Debug logs: run with --verbose (or LOG_LEVEL=debug)."
313
+ ];
314
+ return `${lines.join("\n")}
315
+ `;
316
+ }
317
+
259
318
  // src/commands/dev.ts
260
319
  function defaultStorefrontPort(template) {
261
320
  return template === "astro" ? 4321 : 3e3;
@@ -269,13 +328,7 @@ function preferredStorefrontPort(template, flagPort, env) {
269
328
  }
270
329
  return defaultStorefrontPort(template);
271
330
  }
272
- function storefrontEnv(config, apiUrl, storefrontPort, baseEnv = process.env) {
273
- const stubPayments = config.commerce.payments.provider === "stripe" ? {} : {
274
- PUBLIC_NYMBAL_PAYMENTS_PROVIDER: "native-stub",
275
- // Astro
276
- NEXT_PUBLIC_NYMBAL_PAYMENTS_PROVIDER: "native-stub"
277
- // Next.js
278
- };
331
+ function storefrontEnv(_config, apiUrl, storefrontPort, baseEnv = process.env) {
279
332
  return {
280
333
  ...baseEnv,
281
334
  NYMBAL_API_URL: apiUrl,
@@ -285,8 +338,31 @@ function storefrontEnv(config, apiUrl, storefrontPort, baseEnv = process.env) {
285
338
  // Next.js
286
339
  // The framework also reads the port flag; the env var keeps the scaffold
287
340
  // config (astro.config.mjs) in sync when it honours NYMBAL_STOREFRONT_PORT.
288
- NYMBAL_STOREFRONT_PORT: String(storefrontPort),
289
- ...stubPayments
341
+ NYMBAL_STOREFRONT_PORT: String(storefrontPort)
342
+ };
343
+ }
344
+ function devLogLevel(verbose, env) {
345
+ const fromEnv = env["LOG_LEVEL"];
346
+ if (fromEnv !== void 0 && fromEnv !== "") return fromEnv;
347
+ return verbose ? "debug" : "info";
348
+ }
349
+ function summaryInfo(config, app, apiUrl, storefront, adminBuilt) {
350
+ const report = app.capabilities();
351
+ return {
352
+ store: config.store.name,
353
+ apiUrl,
354
+ storefront,
355
+ admin: adminBuilt ? { url: `${apiUrl}/admin/` } : null,
356
+ commandStore: config.infrastructure.commandStore === "sqlite" ? { kind: "sqlite", path: resolve4(sqliteFile()) } : { kind: "postgres", url: process.env["DATABASE_URL"] ?? "" },
357
+ slots: Object.keys(KNOWN_PROVIDERS).map((slot) => ({
358
+ slot,
359
+ provider: report[slot].provider,
360
+ capabilities: report[slot].capabilities
361
+ })),
362
+ primitives: ["documentStore", "eventBus", "media"].map((name) => ({
363
+ name,
364
+ provider: report[name].provider
365
+ }))
290
366
  };
291
367
  }
292
368
  function apiPort(config, flagPort) {
@@ -328,6 +404,11 @@ var devCommand = defineCommand2({
328
404
  type: "string",
329
405
  alias: "storefront-port",
330
406
  description: "Port for the storefront dev server (default: 4321 Astro / 3000 Next.js). If busy, the next free port is used. Also settable via NYMBAL_STOREFRONT_PORT."
407
+ },
408
+ verbose: {
409
+ type: "boolean",
410
+ description: "Log at debug (LOG_LEVEL, when set, wins)",
411
+ default: false
331
412
  }
332
413
  },
333
414
  async run({ args }) {
@@ -337,7 +418,15 @@ var devCommand = defineCommand2({
337
418
  const config = { ...loaded.config, http: { ...loaded.config.http, port } };
338
419
  const version = getCliVersion();
339
420
  await migrateProject(config, projectRoot);
340
- const app = await createApp(config, { version });
421
+ const logger = createLogger({
422
+ base: { store: config.store.name },
423
+ level: devLogLevel(args.verbose, process.env)
424
+ });
425
+ const app = await createApp(config, {
426
+ version,
427
+ logger,
428
+ ...loaded.adapters !== void 0 && { adapters: loaded.adapters }
429
+ });
341
430
  const { platform } = app;
342
431
  if (args.seed) {
343
432
  const existing = await platform.documentStore.query("products", {
@@ -352,7 +441,8 @@ var devCommand = defineCommand2({
352
441
  documentStore: platform.documentStore,
353
442
  eventBus: platform.eventBus,
354
443
  logger: platform.logger,
355
- version
444
+ version,
445
+ search: app.adapters.search
356
446
  });
357
447
  }
358
448
  }
@@ -363,15 +453,14 @@ var devCommand = defineCommand2({
363
453
  version
364
454
  });
365
455
  const adminRoot = resolveAdminRoot();
366
- if (adminRoot === null) {
367
- console.log(
368
- pc.yellow(
369
- "\u2192 The admin is not built, so /admin/ answers 404: run `pnpm --filter @nymbalhq/admin build`."
370
- )
371
- );
372
- }
373
456
  await app.attachHttp(http.adapter, { adminRoot });
374
457
  await http.start();
458
+ const apiUrl = `http://localhost:${config.http.port}`;
459
+ const printSummary = (storefront) => {
460
+ console.log(
461
+ renderDevSummary(summaryInfo(config, app, apiUrl, storefront, adminRoot !== null))
462
+ );
463
+ };
375
464
  const stopApi = [
376
465
  { name: "http.stop", run: () => http.stop() },
377
466
  { name: "app.stop", run: () => app.stop() }
@@ -382,6 +471,7 @@ var devCommand = defineCommand2({
382
471
  exit: (code) => process.exit(code)
383
472
  });
384
473
  if (args.apiOnly) {
474
+ printSummary(null);
385
475
  const shutdown2 = (signal) => {
386
476
  platform.logger.info({ signal }, "Shutting down API");
387
477
  return shutdownWith(stopApi);
@@ -402,18 +492,12 @@ var devCommand = defineCommand2({
402
492
  );
403
493
  }
404
494
  const launch = planTemplateLaunch(projectRoot, config.template, storefrontPort);
405
- const apiUrl = `http://localhost:${config.http.port}`;
406
495
  const storefrontUrl = `http://localhost:${storefrontPort}`;
407
- console.log(
408
- pc.cyan(
409
- `\u2192 Starting storefront (${pc.bold(config.template)}, ${launch.mode}) on ${pc.bold(storefrontUrl)}`
410
- )
411
- );
412
- console.log(pc.dim(` API: ${apiUrl}`));
413
496
  const templateProc = run(launch.command, launch.args, {
414
497
  cwd: launch.cwd,
415
498
  env: storefrontEnv(config, apiUrl, storefrontPort)
416
499
  });
500
+ printSummary({ template: `${config.template}, ${launch.mode}`, url: storefrontUrl });
417
501
  let shuttingDown = false;
418
502
  const shutdown = (signal) => {
419
503
  shuttingDown = true;
@@ -462,13 +546,31 @@ var buildCommand = defineCommand3({
462
546
 
463
547
  // src/commands/seed.ts
464
548
  import { defineCommand as defineCommand4 } from "citty";
465
- import { createPlatform, runSeed as runSeed2 } from "@nymbalhq/platform";
549
+ import { createPlatform, resolvePrimitives, runSeed as runSeed2 } from "@nymbalhq/platform";
466
550
  var seedCommand = defineCommand4({
467
551
  meta: { name: "seed", description: "Re-seed demo content (products, categories)" },
468
- async run() {
552
+ args: {
553
+ localImages: {
554
+ type: "boolean",
555
+ // citty registers the camelCase key only; the alias maps the kebab flag onto it (see `dev --api-only`).
556
+ alias: "local-images",
557
+ description: "Write the product images through the store's media slot instead of linking a placeholder host",
558
+ default: false
559
+ },
560
+ mediaRoot: {
561
+ type: "string",
562
+ alias: "media-root",
563
+ description: "Where local-fs media is written (default: public/imported-media)"
564
+ }
565
+ },
566
+ async run({ args }) {
469
567
  const { config, projectRoot } = await loadProject();
470
568
  await migrateProject(config, projectRoot);
471
- const platform = createPlatform(config);
569
+ const mediaRoot = typeof args.mediaRoot === "string" && args.mediaRoot !== "" ? args.mediaRoot : void 0;
570
+ const platform = createPlatform(
571
+ config,
572
+ await resolvePrimitives(config, mediaRoot === void 0 ? {} : { mediaRoot })
573
+ );
472
574
  try {
473
575
  const result = await runSeed2({
474
576
  config,
@@ -476,7 +578,8 @@ var seedCommand = defineCommand4({
476
578
  documentStore: platform.documentStore,
477
579
  eventBus: platform.eventBus,
478
580
  logger: platform.logger,
479
- version: getCliVersion()
581
+ version: getCliVersion(),
582
+ ...args.localImages && { media: platform.media }
480
583
  });
481
584
  console.log(
482
585
  result.alreadySeeded ? "\u2713 demo content already present, nothing to seed" : `\u2713 seeded ${result.products} products across ${result.categories} categories`
@@ -490,8 +593,8 @@ var seedCommand = defineCommand4({
490
593
  // src/commands/import.ts
491
594
  import { defineCommand as defineCommand5 } from "citty";
492
595
  import { intro, outro, text, select, spinner, isCancel, cancel } from "@clack/prompts";
493
- import * as p from "picocolors";
494
- import { createApp as createApp2 } from "@nymbalhq/platform";
596
+ import p from "picocolors";
597
+ import { createApp as createApp2, newCorrelationId, runAsActor, runWithCorrelation } from "@nymbalhq/platform";
495
598
  import { runWooCommerceImport, writeReport } from "@nymbalhq/importers";
496
599
  import { join as join3 } from "path";
497
600
  var importCommand = defineCommand5({
@@ -619,34 +722,38 @@ var importCommand = defineCommand5({
619
722
  const app = await createApp2(config, { version: getCliVersion() });
620
723
  loadSpin.stop("Platform ready");
621
724
  try {
622
- const report = await runWooCommerceImport(
623
- {
624
- credentials: { url: storeUrl, consumerKey, consumerSecret },
625
- entities: entityList,
626
- ai: aiEnabled,
627
- gdpr: gdprEnabled,
628
- fresh: args.fresh,
629
- concurrency,
630
- projectRoot,
631
- currency: config.store.currency,
632
- statePath
633
- },
634
- {
635
- services: app.services,
636
- repos: app.repos,
637
- adapters: { ai: app.adapters.ai, reviews: app.adapters.reviews },
638
- onProgress(event) {
639
- if (event.kind === "phase") {
640
- console.log(p.cyan(` \u2192 ${event.phase}`));
641
- } else if (event.kind === "progress") {
642
- const pct = Math.round(event.done / event.total * 100);
643
- process.stdout.write(`\r ${event.entity}: ${event.done}/${event.total} (${pct}%) `);
644
- if (event.done === event.total) process.stdout.write("\n");
645
- } else if (event.kind === "error") {
646
- console.log(p.red(` \u2717 ${event.phase} [${event.id}]: ${event.message}`));
725
+ const report = await runWithCorrelation(
726
+ newCorrelationId("import:woocommerce"),
727
+ () => runAsActor({ kind: "import", id: "woocommerce" }, () => runWooCommerceImport(
728
+ {
729
+ credentials: { url: storeUrl, consumerKey, consumerSecret },
730
+ entities: entityList,
731
+ ai: aiEnabled,
732
+ gdpr: gdprEnabled,
733
+ fresh: args.fresh,
734
+ concurrency,
735
+ projectRoot,
736
+ currency: config.store.currency,
737
+ statePath
738
+ },
739
+ {
740
+ services: app.services,
741
+ repos: app.repos,
742
+ adapters: { ai: app.adapters.ai, reviews: app.adapters.reviews },
743
+ media: app.platform.media,
744
+ onProgress(event) {
745
+ if (event.kind === "phase") {
746
+ console.log(p.cyan(` \u2192 ${event.phase}`));
747
+ } else if (event.kind === "progress") {
748
+ const pct = Math.round(event.done / event.total * 100);
749
+ process.stdout.write(`\r ${event.entity}: ${event.done}/${event.total} (${pct}%) `);
750
+ if (event.done === event.total) process.stdout.write("\n");
751
+ } else if (event.kind === "error") {
752
+ console.log(p.red(` \u2717 ${event.phase} [${event.id}]: ${event.message}`));
753
+ }
647
754
  }
648
755
  }
649
- }
756
+ ))
650
757
  );
651
758
  const reportPath = await writeReport(report, projectRoot, config.store.name);
652
759
  outro(
@@ -680,34 +787,1690 @@ var configValidateCommand = defineCommand6({
680
787
  const { config, path } = await loadProject();
681
788
  console.log(pc2.green(`\u2713 config valid`) + ` (${path})`);
682
789
  console.log(
683
- ` template: ${config.template}, cloud: ${config.infrastructure.cloud}, commandStore: ${config.infrastructure.commandStore}`
790
+ ` template: ${config.template}, commandStore: ${config.infrastructure.commandStore}, documentStore: ${config.infrastructure.documentStore}, eventBus: ${config.infrastructure.eventBus}`
684
791
  );
685
792
  process.exit(0);
686
- } catch (err) {
687
- if (err instanceof NymbalError) {
793
+ } catch (err2) {
794
+ if (err2 instanceof NymbalError) {
688
795
  console.error(pc2.red("\u2717 config invalid"));
689
- console.error(err.message);
796
+ console.error(err2.message);
690
797
  } else {
691
- console.error(err);
798
+ console.error(err2);
692
799
  }
693
800
  process.exit(1);
694
801
  }
695
802
  }
696
803
  });
697
804
 
698
- // src/commands/test.ts
805
+ // src/commands/add.ts
806
+ import { spawnSync } from "child_process";
807
+ import { existsSync as existsSync6, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
808
+ import { dirname as dirname6, join as join5 } from "path";
809
+ import { parseEnv } from "util";
699
810
  import { defineCommand as defineCommand7 } from "citty";
700
- import { loadConfig as loadConfig2 } from "@nymbalhq/config";
701
- var testCommand = defineCommand7({
811
+ import pc3 from "picocolors";
812
+ import { findConfigFile as findConfigFile2, loadConfig as loadConfig2 } from "@nymbalhq/config";
813
+ import { installLine } from "@nymbalhq/platform";
814
+
815
+ // src/recipes/holders.ts
816
+ import { existsSync as existsSync5 } from "fs";
817
+ import { readFile } from "fs/promises";
818
+ import { createRequire as createRequire2 } from "module";
819
+ import { dirname as dirname5, join as join4, resolve as resolve5 } from "path";
820
+ import { pathToFileURL } from "url";
821
+ var RECIPE_HOLDERS = {
822
+ cloudflare: "@nymbalhq/security-cloudflare",
823
+ postgres: "@nymbalhq/platform",
824
+ redis: "@nymbalhq/doc-store-redis",
825
+ s3: "@nymbalhq/media-s3",
826
+ stripe: "@nymbalhq/adapter-stripe"
827
+ };
828
+ function knownRecipes() {
829
+ return Object.keys(RECIPE_HOLDERS).sort();
830
+ }
831
+ function isKnownRecipe(name) {
832
+ return Object.hasOwn(RECIPE_HOLDERS, name);
833
+ }
834
+ function unknownRecipe(name) {
835
+ return new Error(`unknown recipe '${name}': the recipes are ${knownRecipes().join(", ")}`);
836
+ }
837
+ function packageDir(pkg, projectRoot) {
838
+ let dir = resolve5(projectRoot);
839
+ while (true) {
840
+ const candidate = join4(dir, "node_modules", pkg);
841
+ if (existsSync5(join4(candidate, "package.json"))) return candidate;
842
+ const parent = dirname5(dir);
843
+ if (parent === dir) return null;
844
+ dir = parent;
845
+ }
846
+ }
847
+ function recipeFile(name, projectRoot) {
848
+ const holder = RECIPE_HOLDERS[name];
849
+ if (holder === void 0 || !isKnownRecipe(name)) throw unknownRecipe(name);
850
+ const dir = packageDir(holder, projectRoot);
851
+ if (dir === null) return null;
852
+ try {
853
+ return createRequire2(join4(dir, "package.json")).resolve(`${holder}/recipes/${name}.json`);
854
+ } catch (error) {
855
+ const code = error.code;
856
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED") return null;
857
+ throw error;
858
+ }
859
+ }
860
+ function isInstalled(pkg, projectRoot) {
861
+ return packageDir(pkg, projectRoot) !== null;
862
+ }
863
+ async function readRecipe(name, projectRoot) {
864
+ const file = recipeFile(name, projectRoot);
865
+ if (file === null) {
866
+ throw new Error(
867
+ `${RECIPE_HOLDERS[name]} is not installed in ${projectRoot}, so its ${name} recipe cannot be read`
868
+ );
869
+ }
870
+ const recipe = JSON.parse(await readFile(file, "utf8"));
871
+ const malformed = recipe === null || typeof recipe !== "object" || recipe.name !== name || !Array.isArray(recipe.install) || !Array.isArray(recipe.set) || !Array.isArray(recipe.env) || !Array.isArray(recipe.next);
872
+ if (malformed) throw new Error(`${file} is not a ${name} recipe`);
873
+ return recipe;
874
+ }
875
+ function entryOf(dir, manifest) {
876
+ const exportsField = manifest["exports"];
877
+ const root = typeof exportsField === "object" ? exportsField["."] : exportsField;
878
+ const entry = typeof root === "string" ? root : typeof root === "object" && root !== null ? root["import"] ?? root["default"] : manifest["module"] ?? manifest["main"];
879
+ return typeof entry === "string" ? join4(dir, entry) : null;
880
+ }
881
+ async function settingsEnv(packages, projectRoot) {
882
+ const names = /* @__PURE__ */ new Set();
883
+ for (const pkg of packages.filter((p2) => p2.startsWith("@nymbalhq/"))) {
884
+ const dir = packageDir(pkg, projectRoot);
885
+ if (dir === null) continue;
886
+ const manifest = JSON.parse(await readFile(join4(dir, "package.json"), "utf8"));
887
+ const entry = entryOf(dir, manifest);
888
+ if (entry === null || !existsSync5(entry)) continue;
889
+ let mod;
890
+ try {
891
+ mod = await import(pathToFileURL(entry).href);
892
+ } catch (error) {
893
+ if (error.code === "ERR_MODULE_NOT_FOUND") continue;
894
+ throw error;
895
+ }
896
+ if (!Array.isArray(mod.settings)) continue;
897
+ for (const setting of mod.settings) {
898
+ if (typeof setting?.env === "string") names.add(setting.env);
899
+ }
900
+ }
901
+ return names;
902
+ }
903
+
904
+ // src/recipes/config-edit.ts
905
+ import { parseModule } from "magicast";
906
+ var SCAFFOLD_DEFAULTS = [
907
+ "sqlite",
908
+ "in-memory",
909
+ "in-process",
910
+ "local-fs",
911
+ "native",
912
+ "middleware"
913
+ ];
914
+ var CONFIG_MODULE = "@nymbalhq/config";
915
+ var child = (node, key) => node[key];
916
+ var children = (node, key) => node[key];
917
+ function parse(lf) {
918
+ return parseModule(lf, { tabWidth: 1 }).$ast;
919
+ }
920
+ var lfCopy = (source) => source.replace(/\r\n/g, "\n");
921
+ function unwrap(node) {
922
+ let at = node;
923
+ while (at !== void 0 && (at.type === "TSAsExpression" || at.type === "TSSatisfiesExpression" || at.type === "TSNonNullExpression" || at.type === "ParenthesizedExpression")) {
924
+ at = child(at, "expression");
925
+ }
926
+ return at;
927
+ }
928
+ function nameOf(node) {
929
+ if (node.type === "Identifier") return node["name"];
930
+ if (node.type === "StringLiteral") return node["value"];
931
+ if (node.type === "NumericLiteral") return String(node["value"]);
932
+ return void 0;
933
+ }
934
+ function configImports(program) {
935
+ return children(program, "body").filter(
936
+ (n) => n.type === "ImportDeclaration" && child(n, "source")["value"] === CONFIG_MODULE && n["importKind"] !== "type"
937
+ );
938
+ }
939
+ function importedAs(program, imported) {
940
+ for (const decl of configImports(program)) {
941
+ for (const spec of children(decl, "specifiers")) {
942
+ if (spec.type !== "ImportSpecifier" || spec["importKind"] === "type") continue;
943
+ if (nameOf(child(spec, "imported")) === imported)
944
+ return child(spec, "local")["name"];
945
+ }
946
+ }
947
+ return void 0;
948
+ }
949
+ function bindsEnv(program) {
950
+ const bound = (id) => id?.type === "Identifier" && id["name"] === "env";
951
+ return children(program, "body").some((n) => {
952
+ const decl = n.type === "ExportNamedDeclaration" ? child(n, "declaration") : n;
953
+ if (decl === null || decl === void 0) return false;
954
+ if (decl.type === "ImportDeclaration")
955
+ return children(decl, "specifiers").some((s) => bound(child(s, "local")));
956
+ if (decl.type === "VariableDeclaration")
957
+ return children(decl, "declarations").some((d) => bound(child(d, "id")));
958
+ return bound(child(decl, "id"));
959
+ });
960
+ }
961
+ function locate(source) {
962
+ const program = parse(source);
963
+ const exported = children(program, "body").find((n) => n.type === "ExportDefaultDeclaration");
964
+ if (exported === void 0) return void 0;
965
+ let config = unwrap(child(exported, "declaration"));
966
+ if (config?.type === "CallExpression") {
967
+ const callee = child(config, "callee");
968
+ const define = importedAs(program, "defineConfig") ?? "defineConfig";
969
+ if (callee.type !== "Identifier" || callee["name"] !== define) return void 0;
970
+ config = unwrap(children(config, "arguments")[0]);
971
+ }
972
+ if (config?.type !== "ObjectExpression") return void 0;
973
+ if (source[config.start] !== "{" || source[config.end - 1] !== "}") return void 0;
974
+ return {
975
+ program,
976
+ config,
977
+ envName: importedAs(program, "env"),
978
+ envBound: bindsEnv(program),
979
+ namedImport: configImports(program).find(
980
+ (d) => children(d, "specifiers").some((s) => s.type === "ImportSpecifier")
981
+ )
982
+ };
983
+ }
984
+ function isEnvRef(value) {
985
+ if (typeof value !== "object" || value === null) return false;
986
+ const keys = Object.keys(value);
987
+ return typeof value.env === "string" && keys.every((k) => k === "env" || k === "default");
988
+ }
989
+ function evaluate(node, envName) {
990
+ const at = unwrap(node);
991
+ switch (at.type) {
992
+ case "StringLiteral":
993
+ case "NumericLiteral":
994
+ case "BooleanLiteral":
995
+ return at["value"];
996
+ case "TemplateLiteral": {
997
+ const quasis = children(at, "quasis");
998
+ return children(at, "expressions").length === 0 ? (quasis[0]?.["value"]).cooked : void 0;
999
+ }
1000
+ case "ObjectExpression": {
1001
+ const out2 = {};
1002
+ for (const prop of children(at, "properties")) {
1003
+ const key = prop.type === "ObjectProperty" && !prop["computed"] ? nameOf(child(prop, "key")) : void 0;
1004
+ const value = key === void 0 ? void 0 : evaluate(child(prop, "value"), envName);
1005
+ if (key === void 0 || value === void 0) return void 0;
1006
+ out2[key] = value;
1007
+ }
1008
+ return out2;
1009
+ }
1010
+ case "CallExpression": {
1011
+ const callee = child(at, "callee");
1012
+ const args = children(at, "arguments");
1013
+ if (callee.type !== "Identifier" || callee["name"] !== (envName ?? "env")) return void 0;
1014
+ if (args.length < 1 || args.length > 2 || args.some((a) => a.type !== "StringLiteral"))
1015
+ return void 0;
1016
+ return {
1017
+ env: args[0]["value"],
1018
+ ...args[1] !== void 0 && { default: args[1]["value"] }
1019
+ };
1020
+ }
1021
+ default:
1022
+ return void 0;
1023
+ }
1024
+ }
1025
+ function sameValue(a, b) {
1026
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return a === b;
1027
+ const ka = Object.keys(a).sort();
1028
+ const kb = Object.keys(b).sort();
1029
+ return ka.length === kb.length && ka.every(
1030
+ (k, i) => k === kb[i] && sameValue(a[k], b[k])
1031
+ );
1032
+ }
1033
+ function isScaffoldDefault(value) {
1034
+ if (typeof value === "string") return SCAFFOLD_DEFAULTS.includes(value);
1035
+ const provider = value?.provider;
1036
+ return typeof provider === "string" && SCAFFOLD_DEFAULTS.includes(provider);
1037
+ }
1038
+ function styleOf(source, mod) {
1039
+ const firstString = /(['"])(?:(?!\1)[^\\\r\n]|\\.)*\1/.exec(source.slice(mod.config.start));
1040
+ const indented = /\n([ \t]+)\S/.exec(source.slice(mod.config.start));
1041
+ return {
1042
+ quote: firstString?.[1] ?? "'",
1043
+ unit: indented?.[1] ?? " ",
1044
+ envName: mod.envName ?? "env"
1045
+ };
1046
+ }
1047
+ function quoted(text2, style) {
1048
+ const q = style.quote;
1049
+ const body = text2.replace(/\\/g, "\\\\").replace(new RegExp(q, "g"), `\\${q}`).replace(/\r/g, "\\r").replace(/\n/g, "\\n");
1050
+ return `${q}${body}${q}`;
1051
+ }
1052
+ function renderKey(key, style) {
1053
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : quoted(key, style);
1054
+ }
1055
+ function render(value, indent, style) {
1056
+ if (typeof value === "string") return quoted(value, style);
1057
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1058
+ if (isEnvRef(value)) {
1059
+ const fallback = value.default === void 0 ? "" : `, ${quoted(value.default, style)}`;
1060
+ return `${style.envName}(${quoted(value.env, style)}${fallback})`;
1061
+ }
1062
+ const entries = Object.entries(value);
1063
+ if (entries.length === 0) return "{}";
1064
+ const inner = indent + style.unit;
1065
+ const lines = entries.map(
1066
+ ([k, v]) => `${inner}${renderKey(k, style)}: ${render(v, inner, style)},`
1067
+ );
1068
+ return `{
1069
+ ${lines.join("\n")}
1070
+ ${indent}}`;
1071
+ }
1072
+ function writesEnv(value) {
1073
+ if (isEnvRef(value)) return true;
1074
+ return typeof value === "object" && Object.values(value).some(writesEnv);
1075
+ }
1076
+ function indentAt(source, offset) {
1077
+ const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
1078
+ return /^[ \t]*/.exec(source.slice(lineStart))[0];
1079
+ }
1080
+ function spliced(source, edits) {
1081
+ const crlf = [];
1082
+ for (const m of source.matchAll(/\r\n/g)) crlf.push(m.index - crlf.length);
1083
+ const at = (lf) => lf + crlf.filter((p2) => p2 < lf).length;
1084
+ const lastBreak = /\r?\n(?![\s\S]*\n)/.exec(source)?.[0] ?? "\n";
1085
+ return edits.map((e) => {
1086
+ const start = at(e.at);
1087
+ const end = at(e.at + e.remove);
1088
+ const eol = /\r?\n/.exec(source.slice(end))?.[0] ?? lastBreak;
1089
+ return { start, end, text: e.text.replace(/\n/g, eol) };
1090
+ }).sort((a, b) => b.start - a.start).reduce((s, e) => s.slice(0, e.start) + e.text + s.slice(e.end), source);
1091
+ }
1092
+ function addProperty(source, object, key, value, style) {
1093
+ const props = children(object, "properties");
1094
+ const close = object.end - 1;
1095
+ const last = props.at(-1);
1096
+ if (last === void 0) {
1097
+ const indent2 = indentAt(source, object.start);
1098
+ const inside = source.slice(object.start + 1, close);
1099
+ const text2 = `${indent2}${style.unit}${renderKey(key, style)}: ${render(value, indent2 + style.unit, style)},`;
1100
+ return inside.trim() === "" ? [
1101
+ {
1102
+ at: object.start + 1,
1103
+ remove: inside.length,
1104
+ text: `
1105
+ ${text2}
1106
+ ${indent2}`
1107
+ }
1108
+ ] : [{ at: close, remove: 0, text: `
1109
+ ${text2}
1110
+ ${indent2}` }];
1111
+ }
1112
+ const after = /^[ \t]*,/.exec(source.slice(last.end, close));
1113
+ const comma = after === null ? void 0 : last.end + after[0].length;
1114
+ const lineEnd = source.slice(last.end, close).indexOf("\n");
1115
+ const property = (indent2) => `${renderKey(key, style)}: ${render(value, indent2, style)}`;
1116
+ if (lineEnd === -1) {
1117
+ return comma === void 0 ? [{ at: last.end, remove: 0, text: `, ${property(indentAt(source, last.start))}` }] : [{ at: comma, remove: 0, text: ` ${property(indentAt(source, last.start))},` }];
1118
+ }
1119
+ const indent = indentAt(source, last.start);
1120
+ const edits = [
1121
+ {
1122
+ at: last.end + lineEnd,
1123
+ remove: 0,
1124
+ text: `
1125
+ ${indent}${property(indent)}${comma === void 0 ? "" : ","}`
1126
+ }
1127
+ ];
1128
+ if (comma === void 0) edits.push({ at: last.end, remove: 0, text: "," });
1129
+ return edits;
1130
+ }
1131
+ function findProperty(object, key) {
1132
+ return children(object, "properties").findLast(
1133
+ (p2) => p2.type === "ObjectProperty" && !p2["computed"] && nameOf(child(p2, "key")) === key
1134
+ );
1135
+ }
1136
+ function editByHand(recipe, why) {
1137
+ const wanted = recipe.set.map(({ path, value }) => `${path} = ${JSON.stringify(value)}`);
1138
+ return new Error(
1139
+ `nymbal.config.ts ${why}, so the ${recipe.name} recipe cannot edit it: edit by hand and set ${wanted.join("; ")}`
1140
+ );
1141
+ }
1142
+ function setPath(source, recipe, path, value, force) {
1143
+ const lf = lfCopy(source);
1144
+ const mod = locate(lf);
1145
+ if (mod === void 0)
1146
+ throw editByHand(recipe, "does not export a defineConfig({...}) object literal");
1147
+ const style = styleOf(lf, mod);
1148
+ const keys = path.split(".");
1149
+ let object = mod.config;
1150
+ for (const [i, key] of keys.entries()) {
1151
+ const prop = findProperty(object, key);
1152
+ if (prop === void 0) {
1153
+ const nested = keys.slice(i + 1).reduceRight((v, k) => ({ [k]: v }), value);
1154
+ return {
1155
+ source: spliced(source, addProperty(lf, object, key, nested, style)),
1156
+ wrote: true
1157
+ };
1158
+ }
1159
+ const held = child(prop, "value");
1160
+ if (i === keys.length - 1) {
1161
+ const current = evaluate(held, mod.envName);
1162
+ if (current !== void 0 && sameValue(current, value)) return { source, wrote: false };
1163
+ if (!force && !isScaffoldDefault(current)) {
1164
+ return {
1165
+ conflict: { path, current: current ?? lf.slice(held.start, held.end), wanted: value }
1166
+ };
1167
+ }
1168
+ const text2 = render(value, indentAt(lf, prop.start), style);
1169
+ return {
1170
+ source: spliced(source, [{ at: held.start, remove: held.end - held.start, text: text2 }]),
1171
+ wrote: true
1172
+ };
1173
+ }
1174
+ const inner = unwrap(held);
1175
+ if (inner?.type !== "ObjectExpression") {
1176
+ throw editByHand(
1177
+ recipe,
1178
+ `holds ${keys.slice(0, i + 1).join(".")} as something other than an object literal`
1179
+ );
1180
+ }
1181
+ object = inner;
1182
+ }
1183
+ return { source, wrote: false };
1184
+ }
1185
+ function importEnv(source, recipe) {
1186
+ const lf = lfCopy(source);
1187
+ const mod = locate(lf);
1188
+ if (mod.envName !== void 0) return source;
1189
+ if (mod.envBound)
1190
+ throw editByHand(recipe, "binds `env` to something other than the env of @nymbalhq/config");
1191
+ const style = styleOf(lf, mod);
1192
+ if (mod.namedImport !== void 0) {
1193
+ const last = children(mod.namedImport, "specifiers").filter((s) => s.type === "ImportSpecifier").at(-1);
1194
+ return spliced(source, [{ at: last.end, remove: 0, text: ", env" }]);
1195
+ }
1196
+ const imports = children(mod.program, "body").filter((n) => n.type === "ImportDeclaration");
1197
+ const line = `import { env } from ${quoted(CONFIG_MODULE, style)}
1198
+ `;
1199
+ const lastImport = imports.at(-1);
1200
+ const lineEnd = lastImport === void 0 ? -1 : lf.indexOf("\n", lastImport.end);
1201
+ const edit = lastImport === void 0 ? { at: 0, remove: 0, text: line } : lineEnd === -1 ? { at: lf.length, remove: 0, text: `
1202
+ ${line}` } : { at: lineEnd + 1, remove: 0, text: line };
1203
+ return spliced(source, [edit]);
1204
+ }
1205
+ function applyRecipeToConfig(source, recipe, opts) {
1206
+ if (locate(lfCopy(source)) === void 0) {
1207
+ throw editByHand(recipe, "does not export a defineConfig({...}) object literal");
1208
+ }
1209
+ let current = source;
1210
+ const changed = [];
1211
+ let envWritten = false;
1212
+ for (const { path, value } of recipe.set) {
1213
+ const step = setPath(current, recipe, path, value, opts.force);
1214
+ if ("conflict" in step) return step;
1215
+ if (!step.wrote) continue;
1216
+ current = step.source;
1217
+ changed.push(path);
1218
+ envWritten ||= writesEnv(value);
1219
+ }
1220
+ if (envWritten) current = importEnv(current, recipe);
1221
+ return { source: current, changed };
1222
+ }
1223
+ function envCalls(source) {
1224
+ const lf = lfCopy(source);
1225
+ const mod = locate(lf);
1226
+ const envName = mod === void 0 ? importedAs(parse(lf), "env") : mod.envName;
1227
+ if (envName === void 0) return [];
1228
+ const defaulted = /* @__PURE__ */ new Map();
1229
+ const visit = (node) => {
1230
+ if (Array.isArray(node)) return node.forEach(visit);
1231
+ if (typeof node !== "object" || node === null || typeof node.type !== "string") return;
1232
+ const n = node;
1233
+ if (n.type === "CallExpression") {
1234
+ const callee = child(n, "callee");
1235
+ const args = children(n, "arguments");
1236
+ const first = args[0];
1237
+ if (callee.type === "Identifier" && callee["name"] === envName && first?.type === "StringLiteral") {
1238
+ const name = first["value"];
1239
+ defaulted.set(name, (defaulted.get(name) ?? true) && args.length > 1);
1240
+ }
1241
+ }
1242
+ for (const [key, value] of Object.entries(n)) {
1243
+ if (key !== "loc" && key !== "comments" && key !== "extra" && key !== "original") visit(value);
1244
+ }
1245
+ };
1246
+ visit(mod?.program ?? parse(lf));
1247
+ return [...defaulted].map(([name, all]) => ({ name, defaulted: all }));
1248
+ }
1249
+
1250
+ // src/recipes/env-example.ts
1251
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
1252
+ function declares(lines, name) {
1253
+ const re = new RegExp(`^(#\\s*)?${escapeRegExp(name)}=`);
1254
+ return lines.some((line) => re.test(line.trim()));
1255
+ }
1256
+ function missingEnv(example, recipe) {
1257
+ const lines = example.split(/\r?\n/);
1258
+ return recipe.env.filter(
1259
+ (v, i) => !declares(lines, v.name) && recipe.env.findIndex((w) => w.name === v.name) === i
1260
+ );
1261
+ }
1262
+ function appendRecipeEnv(example, recipe) {
1263
+ const eol = example.includes("\r\n") ? "\r\n" : "\n";
1264
+ const missing = missingEnv(example, recipe);
1265
+ if (missing.length === 0) return example;
1266
+ const block = [`# ${recipe.name} (nymbal add ${recipe.name})`];
1267
+ for (const v of missing) {
1268
+ if (v.comment !== "") block.push(`# ${v.comment}`);
1269
+ block.push(`${v.required ? "" : "# "}${v.name}=${v.example}`);
1270
+ }
1271
+ let head = example;
1272
+ if (head !== "" && !head.endsWith("\n")) head += eol;
1273
+ const last = head.split(/\r?\n/).at(-2);
1274
+ const gap = last === void 0 || last.trim() === "" ? "" : eol;
1275
+ return `${head}${gap}${block.join(eol)}${eol}`;
1276
+ }
1277
+
1278
+ // src/commands/add.ts
1279
+ var out = (line = "") => void process.stdout.write(`${line}
1280
+ `);
1281
+ var err = (line) => void process.stderr.write(`${line}
1282
+ `);
1283
+ function fail(message) {
1284
+ err(pc3.red(`\u2717 ${message}`));
1285
+ return 1;
1286
+ }
1287
+ function runInstall(line, root) {
1288
+ out(pc3.dim(`$ ${line}`));
1289
+ const [command, ...args] = line.split(" ");
1290
+ const result = spawnSync(command, args, {
1291
+ cwd: root,
1292
+ stdio: "inherit",
1293
+ shell: process.platform === "win32"
1294
+ });
1295
+ return result.status === 0;
1296
+ }
1297
+ function dotEnv(root) {
1298
+ const file = join5(root, ".env");
1299
+ return existsSync6(file) ? parseEnv(readFileSync2(file, "utf8")) : {};
1300
+ }
1301
+ var isSet = (name, fromFile) => (process.env[name] ?? "") !== "" || (fromFile[name] ?? "") !== "";
1302
+ async function problemsWith(candidate, recipe, root, fromFile) {
1303
+ const problems = [];
1304
+ const missing = recipe.install.filter((pkg) => !isInstalled(pkg, root));
1305
+ if (missing.length > 0) {
1306
+ problems.push(
1307
+ `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not installed: ${installLine(missing, process.env, root)}`
1308
+ );
1309
+ }
1310
+ const saved = /* @__PURE__ */ new Map();
1311
+ for (const { name, defaulted } of envCalls(candidate)) {
1312
+ if ((process.env[name] ?? "") !== "") continue;
1313
+ const value = fromFile[name] || (defaulted ? void 0 : `nymbal-add-placeholder-${name}`);
1314
+ if (value === void 0) continue;
1315
+ saved.set(name, process.env[name]);
1316
+ process.env[name] = value;
1317
+ }
1318
+ const dir = mkdtempSync(join5(root, ".nymbal-add-"));
1319
+ try {
1320
+ writeFileSync(join5(dir, "nymbal.config.ts"), candidate);
1321
+ await loadConfig2(dir);
1322
+ } catch (error) {
1323
+ problems.push(error.message);
1324
+ } finally {
1325
+ rmSync(dir, { recursive: true, force: true });
1326
+ for (const [name, value] of saved) {
1327
+ if (value === void 0) delete process.env[name];
1328
+ else process.env[name] = value;
1329
+ }
1330
+ }
1331
+ return problems;
1332
+ }
1333
+ function lineDiff(before, after) {
1334
+ const a = before.split("\n");
1335
+ const b = after.split("\n");
1336
+ const t = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
1337
+ for (let i2 = a.length - 1; i2 >= 0; i2--)
1338
+ for (let j2 = b.length - 1; j2 >= 0; j2--)
1339
+ t[i2][j2] = a[i2] === b[j2] ? t[i2 + 1][j2 + 1] + 1 : Math.max(t[i2 + 1][j2], t[i2][j2 + 1]);
1340
+ const lines = [];
1341
+ let i = 0;
1342
+ let j = 0;
1343
+ while (i < a.length || j < b.length) {
1344
+ if (i < a.length && j < b.length && a[i] === b[j]) {
1345
+ i++;
1346
+ j++;
1347
+ } else if (j < b.length && (i === a.length || t[i][j + 1] >= t[i + 1][j])) {
1348
+ lines.push(pc3.green(`+ ${b[j++]}`));
1349
+ } else {
1350
+ lines.push(pc3.red(`- ${a[i++]}`));
1351
+ }
1352
+ }
1353
+ return lines;
1354
+ }
1355
+ var show = (value) => typeof value === "string" ? `'${value}'` : JSON.stringify(value);
1356
+ async function add(opts, cwd = process.cwd()) {
1357
+ const name = opts.recipe;
1358
+ if (!isKnownRecipe(name)) {
1359
+ return fail(`unknown recipe '${name}'. The recipes are: ${knownRecipes().join(", ")}`);
1360
+ }
1361
+ const configPath = findConfigFile2(cwd);
1362
+ if (configPath === null) return fail(`no nymbal.config.ts in ${cwd} or any parent directory`);
1363
+ const root = dirname6(configPath);
1364
+ const holder = RECIPE_HOLDERS[name];
1365
+ const installed = [];
1366
+ if (recipeFile(name, root) === null) {
1367
+ const line = installLine([holder], process.env, root);
1368
+ if (!opts.install || opts.dryRun) {
1369
+ return fail(
1370
+ `the ${name} recipe ships in ${holder}, which is not installed. Install it: ${line}`
1371
+ );
1372
+ }
1373
+ if (!runInstall(line, root)) return fail(`${line} failed`);
1374
+ installed.push(holder);
1375
+ }
1376
+ const recipe = await readRecipe(name, root);
1377
+ const source = readFileSync2(configPath, "utf8");
1378
+ let edited;
1379
+ try {
1380
+ const applied = applyRecipeToConfig(source, recipe, { force: opts.force });
1381
+ if ("conflict" in applied) {
1382
+ const { path, current, wanted } = applied.conflict;
1383
+ return fail(
1384
+ `${path} is ${show(current)} in nymbal.config.ts, and the ${name} recipe sets it to ${show(wanted)}. nymbal.config.ts is unchanged: edit it by hand, or re-run with --force to overwrite it.`
1385
+ );
1386
+ }
1387
+ edited = applied;
1388
+ } catch (error) {
1389
+ return fail(error.message);
1390
+ }
1391
+ const examplePath = join5(root, ".env.example");
1392
+ const example = existsSync6(examplePath) ? readFileSync2(examplePath, "utf8") : "";
1393
+ const nextExample = appendRecipeEnv(example, recipe);
1394
+ if (opts.dryRun) {
1395
+ out(pc3.bold(`nymbal add ${name} --dry-run: nothing is written`));
1396
+ out();
1397
+ out(
1398
+ recipe.install.length > 0 ? `install: ${installLine(recipe.install, process.env, root)}` : "install: nothing"
1399
+ );
1400
+ out();
1401
+ out("nymbal.config.ts:");
1402
+ const diff = lineDiff(source, edited.source);
1403
+ for (const line of diff.length > 0 ? diff : ["(already set)"]) out(` ${line}`);
1404
+ out();
1405
+ out(".env.example:");
1406
+ const added = nextExample.slice(example.length).split(/\r?\n/).filter((l) => l !== "");
1407
+ for (const line of added.length > 0 ? added : ["(every variable already listed)"])
1408
+ out(` ${line}`);
1409
+ return 0;
1410
+ }
1411
+ const pending = recipe.install.filter((pkg) => !isInstalled(pkg, root));
1412
+ if (pending.length > 0 && opts.install) {
1413
+ const line = installLine(pending, process.env, root);
1414
+ if (!runInstall(line, root)) return fail(`${line} failed`);
1415
+ installed.push(...pending);
1416
+ }
1417
+ const fromFile = dotEnv(root);
1418
+ const problems = await problemsWith(edited.source, recipe, root, fromFile);
1419
+ if (problems.length > 0) {
1420
+ return fail(
1421
+ `the ${name} recipe would leave a config that does not load, so nymbal.config.ts is unchanged:
1422
+ ${problems.join("\n ")}`
1423
+ );
1424
+ }
1425
+ const written = [];
1426
+ if (installed.length > 0) written.push(`package.json and its lockfile (${installed.join(", ")})`);
1427
+ if (edited.source !== source) {
1428
+ writeFileSync(configPath, edited.source);
1429
+ written.push(`nymbal.config.ts (${edited.changed.join(", ")})`);
1430
+ }
1431
+ if (nextExample !== example) {
1432
+ writeFileSync(examplePath, nextExample);
1433
+ written.push(
1434
+ `.env.example (${missingEnv(example, recipe).map((v) => v.name).join(", ")})`
1435
+ );
1436
+ }
1437
+ out(pc3.green(`\u2713 ${name}: ${recipe.summary}`));
1438
+ out(written.length > 0 ? ` changed ${written.join(", ")}` : " already added: nothing changed");
1439
+ const unset = recipe.env.filter((v) => v.required && !isSet(v.name, fromFile));
1440
+ if (unset.length > 0 || recipe.next.length > 0) {
1441
+ out();
1442
+ out("Next:");
1443
+ const fromSettings = unset.length > 0 ? await settingsEnv(recipe.install, root) : /* @__PURE__ */ new Set();
1444
+ for (const v of unset) {
1445
+ out(
1446
+ fromSettings.has(v.name) ? ` - set ${v.name} in .env or in the admin's Settings` : ` - set ${v.name} in .env`
1447
+ );
1448
+ }
1449
+ for (const line of recipe.next) out(` - ${line}`);
1450
+ }
1451
+ return 0;
1452
+ }
1453
+ var addCommand = defineCommand7({
1454
+ meta: {
1455
+ name: "add",
1456
+ description: `Put the store on a service: ${knownRecipes().join(", ")}`
1457
+ },
1458
+ args: {
1459
+ recipe: { type: "positional", required: true, description: knownRecipes().join(", ") },
1460
+ install: {
1461
+ type: "boolean",
1462
+ default: true,
1463
+ description: "Install the packages (--no-install: they must already be installed)"
1464
+ },
1465
+ force: {
1466
+ type: "boolean",
1467
+ default: false,
1468
+ description: "Overwrite a config value the recipe conflicts with"
1469
+ },
1470
+ "dry-run": {
1471
+ type: "boolean",
1472
+ default: false,
1473
+ description: "Print the config change, the .env.example lines and the install line, and write nothing"
1474
+ }
1475
+ },
1476
+ async run({ args }) {
1477
+ process.exitCode = await add({
1478
+ recipe: args.recipe,
1479
+ install: args.install,
1480
+ force: args.force,
1481
+ dryRun: args["dry-run"]
1482
+ });
1483
+ }
1484
+ });
1485
+
1486
+ // src/commands/doctor.ts
1487
+ import { existsSync as existsSync7 } from "fs";
1488
+ import { defineCommand as defineCommand8 } from "citty";
1489
+ import { createLogger as createLogger2, diagnose } from "@nymbalhq/platform";
1490
+ import { errorText, HEALTH_CHECK_TIMEOUT_MS } from "@nymbalhq/types";
1491
+
1492
+ // src/commands/doctor-report.ts
1493
+ import pc4 from "picocolors";
1494
+ import { scrub as scrubSettings } from "@nymbalhq/platform";
1495
+ import { connectionSecrets, displayUrl as displayUrl2, SECRET_MIN_LENGTH } from "@nymbalhq/types";
1496
+ var TEXT_STATUS = {
1497
+ ok: "ok",
1498
+ healthy: "ok",
1499
+ degraded: "warn",
1500
+ down: "down"
1501
+ };
1502
+ var JSON_STATUS = {
1503
+ ok: "ok",
1504
+ healthy: "ok",
1505
+ degraded: "degraded",
1506
+ down: "down"
1507
+ };
1508
+ var COLOUR = { ok: pc4.green, warn: pc4.yellow, down: pc4.red };
1509
+ var SECRET_KEY = /secret|passw(?:or)?d|^pass$|token|api[-_]?key|private[-_]?key|credential|access[-_]?key/i;
1510
+ var SECRET_ENV = /SECRET|PASSWORD|PASSWD|(?:^|_)PASS(?:$|_)|TOKEN|API_KEY|PRIVATE_KEY|CREDENTIAL|ACCESS_KEY/i;
1511
+ var URL_IN_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>]+/gi;
1512
+ var TRAILING_PUNCTUATION = /[.,;:)\]}]+$/;
1513
+ function addSecret(into, value) {
1514
+ if (value.length >= SECRET_MIN_LENGTH) into.add(value);
1515
+ }
1516
+ function collectFromConfig(value, key, into, seen) {
1517
+ if (typeof value === "string") {
1518
+ if (SECRET_KEY.test(key)) addSecret(into, value);
1519
+ if (value.includes("://"))
1520
+ for (const s of connectionSecrets(value, { user: false })) addSecret(into, s);
1521
+ return;
1522
+ }
1523
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
1524
+ seen.add(value);
1525
+ for (const [k, v] of Object.entries(value)) collectFromConfig(v, k, into, seen);
1526
+ }
1527
+ function secretsOf(config, env) {
1528
+ const found = /* @__PURE__ */ new Set();
1529
+ collectFromConfig(config, "", found, /* @__PURE__ */ new WeakSet());
1530
+ for (const [name, value] of Object.entries(env)) {
1531
+ if (value === void 0) continue;
1532
+ if (SECRET_ENV.test(name)) addSecret(found, value);
1533
+ if (value.includes("://"))
1534
+ for (const s of connectionSecrets(value, { user: false })) addSecret(found, s);
1535
+ }
1536
+ return [...found].sort((a, b) => b.length - a.length);
1537
+ }
1538
+ function scrub(detail, secrets) {
1539
+ let text2 = scrubSettings(detail).replace(URL_IN_TEXT, (match) => {
1540
+ const trailing = TRAILING_PUNCTUATION.exec(match)?.[0] ?? "";
1541
+ return displayUrl2(match.slice(0, match.length - trailing.length)) + trailing;
1542
+ });
1543
+ for (const secret of secrets) text2 = text2.split(secret).join("***");
1544
+ return text2.replace(/\s*\n\s*(?:•\s*)?/g, " ").trim();
1545
+ }
1546
+ function formatDoctorReport(report, options) {
1547
+ const secrets = secretsOf(options.config, options.env ?? {});
1548
+ const checks = report.checks.map((check) => ({
1549
+ name: check.name,
1550
+ status: check.status,
1551
+ detail: scrub(check.detail, secrets)
1552
+ }));
1553
+ if (options.json) {
1554
+ return JSON.stringify(
1555
+ {
1556
+ checks: checks.map((c) => ({
1557
+ name: c.name,
1558
+ status: JSON_STATUS[c.status],
1559
+ detail: c.detail
1560
+ }))
1561
+ },
1562
+ null,
1563
+ 2
1564
+ );
1565
+ }
1566
+ const width = Math.max(...checks.map((c) => c.name.length), 0);
1567
+ const lines = checks.map((c) => {
1568
+ const status = TEXT_STATUS[c.status];
1569
+ return `${COLOUR[status](status.padEnd(4))} ${c.name.padEnd(width)} ${c.detail}`.trimEnd();
1570
+ });
1571
+ const count = (s) => checks.filter((c) => TEXT_STATUS[c.status] === s).length;
1572
+ return [...lines, "", `${count("down")} down, ${count("warn")} warn, ${count("ok")} ok`].join(
1573
+ "\n"
1574
+ );
1575
+ }
1576
+
1577
+ // src/commands/doctor.ts
1578
+ function timeoutOf(flag2) {
1579
+ if (flag2 === void 0 || flag2.trim() === "") return HEALTH_CHECK_TIMEOUT_MS;
1580
+ const ms = Number(flag2);
1581
+ if (!Number.isInteger(ms) || ms < 1) {
1582
+ throw new Error(`--timeout must be a whole number of milliseconds, got "${flag2}"`);
1583
+ }
1584
+ return ms;
1585
+ }
1586
+ function migrationsOf(projectRoot) {
1587
+ const root = resolveMigrations(projectRoot);
1588
+ return existsSync7(root) ? { migrationsRoot: root } : {};
1589
+ }
1590
+ var doctorCommand = defineCommand8({
1591
+ meta: {
1592
+ name: "doctor",
1593
+ description: "Check the project without changing it: config, packages, command store, migrations, slots and primitives"
1594
+ },
1595
+ args: {
1596
+ json: { type: "boolean", description: "Print the checks as one JSON document", default: false },
1597
+ timeout: {
1598
+ type: "string",
1599
+ description: `How long each check may take, in ms (default ${HEALTH_CHECK_TIMEOUT_MS})`
1600
+ }
1601
+ },
1602
+ async run({ args }) {
1603
+ const timeoutMs = timeoutOf(args.timeout);
1604
+ let loaded;
1605
+ try {
1606
+ loaded = await loadProject();
1607
+ } catch (error) {
1608
+ loaded = { error };
1609
+ }
1610
+ const report = "error" in loaded ? (
1611
+ // Nothing else can be checked without a config, so it is the only line.
1612
+ { checks: [{ name: "config", status: "down", detail: errorText(loaded.error) }] }
1613
+ ) : await diagnose(loaded.config, {
1614
+ ...loaded.adapters !== void 0 && { adapters: loaded.adapters },
1615
+ // The report carries every failure; a log line would only repeat one, and break `--json`.
1616
+ logger: createLogger2({ level: "silent", pretty: false }),
1617
+ timeoutMs,
1618
+ ...migrationsOf(loaded.projectRoot)
1619
+ });
1620
+ const text2 = formatDoctorReport(report, {
1621
+ json: args.json,
1622
+ config: "error" in loaded ? void 0 : loaded.config,
1623
+ env: process.env
1624
+ });
1625
+ const down = report.checks.some((check) => check.status === "down");
1626
+ process.stdout.write(`${text2}
1627
+ `, () => process.exit(down ? 1 : 0));
1628
+ }
1629
+ });
1630
+
1631
+ // src/commands/secrets.ts
1632
+ import { defineCommand as defineCommand9 } from "citty";
1633
+ import pc5 from "picocolors";
1634
+ import {
1635
+ createCommandStore as createCommandStore2,
1636
+ createSettingsRepository,
1637
+ decrypt,
1638
+ encrypt,
1639
+ isMissingSettingsTable,
1640
+ keyProblem,
1641
+ readSecretsKey,
1642
+ SECRETS_KEY_ENV,
1643
+ SECRETS_KEY_OLD_ENV,
1644
+ settingAad
1645
+ } from "@nymbalhq/platform";
1646
+ var RotationRefused = class extends Error {
1647
+ };
1648
+ function refuse(message) {
1649
+ process.stderr.write(`${pc5.red(`\u2717 ${message}`)}
1650
+ `);
1651
+ process.exitCode = 2;
1652
+ }
1653
+ async function rotateSecrets(env = process.env) {
1654
+ const oldKey = readSecretsKey(env, SECRETS_KEY_OLD_ENV);
1655
+ if (!oldKey.ok) throw new RotationRefused(keyProblem(oldKey.reason, SECRETS_KEY_OLD_ENV));
1656
+ const newKey = readSecretsKey(env, SECRETS_KEY_ENV);
1657
+ if (!newKey.ok) throw new RotationRefused(keyProblem(newKey.reason, SECRETS_KEY_ENV));
1658
+ const { config } = await loadProject();
1659
+ const store = createCommandStore2(config);
1660
+ try {
1661
+ const repo = createSettingsRepository(store);
1662
+ return await store.transaction(async (tx) => {
1663
+ const rows = await repo.list(tx);
1664
+ const rotated = [];
1665
+ for (const row2 of rows) {
1666
+ if (row2.ciphertext === null) continue;
1667
+ const field = `${row2.slot}.${row2.path}`;
1668
+ if (row2.keyId !== oldKey.id) {
1669
+ throw new RotationRefused(
1670
+ `${field} is under key ${row2.keyId ?? "(none)"}, not ${SECRETS_KEY_OLD_ENV} (key ${oldKey.id}); nothing was rotated`
1671
+ );
1672
+ }
1673
+ let value;
1674
+ try {
1675
+ value = decrypt(oldKey.key, settingAad(row2.slot, row2.path), row2);
1676
+ } catch {
1677
+ throw new RotationRefused(
1678
+ `${field} does not decrypt under ${SECRETS_KEY_OLD_ENV}; nothing was rotated`
1679
+ );
1680
+ }
1681
+ const sealed = encrypt(newKey.key, settingAad(row2.slot, row2.path), value);
1682
+ rotated.push({ ...row2, ...sealed, keyId: newKey.id, updatedAt: /* @__PURE__ */ new Date() });
1683
+ }
1684
+ for (const row2 of rotated) await repo.put(tx, row2);
1685
+ return rotated.length;
1686
+ });
1687
+ } finally {
1688
+ await store.close();
1689
+ }
1690
+ }
1691
+ async function clearSecrets(filter) {
1692
+ const { config } = await loadProject();
1693
+ const store = createCommandStore2(config);
1694
+ try {
1695
+ const repo = createSettingsRepository(store);
1696
+ return await store.transaction((tx) => repo.clear(tx, filter));
1697
+ } catch (error) {
1698
+ if (isMissingSettingsTable(error)) return 0;
1699
+ throw error;
1700
+ } finally {
1701
+ await store.close();
1702
+ }
1703
+ }
1704
+ var rotateCommand = defineCommand9({
1705
+ meta: {
1706
+ name: "rotate",
1707
+ description: `Re-encrypt every saved secret from ${SECRETS_KEY_OLD_ENV} to ${SECRETS_KEY_ENV}, all or none`
1708
+ },
1709
+ async run() {
1710
+ try {
1711
+ const count = await rotateSecrets();
1712
+ process.stdout.write(
1713
+ `${pc5.green(`\u2713 rotated ${count} secret${count === 1 ? "" : "s"} to ${SECRETS_KEY_ENV}`)}
1714
+ `
1715
+ );
1716
+ } catch (error) {
1717
+ refuse(error instanceof Error ? error.message : String(error));
1718
+ }
1719
+ }
1720
+ });
1721
+ var clearCommand = defineCommand9({
1722
+ meta: {
1723
+ name: "clear",
1724
+ description: "Delete saved settings (all, or one slot or path); needs no key"
1725
+ },
1726
+ args: {
1727
+ slot: { type: "string", description: "Only this slot" },
1728
+ path: { type: "string", description: "Only this path" }
1729
+ },
1730
+ async run({ args }) {
1731
+ const count = await clearSecrets({
1732
+ ...args.slot !== void 0 && { slot: args.slot },
1733
+ ...args.path !== void 0 && { path: args.path }
1734
+ });
1735
+ process.stdout.write(
1736
+ `${pc5.green(`\u2713 cleared ${count} setting${count === 1 ? "" : "s"}`)}; restart the store to apply
1737
+ `
1738
+ );
1739
+ }
1740
+ });
1741
+ var secretsCommand = defineCommand9({
1742
+ meta: { name: "secrets", description: "Manage the values saved in the admin's Settings" },
1743
+ subCommands: { rotate: rotateCommand, clear: clearCommand }
1744
+ });
1745
+
1746
+ // src/commands/events.ts
1747
+ import { existsSync as existsSync8 } from "fs";
1748
+ import { resolve as resolve6 } from "path";
1749
+ import { defineCommand as defineCommand10 } from "citty";
1750
+ import {
1751
+ createCommandStore as createCommandStore3,
1752
+ createEventLogRepository,
1753
+ sqliteFile as sqliteFile2
1754
+ } from "@nymbalhq/platform";
1755
+ import { errorText as errorText2, isValidCorrelationId } from "@nymbalhq/types";
1756
+ var POLL_MS = 500;
1757
+ var TAIL_PAGE = 1e3;
1758
+ var EventsError = class extends Error {
1759
+ };
1760
+ function formatEvent(row2, opts) {
1761
+ const { event } = row2;
1762
+ if (!opts.json) {
1763
+ const correlationId = isValidCorrelationId(event.correlationId) ? event.correlationId : JSON.stringify(event.correlationId);
1764
+ return `${row2.seq} ${event.timestamp} ${event.type} ${correlationId} ${event.id}`;
1765
+ }
1766
+ const { payload, ...envelope } = event;
1767
+ return JSON.stringify({
1768
+ ...envelope,
1769
+ ...opts.payload && { payload },
1770
+ seq: row2.seq,
1771
+ deliveredAt: row2.deliveredAt
1772
+ });
1773
+ }
1774
+ function timeFlag(name, value) {
1775
+ if (value === void 0) return void 0;
1776
+ const at = new Date(value);
1777
+ if (value.trim() === "" || Number.isNaN(at.getTime())) {
1778
+ throw new EventsError(`--${name} must be an ISO 8601 time, got "${value}"`);
1779
+ }
1780
+ return at;
1781
+ }
1782
+ function countFlag(name, value, min) {
1783
+ if (value === void 0) return void 0;
1784
+ const n = Number(value);
1785
+ if (value.trim() === "" || !Number.isInteger(n) || n < min) {
1786
+ throw new EventsError(`--${name} must be a whole number of at least ${min}, got "${value}"`);
1787
+ }
1788
+ return n;
1789
+ }
1790
+ function typesFlag(value) {
1791
+ if (value === void 0) return {};
1792
+ if (value.trim() === "") throw new EventsError("--type must not be empty");
1793
+ return { types: [value] };
1794
+ }
1795
+ async function openLog() {
1796
+ const { config } = await loadProject();
1797
+ if (config.infrastructure.commandStore === "sqlite") {
1798
+ const path = sqliteFile2();
1799
+ if (!existsSync8(resolve6(path))) {
1800
+ throw new EventsError(`no database at ${path}: run \`nymbal migrate\``);
1801
+ }
1802
+ }
1803
+ const store = createCommandStore3(config, { readOnly: true });
1804
+ return { store, eventLog: createEventLogRepository(store) };
1805
+ }
1806
+ async function guarded(body) {
1807
+ try {
1808
+ await body();
1809
+ } catch (error) {
1810
+ process.stderr.write(`nymbal events: ${errorText2(error)}
1811
+ `);
1812
+ process.exitCode = 2;
1813
+ }
1814
+ }
1815
+ function write(lines) {
1816
+ if (lines.length === 0) return Promise.resolve();
1817
+ return new Promise((done) => process.stdout.write(`${lines.join("\n")}
1818
+ `, () => done()));
1819
+ }
1820
+ var outputArgs = {
1821
+ json: {
1822
+ type: "boolean",
1823
+ description: "One envelope per line, with seq and deliveredAt",
1824
+ default: false
1825
+ },
1826
+ payload: {
1827
+ type: "boolean",
1828
+ description: "Include each event\u2019s payload (it may hold customer data)",
1829
+ default: false
1830
+ }
1831
+ };
1832
+ var queryCommand = defineCommand10({
1833
+ meta: { name: "query", description: "Print one correlation id\u2019s events, in seq order" },
1834
+ args: {
1835
+ correlation: { type: "string", description: "The correlation id (required)" },
1836
+ type: {
1837
+ type: "string",
1838
+ description: "Only types matching this glob; * matches anything (order.*)"
1839
+ },
1840
+ since: { type: "string", description: "Only events appended at or after this ISO time" },
1841
+ until: { type: "string", description: "Only events appended before this ISO time" },
1842
+ limit: { type: "string", description: "At most this many events (default 1000)" },
1843
+ ...outputArgs
1844
+ },
1845
+ run: ({ args }) => guarded(async () => {
1846
+ const correlationId = args.correlation;
1847
+ if (correlationId === void 0 || correlationId === "") {
1848
+ throw new EventsError("--correlation <id> is required");
1849
+ }
1850
+ const since = timeFlag("since", args.since);
1851
+ const until = timeFlag("until", args.until);
1852
+ const filter = {
1853
+ ...typesFlag(args.type),
1854
+ ...since !== void 0 && { since },
1855
+ ...until !== void 0 && { until },
1856
+ limit: countFlag("limit", args.limit, 1) ?? 1e3
1857
+ };
1858
+ const { store, eventLog } = await openLog();
1859
+ try {
1860
+ const rows = await store.read(
1861
+ (tx) => eventLog.queryByCorrelation(tx, correlationId, filter)
1862
+ );
1863
+ await write(rows.map((row2) => formatEvent(row2, args)));
1864
+ } finally {
1865
+ await store.close();
1866
+ }
1867
+ })
1868
+ });
1869
+ var tailCommand = defineCommand10({
1870
+ meta: {
1871
+ name: "tail",
1872
+ description: "Print events as they are appended, until interrupted. Exact for serially committed events; on Postgres an event whose lower seq commits after a higher one has been printed is skipped."
1873
+ },
1874
+ args: {
1875
+ type: {
1876
+ type: "string",
1877
+ description: "Only types matching this glob; * matches anything (order.*)"
1878
+ },
1879
+ from: {
1880
+ type: "string",
1881
+ description: "Print events above this seq (default: the current maximum)"
1882
+ },
1883
+ ...outputArgs
1884
+ },
1885
+ run: ({ args }) => guarded(async () => {
1886
+ const types = typesFlag(args.type);
1887
+ const from = countFlag("from", args.from, 0);
1888
+ const { store, eventLog } = await openLog();
1889
+ let stopped = false;
1890
+ let wake;
1891
+ const stop = () => {
1892
+ stopped = true;
1893
+ wake?.();
1894
+ };
1895
+ process.once("SIGINT", stop);
1896
+ process.once("SIGTERM", stop);
1897
+ try {
1898
+ let cursor = from ?? await store.read((tx) => eventLog.maxSeq(tx));
1899
+ process.stderr.write(`tailing from seq ${cursor}
1900
+ `);
1901
+ while (!stopped) {
1902
+ const after = cursor;
1903
+ const rows = await store.read(
1904
+ (tx) => eventLog.listAfter(tx, after, { ...types, limit: TAIL_PAGE })
1905
+ );
1906
+ await write(rows.map((row2) => formatEvent(row2, args)));
1907
+ if (rows.length > 0) cursor = rows[rows.length - 1].seq;
1908
+ if (rows.length === TAIL_PAGE) continue;
1909
+ await new Promise((done) => {
1910
+ const timer = setTimeout(done, POLL_MS);
1911
+ wake = () => {
1912
+ clearTimeout(timer);
1913
+ done();
1914
+ };
1915
+ });
1916
+ wake = void 0;
1917
+ }
1918
+ } finally {
1919
+ process.off("SIGINT", stop);
1920
+ process.off("SIGTERM", stop);
1921
+ await store.close();
1922
+ }
1923
+ })
1924
+ });
1925
+ var eventsCommand = defineCommand10({
1926
+ meta: { name: "events", description: "Read the event log without changing it" },
1927
+ subCommands: { query: queryCommand, tail: tailCommand }
1928
+ });
1929
+
1930
+ // src/commands/audit.ts
1931
+ import { createWriteStream, existsSync as existsSync9 } from "fs";
1932
+ import { resolve as resolve7 } from "path";
1933
+ import { once } from "events";
1934
+ import { defineCommand as defineCommand11 } from "citty";
1935
+ import {
1936
+ auditEntriesInOrder,
1937
+ createCommandStore as createCommandStore4,
1938
+ sqliteFile as sqliteFile3
1939
+ } from "@nymbalhq/platform";
1940
+ import { errorText as errorText3 } from "@nymbalhq/types";
1941
+ var AuditError = class extends Error {
1942
+ };
1943
+ function timeFlag2(name, value) {
1944
+ if (value === void 0) return void 0;
1945
+ const at = new Date(value);
1946
+ if (value.trim() === "" || Number.isNaN(at.getTime())) {
1947
+ throw new AuditError(`--${name} must be an ISO 8601 time, got "${value}"`);
1948
+ }
1949
+ return at;
1950
+ }
1951
+ async function write2(out2, text2) {
1952
+ if (!out2.write(text2)) await once(out2, "drain");
1953
+ }
1954
+ var exportCommand = defineCommand11({
1955
+ meta: {
1956
+ name: "export",
1957
+ description: "Print audit entries as NDJSON, oldest first: ids only, no event payloads or customer data"
1958
+ },
1959
+ args: {
1960
+ since: { type: "string", description: "Only entries at or after this ISO time" },
1961
+ until: { type: "string", description: "Only entries before this ISO time" },
1962
+ correlation: { type: "string", description: "Only the entries of this correlation id" },
1963
+ out: { type: "string", description: "Write the lines to this file instead of stdout" }
1964
+ },
1965
+ async run({ args }) {
1966
+ try {
1967
+ const since = timeFlag2("since", args.since);
1968
+ const until = timeFlag2("until", args.until);
1969
+ const filter = {
1970
+ ...since !== void 0 && { since },
1971
+ ...until !== void 0 && { until },
1972
+ ...args.correlation !== void 0 && { correlation: args.correlation }
1973
+ };
1974
+ const { config } = await loadProject();
1975
+ if (config.infrastructure.commandStore === "sqlite") {
1976
+ const path = sqliteFile3();
1977
+ if (!existsSync9(resolve7(path))) {
1978
+ throw new AuditError(`no database at ${path}: run \`nymbal migrate\``);
1979
+ }
1980
+ }
1981
+ const store = createCommandStore4(config, { readOnly: true });
1982
+ const out2 = args.out === void 0 ? process.stdout : createWriteStream(args.out);
1983
+ try {
1984
+ for await (const entry of auditEntriesInOrder(store, filter)) {
1985
+ await write2(out2, `${JSON.stringify(entry)}
1986
+ `);
1987
+ }
1988
+ } finally {
1989
+ await store.close();
1990
+ if (out2 !== process.stdout) {
1991
+ out2.end();
1992
+ await once(out2, "finish");
1993
+ }
1994
+ }
1995
+ } catch (error) {
1996
+ process.stderr.write(`nymbal audit: ${errorText3(error)}
1997
+ `);
1998
+ process.exitCode = 2;
1999
+ }
2000
+ }
2001
+ });
2002
+ var auditCommand = defineCommand11({
2003
+ meta: { name: "audit", description: "Read the audit trail" },
2004
+ subCommands: { export: exportCommand }
2005
+ });
2006
+
2007
+ // src/commands/replay.ts
2008
+ import { existsSync as existsSync10 } from "fs";
2009
+ import { resolve as resolve8 } from "path";
2010
+ import { defineCommand as defineCommand12 } from "citty";
2011
+ import {
2012
+ createLogger as createLogger3,
2013
+ diffReadModel,
2014
+ openReadModels,
2015
+ READ_MODEL_NAMES,
2016
+ rebuild,
2017
+ sqliteFile as sqliteFile4
2018
+ } from "@nymbalhq/platform";
2019
+ import { errorText as errorText4 } from "@nymbalhq/types";
2020
+ var ReplayError = class extends Error {
2021
+ };
2022
+ function modelsOf(to) {
2023
+ const choices = `${READ_MODEL_NAMES.join(", ")} or all`;
2024
+ if (to === void 0 || to === "") throw new ReplayError(`--to <model> is required: ${choices}`);
2025
+ if (to === "all") return READ_MODEL_NAMES;
2026
+ const name = READ_MODEL_NAMES.find((n) => n === to);
2027
+ if (name === void 0) throw new ReplayError(`--to must be ${choices}, got "${to}"`);
2028
+ return [name];
2029
+ }
2030
+ var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
2031
+ function formatRebuildReport(report) {
2032
+ const lines = [];
2033
+ for (const entry of report) {
2034
+ const caught = plural(entry.caughtUpEvents, "event");
2035
+ lines.push(
2036
+ entry.failures.length === 0 ? `${entry.model}: swapped in ${entry.ms} ms, ${caught} caught up` : `${entry.model}: swapped in ${entry.ms} ms, but ${plural(entry.failures.length, "catch-up event")} failed (the model is swapped without them: fix the cause and replay it again)`
2037
+ );
2038
+ for (const c of entry.collections) lines.push(` ${c.name} ${c.documents}`);
2039
+ for (const f of entry.failures) {
2040
+ lines.push(` failed: seq ${f.seq} ${f.type} (${f.handler}): ${f.error}`);
2041
+ }
2042
+ if (entry.note !== void 0) lines.push(` note: ${entry.note}`);
2043
+ }
2044
+ return lines.join("\n");
2045
+ }
2046
+ function formatDiff(diffs) {
2047
+ const lines = [];
2048
+ for (const diff of diffs) {
2049
+ lines.push(`${diff.model} (dry run, nothing written):`);
2050
+ if (diff.skipped !== void 0) lines.push(` skipped: ${diff.skipped}`);
2051
+ for (const c of diff.collections) {
2052
+ lines.push(` ${c.name}: ${c.added} added, ${c.changed} changed, ${c.removed} removed`);
2053
+ }
2054
+ }
2055
+ return lines.join("\n");
2056
+ }
2057
+ var differs = (diffs) => diffs.some((d) => d.collections.some((c) => c.added + c.changed + c.removed > 0));
2058
+ function write3(stream, text2) {
2059
+ return new Promise((done) => stream.write(`${text2}
2060
+ `, () => done()));
2061
+ }
2062
+ var replayCommand = defineCommand12({
2063
+ meta: {
2064
+ name: "replay",
2065
+ description: "Rebuild a derived read model from the command store and swap it in: catalogue, orders, customers, search or all"
2066
+ },
2067
+ args: {
2068
+ to: {
2069
+ type: "string",
2070
+ description: "The read model: catalogue, orders, customers, search or all"
2071
+ },
2072
+ "dry-run": {
2073
+ type: "boolean",
2074
+ description: "Rebuild into memory and count what differs from the live read model; exits 1 if anything does",
2075
+ default: false
2076
+ }
2077
+ },
2078
+ async run({ args }) {
2079
+ try {
2080
+ const names = modelsOf(args.to);
2081
+ const { config, adapters } = await loadProject();
2082
+ const shared = names.filter((n) => n !== "search");
2083
+ if (config.infrastructure.documentStore === "in-memory" && shared.length > 0) {
2084
+ throw new ReplayError(
2085
+ `the ${shared.join(", ")} read model${shared.length === 1 ? "" : "s"} live in the server's own process (documentStore: in-memory), out of reach of this one; a restart of the server rebuilds them. With documentStore redis or postgres-jsonb the read models are shared, and replay rebuilds them in place. --to search works here: the native index lives in the command store.`
2086
+ );
2087
+ }
2088
+ if (config.infrastructure.commandStore === "sqlite" && !existsSync10(resolve8(sqliteFile4()))) {
2089
+ throw new ReplayError(`no database at ${sqliteFile4()}: run \`nymbal migrate\``);
2090
+ }
2091
+ const { deps, close } = await openReadModels(config, {
2092
+ // The report says what happened; log lines would repeat it.
2093
+ logger: createLogger3({ level: "silent", pretty: false }),
2094
+ ...adapters !== void 0 && { adapters }
2095
+ });
2096
+ try {
2097
+ if (args["dry-run"]) {
2098
+ const diffs = [];
2099
+ for (const name of names) diffs.push(await diffReadModel(name, deps));
2100
+ await write3(process.stdout, formatDiff(diffs));
2101
+ process.exitCode = differs(diffs) ? 1 : 0;
2102
+ return;
2103
+ }
2104
+ const report = await rebuild(names, deps);
2105
+ await write3(process.stdout, formatRebuildReport(report));
2106
+ process.exitCode = report.some((e) => e.failures.length > 0) ? 2 : 0;
2107
+ } finally {
2108
+ await close();
2109
+ }
2110
+ } catch (error) {
2111
+ await write3(process.stderr, `nymbal replay: ${errorText4(error)}`);
2112
+ process.exitCode = 2;
2113
+ }
2114
+ }
2115
+ });
2116
+
2117
+ // src/commands/security.ts
2118
+ import { defineCommand as defineCommand13 } from "citty";
2119
+ import { createLogger as createLogger4, importProviderPackage } from "@nymbalhq/platform";
2120
+ import {
2121
+ errorText as errorText5
2122
+ } from "@nymbalhq/types";
2123
+ var failure = (...stderr) => ({ code: 2, stdout: [], stderr });
2124
+ var isEmpty = (plan) => plan.phases.every((p2) => p2.add.length + p2.change.length + p2.remove.length === 0);
2125
+ function formatPlan(plan) {
2126
+ const lines = [];
2127
+ for (const { phase, add: add2, change, remove } of plan.phases) {
2128
+ if (add2.length + change.length + remove.length === 0) continue;
2129
+ lines.push(
2130
+ `${phase}: ${add2.length} to add, ${change.length} to change, ${remove.length} to remove`
2131
+ );
2132
+ for (const rule of add2) lines.push(` + ${rule.ref} ${rule.action} ${rule.expression}`);
2133
+ for (const { ref, to } of change) lines.push(` ~ ${ref} ${to.action} ${to.expression}`);
2134
+ for (const rule of remove) lines.push(` - ${rule.ref}`);
2135
+ }
2136
+ return lines;
2137
+ }
2138
+ function invalid(result) {
2139
+ return failure(
2140
+ "The security config cannot go onto the zone:",
2141
+ ...result.errors.map((e) => ` ${e.code} ${e.message}`)
2142
+ );
2143
+ }
2144
+ function edgeNotes(security, mod) {
2145
+ const notes = Object.keys(security.rateLimit).filter((key) => key.startsWith("/") && mod.hasSingleStar(key)).map(
2146
+ (key) => `note: '${key}' matches one path segment per * in process, but any number at the edge (Cloudflare's * crosses /)`
2147
+ );
2148
+ if (security.botProtection.mode !== "off") {
2149
+ notes.push(
2150
+ "note: botProtection is left as the zone has it: Bot Fight Mode is a zone setting, not a ruleset rule"
2151
+ );
2152
+ }
2153
+ return notes;
2154
+ }
2155
+ function finish(outcome, secrets) {
2156
+ const masked = (lines) => lines.map((line) => secrets.reduce((text2, secret) => text2.split(secret).join("***"), line)).map((line) => `${line}
2157
+ `).join("");
2158
+ process.stderr.write(
2159
+ masked(outcome.stderr),
2160
+ () => process.stdout.write(masked(outcome.stdout), () => process.exit(outcome.code))
2161
+ );
2162
+ }
2163
+ async function runEdge(command, body) {
2164
+ const secrets = [process.env["CLOUDFLARE_API_TOKEN"]];
2165
+ let outcome;
2166
+ try {
2167
+ const { config } = await loadProject();
2168
+ const security = config.security;
2169
+ secrets.push(security.cloudflare?.apiToken);
2170
+ if (security.adapter !== "cloudflare") {
2171
+ outcome = failure(
2172
+ `security.adapter is '${security.adapter}', which has no edge rules for nymbal ${command}: set security.adapter to 'cloudflare' (nymbal add cloudflare) to manage them on a Cloudflare zone`
2173
+ );
2174
+ } else {
2175
+ const mod = await importProviderPackage(
2176
+ "security",
2177
+ "cloudflare",
2178
+ process.env
2179
+ );
2180
+ const adapter = await mod.createAdapter({
2181
+ slot: "security",
2182
+ config: security.cloudflare ?? {},
2183
+ logger: createLogger4({ level: "silent", pretty: false }),
2184
+ env: process.env
2185
+ });
2186
+ outcome = await body(adapter, security, mod);
2187
+ }
2188
+ } catch (error) {
2189
+ outcome = failure(`nymbal ${command} failed: ${errorText5(error)}`);
2190
+ }
2191
+ finish(
2192
+ outcome,
2193
+ secrets.filter((s) => typeof s === "string" && s !== "")
2194
+ );
2195
+ }
2196
+ var json = {
2197
+ type: "boolean",
2198
+ description: "Print the result as one JSON document",
2199
+ default: false
2200
+ };
2201
+ var securityPlanCommand = defineCommand13({
2202
+ meta: {
2203
+ name: "security:plan",
2204
+ description: "Show what security:apply would change on the Cloudflare zone"
2205
+ },
2206
+ args: { json },
2207
+ run: ({ args }) => runEdge("security:plan", async (adapter, security, mod) => {
2208
+ const check = await adapter.validate(security);
2209
+ if (!check.valid) return invalid(check);
2210
+ const plan = await adapter.plan(security);
2211
+ const notes = edgeNotes(security, mod);
2212
+ if (args.json) return { code: 0, stdout: [JSON.stringify(plan, null, 2)], stderr: notes };
2213
+ const lines = isEmpty(plan) ? ["No changes: the zone holds every Nymbal rule."] : formatPlan(plan);
2214
+ return { code: 0, stdout: [...lines, ...notes], stderr: [] };
2215
+ })
2216
+ });
2217
+ var securityApplyCommand = defineCommand13({
2218
+ meta: {
2219
+ name: "security:apply",
2220
+ description: "Write the store's security rules to the Cloudflare zone, leaving the zone's own rules"
2221
+ },
2222
+ run: () => runEdge("security:apply", async (adapter, security) => {
2223
+ const check = await adapter.validate(security);
2224
+ if (!check.valid) return invalid(check);
2225
+ const done = await adapter.apply(security);
2226
+ const lines = isEmpty(done) ? ["Nothing to write: the zone already holds every Nymbal rule."] : ["Applied:", ...formatPlan(done)];
2227
+ return { code: 0, stdout: lines, stderr: [] };
2228
+ })
2229
+ });
2230
+ var securityDriftCommand = defineCommand13({
2231
+ meta: {
2232
+ name: "security:drift",
2233
+ description: "Report where the zone's Nymbal rules differ from the config (exit 1 on drift)"
2234
+ },
2235
+ args: { json },
2236
+ run: ({ args }) => runEdge("security:drift", async (adapter, security) => {
2237
+ const report = await adapter.drift(security);
2238
+ const code = report.drift ? 1 : 0;
2239
+ if (args.json) return { code, stdout: [JSON.stringify(report, null, 2)], stderr: [] };
2240
+ if (!report.drift)
2241
+ return { code, stdout: ["In sync: the zone holds every Nymbal rule."], stderr: [] };
2242
+ const lines = report.differences.map(
2243
+ (d) => ` ${d.path} configured ${JSON.stringify(d.configured) ?? "nothing"}, live ${JSON.stringify(d.actual) ?? "nothing"}`
2244
+ );
2245
+ return {
2246
+ code,
2247
+ stdout: [
2248
+ `Drift: ${report.differences.length} difference(s); security:apply restores them`,
2249
+ ...lines
2250
+ ],
2251
+ stderr: []
2252
+ };
2253
+ })
2254
+ });
2255
+ var securityInspectCommand = defineCommand13({
2256
+ meta: {
2257
+ name: "security:inspect",
2258
+ description: "List the Nymbal rules live on the Cloudflare zone"
2259
+ },
2260
+ run: () => runEdge("security:inspect", async (adapter) => {
2261
+ const { rulesApplied } = await adapter.inspect();
2262
+ const lines = rulesApplied.length === 0 ? ["No Nymbal rules are live on the zone."] : rulesApplied;
2263
+ return { code: 0, stdout: lines, stderr: [] };
2264
+ })
2265
+ });
2266
+
2267
+ // src/commands/test.ts
2268
+ import { defineCommand as defineCommand14 } from "citty";
2269
+ import { loadConfig as loadConfig4 } from "@nymbalhq/config";
2270
+
2271
+ // src/commands/test-contracts.ts
2272
+ import { mkdirSync, realpathSync, writeFileSync as writeFileSync2 } from "fs";
2273
+ import { createRequire as createRequire3 } from "module";
2274
+ import { dirname as dirname7, join as join6, resolve as resolve9 } from "path";
2275
+ import { pathToFileURL as pathToFileURL2 } from "url";
2276
+ import { isDeepStrictEqual } from "util";
2277
+ import { findConfigFile as findConfigFile3, loadConfig as loadConfig3 } from "@nymbalhq/config";
2278
+ var CertifyError = class extends Error {
2279
+ };
2280
+ var firstLine = (text2) => text2.split("\n")[0].trim();
2281
+ var messageOf = (err2) => firstLine(err2 instanceof Error ? err2.message : String(err2));
2282
+ function resolveFrom(project, specifier, name, install) {
2283
+ try {
2284
+ return createRequire3(join6(project, "package.json")).resolve(specifier);
2285
+ } catch (err2) {
2286
+ if (err2.code === "MODULE_NOT_FOUND") {
2287
+ throw new CertifyError(`${name} is not installed in ${project}: ${install}`);
2288
+ }
2289
+ throw new CertifyError(`${name} could not be resolved from ${project}: ${messageOf(err2)}`);
2290
+ }
2291
+ }
2292
+ async function slotOptions(project, pkg, slot) {
2293
+ if (findConfigFile3(project) === null) return void 0;
2294
+ let commerce;
2295
+ try {
2296
+ commerce = (await loadConfig3(project)).config.commerce;
2297
+ } catch (err2) {
2298
+ throw new CertifyError(`the project's config could not be loaded: ${messageOf(err2)}`);
2299
+ }
2300
+ const naming = Object.entries(commerce).filter(([, block2]) => {
2301
+ const b = block2;
2302
+ return b.provider === "custom" && b.package === pkg;
2303
+ });
2304
+ const picked = slot === void 0 ? naming : naming.filter(([name]) => name === slot);
2305
+ if (slot !== void 0 && picked.length === 0) {
2306
+ throw new CertifyError(`no commerce slot '${slot}' with provider 'custom' names ${pkg}`);
2307
+ }
2308
+ if (picked.length > 1) {
2309
+ throw new CertifyError(
2310
+ `${pkg} is named by the ${picked.map(([name]) => name).join(", ")} slots: pick one with --slot`
2311
+ );
2312
+ }
2313
+ const block = picked[0]?.[1];
2314
+ return block === void 0 ? void 0 : block.config ?? {};
2315
+ }
2316
+ function withoutUndefined(value, path = /* @__PURE__ */ new Set()) {
2317
+ if (value === null || typeof value !== "object" || path.has(value)) return value;
2318
+ const proto = Object.getPrototypeOf(value);
2319
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return value;
2320
+ path.add(value);
2321
+ const out2 = Array.isArray(value) ? value.map((item) => withoutUndefined(item, path)) : Object.fromEntries(
2322
+ Object.entries(value).filter(([, v]) => v !== void 0).map(([k, v]) => [k, withoutUndefined(v, path)])
2323
+ );
2324
+ path.delete(value);
2325
+ return out2;
2326
+ }
2327
+ function survivesJson(value) {
2328
+ try {
2329
+ return isDeepStrictEqual(JSON.parse(JSON.stringify(value)), value);
2330
+ } catch {
2331
+ return false;
2332
+ }
2333
+ }
2334
+ async function closeProbe(adapter) {
2335
+ const close = adapter?.close;
2336
+ if (typeof close === "function") await close.call(adapter);
2337
+ }
2338
+ function countCases(files) {
2339
+ let passed = 0;
2340
+ let failed = 0;
2341
+ const visit = (task) => {
2342
+ if (task.type === "test") {
2343
+ if (task.result?.state === "pass") passed += 1;
2344
+ else if (task.mode !== "skip" && task.mode !== "todo") failed += 1;
2345
+ }
2346
+ for (const child2 of task.tasks ?? []) visit(child2);
2347
+ };
2348
+ for (const file of files) visit(file);
2349
+ return { passed, failed };
2350
+ }
2351
+ async function certifyPackage(input) {
2352
+ const { pkg, slot } = input;
2353
+ const config = findConfigFile3(input.cwd);
2354
+ const project = realpathSync(config === null ? resolve9(input.cwd) : dirname7(config));
2355
+ const vitestNode = resolveFrom(project, "vitest/node", "vitest", "npm install --save-dev vitest");
2356
+ const kindsEntry = resolveFrom(
2357
+ project,
2358
+ "@nymbalhq/contract-tests/kinds",
2359
+ "@nymbalhq/contract-tests",
2360
+ "npm install --save-dev @nymbalhq/contract-tests"
2361
+ );
2362
+ if (!isInstalled(pkg, project)) {
2363
+ throw new CertifyError(`${pkg} is not installed in ${project}: npm install ${pkg}`);
2364
+ }
2365
+ const contracts = join6(project, ".nymbal", "contracts");
2366
+ const slug = pkg.replace(/[^a-z0-9]+/gi, "-").replace(/^-|-$/g, "");
2367
+ mkdirSync(contracts, { recursive: true });
2368
+ const shim = join6(contracts, `${slug}.adapter.mjs`);
2369
+ writeFileSync2(shim, `export * from ${JSON.stringify(pkg)}
2370
+ `);
2371
+ let mod;
2372
+ try {
2373
+ mod = await import(pathToFileURL2(shim).href);
2374
+ } catch (err2) {
2375
+ throw new CertifyError(`${pkg} could not be loaded: ${messageOf(err2)}`);
2376
+ }
2377
+ if (typeof mod.createAdapter !== "function") {
2378
+ throw new CertifyError(`${pkg} exports no createAdapter(options) function to certify`);
2379
+ }
2380
+ const createAdapter = mod.createAdapter;
2381
+ const extra = mod.contractOptions ?? {};
2382
+ const slotConfig = await slotOptions(project, pkg, slot);
2383
+ const fromSlot = slotConfig === void 0 ? void 0 : withoutUndefined(slotConfig);
2384
+ const options = fromSlot ?? extra.options ?? {};
2385
+ if (fromSlot !== void 0 && !survivesJson(fromSlot)) {
2386
+ throw new CertifyError(
2387
+ `the config of the slot naming ${pkg} holds a value JSON cannot carry (a function, Date, BigInt or cycle): certify it with plain data`
2388
+ );
2389
+ }
2390
+ let kind;
2391
+ try {
2392
+ const probe = await createAdapter(options);
2393
+ const own = probe?.kind;
2394
+ kind = typeof own === "string" ? own : extra.kind;
2395
+ await closeProbe(probe);
2396
+ } catch (err2) {
2397
+ throw new CertifyError(`createAdapter from ${pkg} threw: ${messageOf(err2)}`);
2398
+ }
2399
+ if (kind === void 0) {
2400
+ throw new CertifyError(
2401
+ `${pkg}'s adapter has no kind: a primitive names its own in contractOptions.kind`
2402
+ );
2403
+ }
2404
+ const { CONTRACT_KINDS } = await import(pathToFileURL2(kindsEntry).href);
2405
+ if (!CONTRACT_KINDS.includes(kind)) {
2406
+ throw new CertifyError(
2407
+ `no contract suite for kind '${kind}' (${pkg}); the kinds with one are ${CONTRACT_KINDS.join(", ")}`
2408
+ );
2409
+ }
2410
+ const file = `${slug}.contract.test.mjs`;
2411
+ writeFileSync2(
2412
+ join6(contracts, file),
2413
+ [
2414
+ `// Written by \`nymbal test:contracts --package ${pkg}\`, and again on every run.`,
2415
+ `import * as adapter from ${JSON.stringify(pkg)}`,
2416
+ `import { contractSuites } from '@nymbalhq/contract-tests'`,
2417
+ ``,
2418
+ fromSlot === void 0 ? `const options = adapter.contractOptions?.options ?? {}` : `const options = ${JSON.stringify(fromSlot)}`,
2419
+ `const unreachable = adapter.contractOptions?.unreachable`,
2420
+ ``,
2421
+ `async function build(config) {`,
2422
+ ` const built = await adapter.createAdapter(config)`,
2423
+ ` if (typeof built?.initialize === 'function') await built.initialize(config)`,
2424
+ ` return built`,
2425
+ `}`,
2426
+ ``,
2427
+ `contractSuites[${JSON.stringify(kind)}](${JSON.stringify(pkg)}, () => build(options), {`,
2428
+ ` ...(unreachable !== undefined && { unreachable: () => build(unreachable) }),`,
2429
+ `})`,
2430
+ ``
2431
+ ].join("\n")
2432
+ );
2433
+ const { startVitest } = await import(pathToFileURL2(vitestNode).href);
2434
+ const vitest = await startVitest(
2435
+ "test",
2436
+ [],
2437
+ {
2438
+ config: false,
2439
+ root: contracts,
2440
+ include: [file],
2441
+ watch: false,
2442
+ testTimeout: 6e4,
2443
+ hookTimeout: 6e4
2444
+ },
2445
+ { cacheDir: join6(contracts, ".vite") }
2446
+ );
2447
+ if (vitest === void 0) throw new CertifyError(`vitest did not start for ${pkg}`);
2448
+ const files = vitest.state.getFiles();
2449
+ const { passed, failed } = countCases(files);
2450
+ const broken = files.some((f) => f.result?.state === "fail") || vitest.state.getUnhandledErrors().length > 0;
2451
+ await vitest.close();
2452
+ const total = passed + failed;
2453
+ if (failed === 0 && total > 0 && !broken) {
2454
+ process.stdout.write(`certified ${pkg} (${kind}): ${passed} cases passed
2455
+ `);
2456
+ return 0;
2457
+ }
2458
+ process.stdout.write(`not certified ${pkg} (${kind}): ${failed} of ${total} cases failed
2459
+ `);
2460
+ return 1;
2461
+ }
2462
+
2463
+ // src/commands/test.ts
2464
+ var testCommand = defineCommand14({
702
2465
  meta: { name: "test", description: "Run unit + integration tests (vitest)" },
703
2466
  async run() {
704
2467
  await runOrFail("pnpm", ["vitest", "run"]);
705
2468
  }
706
2469
  });
707
- var testE2eCommand = defineCommand7({
2470
+ var testE2eCommand = defineCommand14({
708
2471
  meta: { name: "test:e2e", description: "Run Playwright E2E against the configured template" },
709
2472
  async run() {
710
- const { config } = await loadConfig2();
2473
+ const { config } = await loadConfig4();
711
2474
  await runOrFail("pnpm", [
712
2475
  "--filter",
713
2476
  "@nymbalhq/e2e",
@@ -721,7 +2484,7 @@ var testE2eCommand = defineCommand7({
721
2484
  ]);
722
2485
  }
723
2486
  });
724
- var testVisualCommand = defineCommand7({
2487
+ var testVisualCommand = defineCommand14({
725
2488
  meta: { name: "test:visual", description: "Run visual regression tests (Playwright toHaveScreenshot)" },
726
2489
  args: {
727
2490
  "update-baselines": {
@@ -731,7 +2494,7 @@ var testVisualCommand = defineCommand7({
731
2494
  }
732
2495
  },
733
2496
  async run({ args }) {
734
- const { config } = await loadConfig2();
2497
+ const { config } = await loadConfig4();
735
2498
  const isCI = !!process.env.CI;
736
2499
  const updateBaselines = args["update-baselines"];
737
2500
  if (updateBaselines && !isCI) {
@@ -766,12 +2529,35 @@ var testVisualCommand = defineCommand7({
766
2529
  }
767
2530
  }
768
2531
  });
769
- var testContractsCommand = defineCommand7({
770
- meta: { name: "test:contracts", description: "Run adapter contract tests" },
2532
+ var testContractsCommand = defineCommand14({
2533
+ meta: { name: "test:contracts", description: "Run adapter contract tests, or certify an adapter package" },
771
2534
  args: {
772
- adapter: { type: "string", description: "Run only contracts for a specific adapter (e.g. payments, email)" }
2535
+ adapter: { type: "string", description: "Run only contracts for a specific adapter (e.g. payments, email)" },
2536
+ package: {
2537
+ type: "string",
2538
+ description: "Certify an installed adapter package against the published suite for its kind"
2539
+ },
2540
+ slot: { type: "string", description: "With --package: the commerce slot whose config builds it" }
773
2541
  },
774
2542
  async run({ args }) {
2543
+ if (args.package) {
2544
+ const code = await certifyPackage({
2545
+ pkg: String(args.package),
2546
+ ...args.slot !== void 0 && { slot: String(args.slot) },
2547
+ cwd: process.cwd()
2548
+ }).catch((err2) => {
2549
+ const message = err2 instanceof Error ? err2.message : String(err2);
2550
+ const line = err2 instanceof CertifyError ? message : `test:contracts: ${message}`;
2551
+ process.stderr.write(`${line.split("\n")[0]}
2552
+ `);
2553
+ return 1;
2554
+ });
2555
+ await Promise.all([
2556
+ new Promise((done) => process.stdout.write("", done)),
2557
+ new Promise((done) => process.stderr.write("", done))
2558
+ ]);
2559
+ process.exit(code);
2560
+ }
775
2561
  const vArgs = [
776
2562
  "vitest",
777
2563
  "run",
@@ -784,10 +2570,10 @@ var testContractsCommand = defineCommand7({
784
2570
  await runOrFail("pnpm", vArgs);
785
2571
  }
786
2572
  });
787
- var testPerformanceCommand = defineCommand7({
2573
+ var testPerformanceCommand = defineCommand14({
788
2574
  meta: { name: "test:performance", description: "Run Lighthouse CI against the configured template" },
789
2575
  async run() {
790
- const { config } = await loadConfig2();
2576
+ const { config } = await loadConfig4();
791
2577
  const port = config.template === "nextjs" ? 3e3 : 4321;
792
2578
  await runOrFail("pnpm", ["lhci", "autorun"], {
793
2579
  env: {
@@ -798,7 +2584,7 @@ var testPerformanceCommand = defineCommand7({
798
2584
  });
799
2585
  }
800
2586
  });
801
- var testAllCommand = defineCommand7({
2587
+ var testAllCommand = defineCommand14({
802
2588
  meta: { name: "test:all", description: "Run unit, contract, e2e, visual, and performance tests" },
803
2589
  async run() {
804
2590
  const steps = [
@@ -813,7 +2599,7 @@ var testAllCommand = defineCommand7({
813
2599
  process.exit(code);
814
2600
  }
815
2601
  }
816
- const { config } = await loadConfig2();
2602
+ const { config } = await loadConfig4();
817
2603
  await runOrFail("pnpm", [
818
2604
  "--filter",
819
2605
  "@nymbalhq/e2e",
@@ -843,7 +2629,7 @@ var testAllCommand = defineCommand7({
843
2629
  });
844
2630
 
845
2631
  // src/index.ts
846
- var main = defineCommand8({
2632
+ var main = defineCommand15({
847
2633
  meta: {
848
2634
  name: "nymbal",
849
2635
  version: getCliVersion(),
@@ -856,6 +2642,16 @@ var main = defineCommand8({
856
2642
  seed: seedCommand,
857
2643
  import: importCommand,
858
2644
  "config:validate": configValidateCommand,
2645
+ add: addCommand,
2646
+ doctor: doctorCommand,
2647
+ "security:plan": securityPlanCommand,
2648
+ "security:apply": securityApplyCommand,
2649
+ "security:drift": securityDriftCommand,
2650
+ "security:inspect": securityInspectCommand,
2651
+ secrets: secretsCommand,
2652
+ events: eventsCommand,
2653
+ audit: auditCommand,
2654
+ replay: replayCommand,
859
2655
  test: testCommand,
860
2656
  "test:e2e": testE2eCommand,
861
2657
  "test:visual": testVisualCommand,