nymbal 1.1.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 defineCommand10, 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";
@@ -56,9 +56,9 @@ function run(command, args, options = {}) {
56
56
  shell: false,
57
57
  ...options
58
58
  });
59
- const done = new Promise((resolve6, reject) => {
59
+ const done = new Promise((resolve10, reject) => {
60
60
  child2.on("error", reject);
61
- child2.on("exit", (code) => resolve6(code ?? 0));
61
+ child2.on("exit", (code) => resolve10(code ?? 0));
62
62
  });
63
63
  return { child: child2, done };
64
64
  }
@@ -73,16 +73,16 @@ async function runOrFail(command, args, options = {}) {
73
73
  // src/utils/port.ts
74
74
  import { createServer } from "net";
75
75
  async function isPortFree(port, host) {
76
- return new Promise((resolve6) => {
76
+ return new Promise((resolve10) => {
77
77
  const server = createServer();
78
78
  server.once("error", (err2) => {
79
79
  if (err2.code !== "EADDRINUSE" && err2.code !== "EACCES") {
80
80
  console.error(`[dev] unexpected error probing port ${port}:`, err2.message);
81
81
  }
82
- resolve6(false);
82
+ resolve10(false);
83
83
  });
84
84
  server.once("listening", () => {
85
- server.close(() => resolve6(true));
85
+ server.close(() => resolve10(true));
86
86
  });
87
87
  if (host) {
88
88
  server.listen(port, host);
@@ -243,8 +243,8 @@ async function runShutdown(steps, opts) {
243
243
  pending = void 0;
244
244
  })();
245
245
  let timer;
246
- const bound = new Promise((resolve6) => {
247
- timer = setTimeout(() => resolve6("bound"), opts.boundMs);
246
+ const bound = new Promise((resolve10) => {
247
+ timer = setTimeout(() => resolve10("bound"), opts.boundMs);
248
248
  });
249
249
  const outcome = await Promise.race([work.then(() => "done"), bound]);
250
250
  clearTimeout(timer);
@@ -441,7 +441,8 @@ var devCommand = defineCommand2({
441
441
  documentStore: platform.documentStore,
442
442
  eventBus: platform.eventBus,
443
443
  logger: platform.logger,
444
- version
444
+ version,
445
+ search: app.adapters.search
445
446
  });
446
447
  }
447
448
  }
@@ -456,7 +457,9 @@ var devCommand = defineCommand2({
456
457
  await http.start();
457
458
  const apiUrl = `http://localhost:${config.http.port}`;
458
459
  const printSummary = (storefront) => {
459
- console.log(renderDevSummary(summaryInfo(config, app, apiUrl, storefront, adminRoot !== null)));
460
+ console.log(
461
+ renderDevSummary(summaryInfo(config, app, apiUrl, storefront, adminRoot !== null))
462
+ );
460
463
  };
461
464
  const stopApi = [
462
465
  { name: "http.stop", run: () => http.stop() },
@@ -546,10 +549,28 @@ import { defineCommand as defineCommand4 } from "citty";
546
549
  import { createPlatform, resolvePrimitives, runSeed as runSeed2 } from "@nymbalhq/platform";
547
550
  var seedCommand = defineCommand4({
548
551
  meta: { name: "seed", description: "Re-seed demo content (products, categories)" },
549
- 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 }) {
550
567
  const { config, projectRoot } = await loadProject();
551
568
  await migrateProject(config, projectRoot);
552
- const platform = createPlatform(config, await resolvePrimitives(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
+ );
553
574
  try {
554
575
  const result = await runSeed2({
555
576
  config,
@@ -557,7 +578,8 @@ var seedCommand = defineCommand4({
557
578
  documentStore: platform.documentStore,
558
579
  eventBus: platform.eventBus,
559
580
  logger: platform.logger,
560
- version: getCliVersion()
581
+ version: getCliVersion(),
582
+ ...args.localImages && { media: platform.media }
561
583
  });
562
584
  console.log(
563
585
  result.alreadySeeded ? "\u2713 demo content already present, nothing to seed" : `\u2713 seeded ${result.products} products across ${result.categories} categories`
@@ -572,7 +594,7 @@ var seedCommand = defineCommand4({
572
594
  import { defineCommand as defineCommand5 } from "citty";
573
595
  import { intro, outro, text, select, spinner, isCancel, cancel } from "@clack/prompts";
574
596
  import p from "picocolors";
575
- import { createApp as createApp2 } from "@nymbalhq/platform";
597
+ import { createApp as createApp2, newCorrelationId, runAsActor, runWithCorrelation } from "@nymbalhq/platform";
576
598
  import { runWooCommerceImport, writeReport } from "@nymbalhq/importers";
577
599
  import { join as join3 } from "path";
578
600
  var importCommand = defineCommand5({
@@ -700,35 +722,38 @@ var importCommand = defineCommand5({
700
722
  const app = await createApp2(config, { version: getCliVersion() });
701
723
  loadSpin.stop("Platform ready");
702
724
  try {
703
- const report = await runWooCommerceImport(
704
- {
705
- credentials: { url: storeUrl, consumerKey, consumerSecret },
706
- entities: entityList,
707
- ai: aiEnabled,
708
- gdpr: gdprEnabled,
709
- fresh: args.fresh,
710
- concurrency,
711
- projectRoot,
712
- currency: config.store.currency,
713
- statePath
714
- },
715
- {
716
- services: app.services,
717
- repos: app.repos,
718
- adapters: { ai: app.adapters.ai, reviews: app.adapters.reviews },
719
- media: app.platform.media,
720
- onProgress(event) {
721
- if (event.kind === "phase") {
722
- console.log(p.cyan(` \u2192 ${event.phase}`));
723
- } else if (event.kind === "progress") {
724
- const pct = Math.round(event.done / event.total * 100);
725
- process.stdout.write(`\r ${event.entity}: ${event.done}/${event.total} (${pct}%) `);
726
- if (event.done === event.total) process.stdout.write("\n");
727
- } else if (event.kind === "error") {
728
- 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
+ }
729
754
  }
730
755
  }
731
- }
756
+ ))
732
757
  );
733
758
  const reportPath = await writeReport(report, projectRoot, config.store.name);
734
759
  outro(
@@ -792,7 +817,9 @@ import { existsSync as existsSync5 } from "fs";
792
817
  import { readFile } from "fs/promises";
793
818
  import { createRequire as createRequire2 } from "module";
794
819
  import { dirname as dirname5, join as join4, resolve as resolve5 } from "path";
820
+ import { pathToFileURL } from "url";
795
821
  var RECIPE_HOLDERS = {
822
+ cloudflare: "@nymbalhq/security-cloudflare",
796
823
  postgres: "@nymbalhq/platform",
797
824
  redis: "@nymbalhq/doc-store-redis",
798
825
  s3: "@nymbalhq/media-s3",
@@ -845,6 +872,34 @@ async function readRecipe(name, projectRoot) {
845
872
  if (malformed) throw new Error(`${file} is not a ${name} recipe`);
846
873
  return recipe;
847
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
+ }
848
903
 
849
904
  // src/recipes/config-edit.ts
850
905
  import { parseModule } from "magicast";
@@ -853,7 +908,8 @@ var SCAFFOLD_DEFAULTS = [
853
908
  "in-memory",
854
909
  "in-process",
855
910
  "local-fs",
856
- "native"
911
+ "native",
912
+ "middleware"
857
913
  ];
858
914
  var CONFIG_MODULE = "@nymbalhq/config";
859
915
  var child = (node, key) => node[key];
@@ -1384,7 +1440,12 @@ async function add(opts, cwd = process.cwd()) {
1384
1440
  if (unset.length > 0 || recipe.next.length > 0) {
1385
1441
  out();
1386
1442
  out("Next:");
1387
- for (const v of unset) out(` - set ${v.name} in .env`);
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
+ }
1388
1449
  for (const line of recipe.next) out(` - ${line}`);
1389
1450
  }
1390
1451
  return 0;
@@ -1430,6 +1491,7 @@ import { errorText, HEALTH_CHECK_TIMEOUT_MS } from "@nymbalhq/types";
1430
1491
 
1431
1492
  // src/commands/doctor-report.ts
1432
1493
  import pc4 from "picocolors";
1494
+ import { scrub as scrubSettings } from "@nymbalhq/platform";
1433
1495
  import { connectionSecrets, displayUrl as displayUrl2, SECRET_MIN_LENGTH } from "@nymbalhq/types";
1434
1496
  var TEXT_STATUS = {
1435
1497
  ok: "ok",
@@ -1454,7 +1516,8 @@ function addSecret(into, value) {
1454
1516
  function collectFromConfig(value, key, into, seen) {
1455
1517
  if (typeof value === "string") {
1456
1518
  if (SECRET_KEY.test(key)) addSecret(into, value);
1457
- if (value.includes("://")) for (const s of connectionSecrets(value, { user: false })) addSecret(into, s);
1519
+ if (value.includes("://"))
1520
+ for (const s of connectionSecrets(value, { user: false })) addSecret(into, s);
1458
1521
  return;
1459
1522
  }
1460
1523
  if (value === null || typeof value !== "object" || seen.has(value)) return;
@@ -1467,12 +1530,13 @@ function secretsOf(config, env) {
1467
1530
  for (const [name, value] of Object.entries(env)) {
1468
1531
  if (value === void 0) continue;
1469
1532
  if (SECRET_ENV.test(name)) addSecret(found, value);
1470
- if (value.includes("://")) for (const s of connectionSecrets(value, { user: false })) addSecret(found, s);
1533
+ if (value.includes("://"))
1534
+ for (const s of connectionSecrets(value, { user: false })) addSecret(found, s);
1471
1535
  }
1472
1536
  return [...found].sort((a, b) => b.length - a.length);
1473
1537
  }
1474
1538
  function scrub(detail, secrets) {
1475
- let text2 = detail.replace(URL_IN_TEXT, (match) => {
1539
+ let text2 = scrubSettings(detail).replace(URL_IN_TEXT, (match) => {
1476
1540
  const trailing = TRAILING_PUNCTUATION.exec(match)?.[0] ?? "";
1477
1541
  return displayUrl2(match.slice(0, match.length - trailing.length)) + trailing;
1478
1542
  });
@@ -1489,7 +1553,11 @@ function formatDoctorReport(report, options) {
1489
1553
  if (options.json) {
1490
1554
  return JSON.stringify(
1491
1555
  {
1492
- checks: checks.map((c) => ({ name: c.name, status: JSON_STATUS[c.status], detail: c.detail }))
1556
+ checks: checks.map((c) => ({
1557
+ name: c.name,
1558
+ status: JSON_STATUS[c.status],
1559
+ detail: c.detail
1560
+ }))
1493
1561
  },
1494
1562
  null,
1495
1563
  2
@@ -1501,7 +1569,9 @@ function formatDoctorReport(report, options) {
1501
1569
  return `${COLOUR[status](status.padEnd(4))} ${c.name.padEnd(width)} ${c.detail}`.trimEnd();
1502
1570
  });
1503
1571
  const count = (s) => checks.filter((c) => TEXT_STATUS[c.status] === s).length;
1504
- return [...lines, "", `${count("down")} down, ${count("warn")} warn, ${count("ok")} ok`].join("\n");
1572
+ return [...lines, "", `${count("down")} down, ${count("warn")} warn, ${count("ok")} ok`].join(
1573
+ "\n"
1574
+ );
1505
1575
  }
1506
1576
 
1507
1577
  // src/commands/doctor.ts
@@ -1558,19 +1628,849 @@ var doctorCommand = defineCommand8({
1558
1628
  }
1559
1629
  });
1560
1630
 
1561
- // src/commands/test.ts
1631
+ // src/commands/secrets.ts
1562
1632
  import { defineCommand as defineCommand9 } from "citty";
1563
- import { loadConfig as loadConfig3 } from "@nymbalhq/config";
1564
- var testCommand = defineCommand9({
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({
1565
2465
  meta: { name: "test", description: "Run unit + integration tests (vitest)" },
1566
2466
  async run() {
1567
2467
  await runOrFail("pnpm", ["vitest", "run"]);
1568
2468
  }
1569
2469
  });
1570
- var testE2eCommand = defineCommand9({
2470
+ var testE2eCommand = defineCommand14({
1571
2471
  meta: { name: "test:e2e", description: "Run Playwright E2E against the configured template" },
1572
2472
  async run() {
1573
- const { config } = await loadConfig3();
2473
+ const { config } = await loadConfig4();
1574
2474
  await runOrFail("pnpm", [
1575
2475
  "--filter",
1576
2476
  "@nymbalhq/e2e",
@@ -1584,7 +2484,7 @@ var testE2eCommand = defineCommand9({
1584
2484
  ]);
1585
2485
  }
1586
2486
  });
1587
- var testVisualCommand = defineCommand9({
2487
+ var testVisualCommand = defineCommand14({
1588
2488
  meta: { name: "test:visual", description: "Run visual regression tests (Playwright toHaveScreenshot)" },
1589
2489
  args: {
1590
2490
  "update-baselines": {
@@ -1594,7 +2494,7 @@ var testVisualCommand = defineCommand9({
1594
2494
  }
1595
2495
  },
1596
2496
  async run({ args }) {
1597
- const { config } = await loadConfig3();
2497
+ const { config } = await loadConfig4();
1598
2498
  const isCI = !!process.env.CI;
1599
2499
  const updateBaselines = args["update-baselines"];
1600
2500
  if (updateBaselines && !isCI) {
@@ -1629,12 +2529,35 @@ var testVisualCommand = defineCommand9({
1629
2529
  }
1630
2530
  }
1631
2531
  });
1632
- var testContractsCommand = defineCommand9({
1633
- 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" },
1634
2534
  args: {
1635
- 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" }
1636
2541
  },
1637
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
+ }
1638
2561
  const vArgs = [
1639
2562
  "vitest",
1640
2563
  "run",
@@ -1647,10 +2570,10 @@ var testContractsCommand = defineCommand9({
1647
2570
  await runOrFail("pnpm", vArgs);
1648
2571
  }
1649
2572
  });
1650
- var testPerformanceCommand = defineCommand9({
2573
+ var testPerformanceCommand = defineCommand14({
1651
2574
  meta: { name: "test:performance", description: "Run Lighthouse CI against the configured template" },
1652
2575
  async run() {
1653
- const { config } = await loadConfig3();
2576
+ const { config } = await loadConfig4();
1654
2577
  const port = config.template === "nextjs" ? 3e3 : 4321;
1655
2578
  await runOrFail("pnpm", ["lhci", "autorun"], {
1656
2579
  env: {
@@ -1661,7 +2584,7 @@ var testPerformanceCommand = defineCommand9({
1661
2584
  });
1662
2585
  }
1663
2586
  });
1664
- var testAllCommand = defineCommand9({
2587
+ var testAllCommand = defineCommand14({
1665
2588
  meta: { name: "test:all", description: "Run unit, contract, e2e, visual, and performance tests" },
1666
2589
  async run() {
1667
2590
  const steps = [
@@ -1676,7 +2599,7 @@ var testAllCommand = defineCommand9({
1676
2599
  process.exit(code);
1677
2600
  }
1678
2601
  }
1679
- const { config } = await loadConfig3();
2602
+ const { config } = await loadConfig4();
1680
2603
  await runOrFail("pnpm", [
1681
2604
  "--filter",
1682
2605
  "@nymbalhq/e2e",
@@ -1706,7 +2629,7 @@ var testAllCommand = defineCommand9({
1706
2629
  });
1707
2630
 
1708
2631
  // src/index.ts
1709
- var main = defineCommand10({
2632
+ var main = defineCommand15({
1710
2633
  meta: {
1711
2634
  name: "nymbal",
1712
2635
  version: getCliVersion(),
@@ -1721,6 +2644,14 @@ var main = defineCommand10({
1721
2644
  "config:validate": configValidateCommand,
1722
2645
  add: addCommand,
1723
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,
1724
2655
  test: testCommand,
1725
2656
  "test:e2e": testE2eCommand,
1726
2657
  "test:visual": testVisualCommand,