nymbal 1.0.0 → 1.1.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 defineCommand10, 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((resolve6, reject) => {
60
+ child2.on("error", reject);
61
+ child2.on("exit", (code) => resolve6(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((resolve6) => {
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
+ resolve6(false);
81
83
  });
82
84
  server.once("listening", () => {
83
- server.close(() => resolve4(true));
85
+ server.close(() => resolve6(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((resolve6) => {
247
+ timer = setTimeout(() => resolve6("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", {
@@ -363,15 +452,12 @@ var devCommand = defineCommand2({
363
452
  version
364
453
  });
365
454
  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
455
  await app.attachHttp(http.adapter, { adminRoot });
374
456
  await http.start();
457
+ const apiUrl = `http://localhost:${config.http.port}`;
458
+ const printSummary = (storefront) => {
459
+ console.log(renderDevSummary(summaryInfo(config, app, apiUrl, storefront, adminRoot !== null)));
460
+ };
375
461
  const stopApi = [
376
462
  { name: "http.stop", run: () => http.stop() },
377
463
  { name: "app.stop", run: () => app.stop() }
@@ -382,6 +468,7 @@ var devCommand = defineCommand2({
382
468
  exit: (code) => process.exit(code)
383
469
  });
384
470
  if (args.apiOnly) {
471
+ printSummary(null);
385
472
  const shutdown2 = (signal) => {
386
473
  platform.logger.info({ signal }, "Shutting down API");
387
474
  return shutdownWith(stopApi);
@@ -402,18 +489,12 @@ var devCommand = defineCommand2({
402
489
  );
403
490
  }
404
491
  const launch = planTemplateLaunch(projectRoot, config.template, storefrontPort);
405
- const apiUrl = `http://localhost:${config.http.port}`;
406
492
  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
493
  const templateProc = run(launch.command, launch.args, {
414
494
  cwd: launch.cwd,
415
495
  env: storefrontEnv(config, apiUrl, storefrontPort)
416
496
  });
497
+ printSummary({ template: `${config.template}, ${launch.mode}`, url: storefrontUrl });
417
498
  let shuttingDown = false;
418
499
  const shutdown = (signal) => {
419
500
  shuttingDown = true;
@@ -462,13 +543,13 @@ var buildCommand = defineCommand3({
462
543
 
463
544
  // src/commands/seed.ts
464
545
  import { defineCommand as defineCommand4 } from "citty";
465
- import { createPlatform, runSeed as runSeed2 } from "@nymbalhq/platform";
546
+ import { createPlatform, resolvePrimitives, runSeed as runSeed2 } from "@nymbalhq/platform";
466
547
  var seedCommand = defineCommand4({
467
548
  meta: { name: "seed", description: "Re-seed demo content (products, categories)" },
468
549
  async run() {
469
550
  const { config, projectRoot } = await loadProject();
470
551
  await migrateProject(config, projectRoot);
471
- const platform = createPlatform(config);
552
+ const platform = createPlatform(config, await resolvePrimitives(config));
472
553
  try {
473
554
  const result = await runSeed2({
474
555
  config,
@@ -490,7 +571,7 @@ var seedCommand = defineCommand4({
490
571
  // src/commands/import.ts
491
572
  import { defineCommand as defineCommand5 } from "citty";
492
573
  import { intro, outro, text, select, spinner, isCancel, cancel } from "@clack/prompts";
493
- import * as p from "picocolors";
574
+ import p from "picocolors";
494
575
  import { createApp as createApp2 } from "@nymbalhq/platform";
495
576
  import { runWooCommerceImport, writeReport } from "@nymbalhq/importers";
496
577
  import { join as join3 } from "path";
@@ -635,6 +716,7 @@ var importCommand = defineCommand5({
635
716
  services: app.services,
636
717
  repos: app.repos,
637
718
  adapters: { ai: app.adapters.ai, reviews: app.adapters.reviews },
719
+ media: app.platform.media,
638
720
  onProgress(event) {
639
721
  if (event.kind === "phase") {
640
722
  console.log(p.cyan(` \u2192 ${event.phase}`));
@@ -680,34 +762,815 @@ var configValidateCommand = defineCommand6({
680
762
  const { config, path } = await loadProject();
681
763
  console.log(pc2.green(`\u2713 config valid`) + ` (${path})`);
682
764
  console.log(
683
- ` template: ${config.template}, cloud: ${config.infrastructure.cloud}, commandStore: ${config.infrastructure.commandStore}`
765
+ ` template: ${config.template}, commandStore: ${config.infrastructure.commandStore}, documentStore: ${config.infrastructure.documentStore}, eventBus: ${config.infrastructure.eventBus}`
684
766
  );
685
767
  process.exit(0);
686
- } catch (err) {
687
- if (err instanceof NymbalError) {
768
+ } catch (err2) {
769
+ if (err2 instanceof NymbalError) {
688
770
  console.error(pc2.red("\u2717 config invalid"));
689
- console.error(err.message);
771
+ console.error(err2.message);
690
772
  } else {
691
- console.error(err);
773
+ console.error(err2);
692
774
  }
693
775
  process.exit(1);
694
776
  }
695
777
  }
696
778
  });
697
779
 
698
- // src/commands/test.ts
780
+ // src/commands/add.ts
781
+ import { spawnSync } from "child_process";
782
+ import { existsSync as existsSync6, mkdtempSync, readFileSync as readFileSync2, rmSync, writeFileSync } from "fs";
783
+ import { dirname as dirname6, join as join5 } from "path";
784
+ import { parseEnv } from "util";
699
785
  import { defineCommand as defineCommand7 } from "citty";
700
- import { loadConfig as loadConfig2 } from "@nymbalhq/config";
701
- var testCommand = defineCommand7({
786
+ import pc3 from "picocolors";
787
+ import { findConfigFile as findConfigFile2, loadConfig as loadConfig2 } from "@nymbalhq/config";
788
+ import { installLine } from "@nymbalhq/platform";
789
+
790
+ // src/recipes/holders.ts
791
+ import { existsSync as existsSync5 } from "fs";
792
+ import { readFile } from "fs/promises";
793
+ import { createRequire as createRequire2 } from "module";
794
+ import { dirname as dirname5, join as join4, resolve as resolve5 } from "path";
795
+ var RECIPE_HOLDERS = {
796
+ postgres: "@nymbalhq/platform",
797
+ redis: "@nymbalhq/doc-store-redis",
798
+ s3: "@nymbalhq/media-s3",
799
+ stripe: "@nymbalhq/adapter-stripe"
800
+ };
801
+ function knownRecipes() {
802
+ return Object.keys(RECIPE_HOLDERS).sort();
803
+ }
804
+ function isKnownRecipe(name) {
805
+ return Object.hasOwn(RECIPE_HOLDERS, name);
806
+ }
807
+ function unknownRecipe(name) {
808
+ return new Error(`unknown recipe '${name}': the recipes are ${knownRecipes().join(", ")}`);
809
+ }
810
+ function packageDir(pkg, projectRoot) {
811
+ let dir = resolve5(projectRoot);
812
+ while (true) {
813
+ const candidate = join4(dir, "node_modules", pkg);
814
+ if (existsSync5(join4(candidate, "package.json"))) return candidate;
815
+ const parent = dirname5(dir);
816
+ if (parent === dir) return null;
817
+ dir = parent;
818
+ }
819
+ }
820
+ function recipeFile(name, projectRoot) {
821
+ const holder = RECIPE_HOLDERS[name];
822
+ if (holder === void 0 || !isKnownRecipe(name)) throw unknownRecipe(name);
823
+ const dir = packageDir(holder, projectRoot);
824
+ if (dir === null) return null;
825
+ try {
826
+ return createRequire2(join4(dir, "package.json")).resolve(`${holder}/recipes/${name}.json`);
827
+ } catch (error) {
828
+ const code = error.code;
829
+ if (code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED") return null;
830
+ throw error;
831
+ }
832
+ }
833
+ function isInstalled(pkg, projectRoot) {
834
+ return packageDir(pkg, projectRoot) !== null;
835
+ }
836
+ async function readRecipe(name, projectRoot) {
837
+ const file = recipeFile(name, projectRoot);
838
+ if (file === null) {
839
+ throw new Error(
840
+ `${RECIPE_HOLDERS[name]} is not installed in ${projectRoot}, so its ${name} recipe cannot be read`
841
+ );
842
+ }
843
+ const recipe = JSON.parse(await readFile(file, "utf8"));
844
+ 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);
845
+ if (malformed) throw new Error(`${file} is not a ${name} recipe`);
846
+ return recipe;
847
+ }
848
+
849
+ // src/recipes/config-edit.ts
850
+ import { parseModule } from "magicast";
851
+ var SCAFFOLD_DEFAULTS = [
852
+ "sqlite",
853
+ "in-memory",
854
+ "in-process",
855
+ "local-fs",
856
+ "native"
857
+ ];
858
+ var CONFIG_MODULE = "@nymbalhq/config";
859
+ var child = (node, key) => node[key];
860
+ var children = (node, key) => node[key];
861
+ function parse(lf) {
862
+ return parseModule(lf, { tabWidth: 1 }).$ast;
863
+ }
864
+ var lfCopy = (source) => source.replace(/\r\n/g, "\n");
865
+ function unwrap(node) {
866
+ let at = node;
867
+ while (at !== void 0 && (at.type === "TSAsExpression" || at.type === "TSSatisfiesExpression" || at.type === "TSNonNullExpression" || at.type === "ParenthesizedExpression")) {
868
+ at = child(at, "expression");
869
+ }
870
+ return at;
871
+ }
872
+ function nameOf(node) {
873
+ if (node.type === "Identifier") return node["name"];
874
+ if (node.type === "StringLiteral") return node["value"];
875
+ if (node.type === "NumericLiteral") return String(node["value"]);
876
+ return void 0;
877
+ }
878
+ function configImports(program) {
879
+ return children(program, "body").filter(
880
+ (n) => n.type === "ImportDeclaration" && child(n, "source")["value"] === CONFIG_MODULE && n["importKind"] !== "type"
881
+ );
882
+ }
883
+ function importedAs(program, imported) {
884
+ for (const decl of configImports(program)) {
885
+ for (const spec of children(decl, "specifiers")) {
886
+ if (spec.type !== "ImportSpecifier" || spec["importKind"] === "type") continue;
887
+ if (nameOf(child(spec, "imported")) === imported)
888
+ return child(spec, "local")["name"];
889
+ }
890
+ }
891
+ return void 0;
892
+ }
893
+ function bindsEnv(program) {
894
+ const bound = (id) => id?.type === "Identifier" && id["name"] === "env";
895
+ return children(program, "body").some((n) => {
896
+ const decl = n.type === "ExportNamedDeclaration" ? child(n, "declaration") : n;
897
+ if (decl === null || decl === void 0) return false;
898
+ if (decl.type === "ImportDeclaration")
899
+ return children(decl, "specifiers").some((s) => bound(child(s, "local")));
900
+ if (decl.type === "VariableDeclaration")
901
+ return children(decl, "declarations").some((d) => bound(child(d, "id")));
902
+ return bound(child(decl, "id"));
903
+ });
904
+ }
905
+ function locate(source) {
906
+ const program = parse(source);
907
+ const exported = children(program, "body").find((n) => n.type === "ExportDefaultDeclaration");
908
+ if (exported === void 0) return void 0;
909
+ let config = unwrap(child(exported, "declaration"));
910
+ if (config?.type === "CallExpression") {
911
+ const callee = child(config, "callee");
912
+ const define = importedAs(program, "defineConfig") ?? "defineConfig";
913
+ if (callee.type !== "Identifier" || callee["name"] !== define) return void 0;
914
+ config = unwrap(children(config, "arguments")[0]);
915
+ }
916
+ if (config?.type !== "ObjectExpression") return void 0;
917
+ if (source[config.start] !== "{" || source[config.end - 1] !== "}") return void 0;
918
+ return {
919
+ program,
920
+ config,
921
+ envName: importedAs(program, "env"),
922
+ envBound: bindsEnv(program),
923
+ namedImport: configImports(program).find(
924
+ (d) => children(d, "specifiers").some((s) => s.type === "ImportSpecifier")
925
+ )
926
+ };
927
+ }
928
+ function isEnvRef(value) {
929
+ if (typeof value !== "object" || value === null) return false;
930
+ const keys = Object.keys(value);
931
+ return typeof value.env === "string" && keys.every((k) => k === "env" || k === "default");
932
+ }
933
+ function evaluate(node, envName) {
934
+ const at = unwrap(node);
935
+ switch (at.type) {
936
+ case "StringLiteral":
937
+ case "NumericLiteral":
938
+ case "BooleanLiteral":
939
+ return at["value"];
940
+ case "TemplateLiteral": {
941
+ const quasis = children(at, "quasis");
942
+ return children(at, "expressions").length === 0 ? (quasis[0]?.["value"]).cooked : void 0;
943
+ }
944
+ case "ObjectExpression": {
945
+ const out2 = {};
946
+ for (const prop of children(at, "properties")) {
947
+ const key = prop.type === "ObjectProperty" && !prop["computed"] ? nameOf(child(prop, "key")) : void 0;
948
+ const value = key === void 0 ? void 0 : evaluate(child(prop, "value"), envName);
949
+ if (key === void 0 || value === void 0) return void 0;
950
+ out2[key] = value;
951
+ }
952
+ return out2;
953
+ }
954
+ case "CallExpression": {
955
+ const callee = child(at, "callee");
956
+ const args = children(at, "arguments");
957
+ if (callee.type !== "Identifier" || callee["name"] !== (envName ?? "env")) return void 0;
958
+ if (args.length < 1 || args.length > 2 || args.some((a) => a.type !== "StringLiteral"))
959
+ return void 0;
960
+ return {
961
+ env: args[0]["value"],
962
+ ...args[1] !== void 0 && { default: args[1]["value"] }
963
+ };
964
+ }
965
+ default:
966
+ return void 0;
967
+ }
968
+ }
969
+ function sameValue(a, b) {
970
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return a === b;
971
+ const ka = Object.keys(a).sort();
972
+ const kb = Object.keys(b).sort();
973
+ return ka.length === kb.length && ka.every(
974
+ (k, i) => k === kb[i] && sameValue(a[k], b[k])
975
+ );
976
+ }
977
+ function isScaffoldDefault(value) {
978
+ if (typeof value === "string") return SCAFFOLD_DEFAULTS.includes(value);
979
+ const provider = value?.provider;
980
+ return typeof provider === "string" && SCAFFOLD_DEFAULTS.includes(provider);
981
+ }
982
+ function styleOf(source, mod) {
983
+ const firstString = /(['"])(?:(?!\1)[^\\\r\n]|\\.)*\1/.exec(source.slice(mod.config.start));
984
+ const indented = /\n([ \t]+)\S/.exec(source.slice(mod.config.start));
985
+ return {
986
+ quote: firstString?.[1] ?? "'",
987
+ unit: indented?.[1] ?? " ",
988
+ envName: mod.envName ?? "env"
989
+ };
990
+ }
991
+ function quoted(text2, style) {
992
+ const q = style.quote;
993
+ const body = text2.replace(/\\/g, "\\\\").replace(new RegExp(q, "g"), `\\${q}`).replace(/\r/g, "\\r").replace(/\n/g, "\\n");
994
+ return `${q}${body}${q}`;
995
+ }
996
+ function renderKey(key, style) {
997
+ return /^[A-Za-z_$][\w$]*$/.test(key) ? key : quoted(key, style);
998
+ }
999
+ function render(value, indent, style) {
1000
+ if (typeof value === "string") return quoted(value, style);
1001
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
1002
+ if (isEnvRef(value)) {
1003
+ const fallback = value.default === void 0 ? "" : `, ${quoted(value.default, style)}`;
1004
+ return `${style.envName}(${quoted(value.env, style)}${fallback})`;
1005
+ }
1006
+ const entries = Object.entries(value);
1007
+ if (entries.length === 0) return "{}";
1008
+ const inner = indent + style.unit;
1009
+ const lines = entries.map(
1010
+ ([k, v]) => `${inner}${renderKey(k, style)}: ${render(v, inner, style)},`
1011
+ );
1012
+ return `{
1013
+ ${lines.join("\n")}
1014
+ ${indent}}`;
1015
+ }
1016
+ function writesEnv(value) {
1017
+ if (isEnvRef(value)) return true;
1018
+ return typeof value === "object" && Object.values(value).some(writesEnv);
1019
+ }
1020
+ function indentAt(source, offset) {
1021
+ const lineStart = source.lastIndexOf("\n", offset - 1) + 1;
1022
+ return /^[ \t]*/.exec(source.slice(lineStart))[0];
1023
+ }
1024
+ function spliced(source, edits) {
1025
+ const crlf = [];
1026
+ for (const m of source.matchAll(/\r\n/g)) crlf.push(m.index - crlf.length);
1027
+ const at = (lf) => lf + crlf.filter((p2) => p2 < lf).length;
1028
+ const lastBreak = /\r?\n(?![\s\S]*\n)/.exec(source)?.[0] ?? "\n";
1029
+ return edits.map((e) => {
1030
+ const start = at(e.at);
1031
+ const end = at(e.at + e.remove);
1032
+ const eol = /\r?\n/.exec(source.slice(end))?.[0] ?? lastBreak;
1033
+ return { start, end, text: e.text.replace(/\n/g, eol) };
1034
+ }).sort((a, b) => b.start - a.start).reduce((s, e) => s.slice(0, e.start) + e.text + s.slice(e.end), source);
1035
+ }
1036
+ function addProperty(source, object, key, value, style) {
1037
+ const props = children(object, "properties");
1038
+ const close = object.end - 1;
1039
+ const last = props.at(-1);
1040
+ if (last === void 0) {
1041
+ const indent2 = indentAt(source, object.start);
1042
+ const inside = source.slice(object.start + 1, close);
1043
+ const text2 = `${indent2}${style.unit}${renderKey(key, style)}: ${render(value, indent2 + style.unit, style)},`;
1044
+ return inside.trim() === "" ? [
1045
+ {
1046
+ at: object.start + 1,
1047
+ remove: inside.length,
1048
+ text: `
1049
+ ${text2}
1050
+ ${indent2}`
1051
+ }
1052
+ ] : [{ at: close, remove: 0, text: `
1053
+ ${text2}
1054
+ ${indent2}` }];
1055
+ }
1056
+ const after = /^[ \t]*,/.exec(source.slice(last.end, close));
1057
+ const comma = after === null ? void 0 : last.end + after[0].length;
1058
+ const lineEnd = source.slice(last.end, close).indexOf("\n");
1059
+ const property = (indent2) => `${renderKey(key, style)}: ${render(value, indent2, style)}`;
1060
+ if (lineEnd === -1) {
1061
+ 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))},` }];
1062
+ }
1063
+ const indent = indentAt(source, last.start);
1064
+ const edits = [
1065
+ {
1066
+ at: last.end + lineEnd,
1067
+ remove: 0,
1068
+ text: `
1069
+ ${indent}${property(indent)}${comma === void 0 ? "" : ","}`
1070
+ }
1071
+ ];
1072
+ if (comma === void 0) edits.push({ at: last.end, remove: 0, text: "," });
1073
+ return edits;
1074
+ }
1075
+ function findProperty(object, key) {
1076
+ return children(object, "properties").findLast(
1077
+ (p2) => p2.type === "ObjectProperty" && !p2["computed"] && nameOf(child(p2, "key")) === key
1078
+ );
1079
+ }
1080
+ function editByHand(recipe, why) {
1081
+ const wanted = recipe.set.map(({ path, value }) => `${path} = ${JSON.stringify(value)}`);
1082
+ return new Error(
1083
+ `nymbal.config.ts ${why}, so the ${recipe.name} recipe cannot edit it: edit by hand and set ${wanted.join("; ")}`
1084
+ );
1085
+ }
1086
+ function setPath(source, recipe, path, value, force) {
1087
+ const lf = lfCopy(source);
1088
+ const mod = locate(lf);
1089
+ if (mod === void 0)
1090
+ throw editByHand(recipe, "does not export a defineConfig({...}) object literal");
1091
+ const style = styleOf(lf, mod);
1092
+ const keys = path.split(".");
1093
+ let object = mod.config;
1094
+ for (const [i, key] of keys.entries()) {
1095
+ const prop = findProperty(object, key);
1096
+ if (prop === void 0) {
1097
+ const nested = keys.slice(i + 1).reduceRight((v, k) => ({ [k]: v }), value);
1098
+ return {
1099
+ source: spliced(source, addProperty(lf, object, key, nested, style)),
1100
+ wrote: true
1101
+ };
1102
+ }
1103
+ const held = child(prop, "value");
1104
+ if (i === keys.length - 1) {
1105
+ const current = evaluate(held, mod.envName);
1106
+ if (current !== void 0 && sameValue(current, value)) return { source, wrote: false };
1107
+ if (!force && !isScaffoldDefault(current)) {
1108
+ return {
1109
+ conflict: { path, current: current ?? lf.slice(held.start, held.end), wanted: value }
1110
+ };
1111
+ }
1112
+ const text2 = render(value, indentAt(lf, prop.start), style);
1113
+ return {
1114
+ source: spliced(source, [{ at: held.start, remove: held.end - held.start, text: text2 }]),
1115
+ wrote: true
1116
+ };
1117
+ }
1118
+ const inner = unwrap(held);
1119
+ if (inner?.type !== "ObjectExpression") {
1120
+ throw editByHand(
1121
+ recipe,
1122
+ `holds ${keys.slice(0, i + 1).join(".")} as something other than an object literal`
1123
+ );
1124
+ }
1125
+ object = inner;
1126
+ }
1127
+ return { source, wrote: false };
1128
+ }
1129
+ function importEnv(source, recipe) {
1130
+ const lf = lfCopy(source);
1131
+ const mod = locate(lf);
1132
+ if (mod.envName !== void 0) return source;
1133
+ if (mod.envBound)
1134
+ throw editByHand(recipe, "binds `env` to something other than the env of @nymbalhq/config");
1135
+ const style = styleOf(lf, mod);
1136
+ if (mod.namedImport !== void 0) {
1137
+ const last = children(mod.namedImport, "specifiers").filter((s) => s.type === "ImportSpecifier").at(-1);
1138
+ return spliced(source, [{ at: last.end, remove: 0, text: ", env" }]);
1139
+ }
1140
+ const imports = children(mod.program, "body").filter((n) => n.type === "ImportDeclaration");
1141
+ const line = `import { env } from ${quoted(CONFIG_MODULE, style)}
1142
+ `;
1143
+ const lastImport = imports.at(-1);
1144
+ const lineEnd = lastImport === void 0 ? -1 : lf.indexOf("\n", lastImport.end);
1145
+ const edit = lastImport === void 0 ? { at: 0, remove: 0, text: line } : lineEnd === -1 ? { at: lf.length, remove: 0, text: `
1146
+ ${line}` } : { at: lineEnd + 1, remove: 0, text: line };
1147
+ return spliced(source, [edit]);
1148
+ }
1149
+ function applyRecipeToConfig(source, recipe, opts) {
1150
+ if (locate(lfCopy(source)) === void 0) {
1151
+ throw editByHand(recipe, "does not export a defineConfig({...}) object literal");
1152
+ }
1153
+ let current = source;
1154
+ const changed = [];
1155
+ let envWritten = false;
1156
+ for (const { path, value } of recipe.set) {
1157
+ const step = setPath(current, recipe, path, value, opts.force);
1158
+ if ("conflict" in step) return step;
1159
+ if (!step.wrote) continue;
1160
+ current = step.source;
1161
+ changed.push(path);
1162
+ envWritten ||= writesEnv(value);
1163
+ }
1164
+ if (envWritten) current = importEnv(current, recipe);
1165
+ return { source: current, changed };
1166
+ }
1167
+ function envCalls(source) {
1168
+ const lf = lfCopy(source);
1169
+ const mod = locate(lf);
1170
+ const envName = mod === void 0 ? importedAs(parse(lf), "env") : mod.envName;
1171
+ if (envName === void 0) return [];
1172
+ const defaulted = /* @__PURE__ */ new Map();
1173
+ const visit = (node) => {
1174
+ if (Array.isArray(node)) return node.forEach(visit);
1175
+ if (typeof node !== "object" || node === null || typeof node.type !== "string") return;
1176
+ const n = node;
1177
+ if (n.type === "CallExpression") {
1178
+ const callee = child(n, "callee");
1179
+ const args = children(n, "arguments");
1180
+ const first = args[0];
1181
+ if (callee.type === "Identifier" && callee["name"] === envName && first?.type === "StringLiteral") {
1182
+ const name = first["value"];
1183
+ defaulted.set(name, (defaulted.get(name) ?? true) && args.length > 1);
1184
+ }
1185
+ }
1186
+ for (const [key, value] of Object.entries(n)) {
1187
+ if (key !== "loc" && key !== "comments" && key !== "extra" && key !== "original") visit(value);
1188
+ }
1189
+ };
1190
+ visit(mod?.program ?? parse(lf));
1191
+ return [...defaulted].map(([name, all]) => ({ name, defaulted: all }));
1192
+ }
1193
+
1194
+ // src/recipes/env-example.ts
1195
+ var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
1196
+ function declares(lines, name) {
1197
+ const re = new RegExp(`^(#\\s*)?${escapeRegExp(name)}=`);
1198
+ return lines.some((line) => re.test(line.trim()));
1199
+ }
1200
+ function missingEnv(example, recipe) {
1201
+ const lines = example.split(/\r?\n/);
1202
+ return recipe.env.filter(
1203
+ (v, i) => !declares(lines, v.name) && recipe.env.findIndex((w) => w.name === v.name) === i
1204
+ );
1205
+ }
1206
+ function appendRecipeEnv(example, recipe) {
1207
+ const eol = example.includes("\r\n") ? "\r\n" : "\n";
1208
+ const missing = missingEnv(example, recipe);
1209
+ if (missing.length === 0) return example;
1210
+ const block = [`# ${recipe.name} (nymbal add ${recipe.name})`];
1211
+ for (const v of missing) {
1212
+ if (v.comment !== "") block.push(`# ${v.comment}`);
1213
+ block.push(`${v.required ? "" : "# "}${v.name}=${v.example}`);
1214
+ }
1215
+ let head = example;
1216
+ if (head !== "" && !head.endsWith("\n")) head += eol;
1217
+ const last = head.split(/\r?\n/).at(-2);
1218
+ const gap = last === void 0 || last.trim() === "" ? "" : eol;
1219
+ return `${head}${gap}${block.join(eol)}${eol}`;
1220
+ }
1221
+
1222
+ // src/commands/add.ts
1223
+ var out = (line = "") => void process.stdout.write(`${line}
1224
+ `);
1225
+ var err = (line) => void process.stderr.write(`${line}
1226
+ `);
1227
+ function fail(message) {
1228
+ err(pc3.red(`\u2717 ${message}`));
1229
+ return 1;
1230
+ }
1231
+ function runInstall(line, root) {
1232
+ out(pc3.dim(`$ ${line}`));
1233
+ const [command, ...args] = line.split(" ");
1234
+ const result = spawnSync(command, args, {
1235
+ cwd: root,
1236
+ stdio: "inherit",
1237
+ shell: process.platform === "win32"
1238
+ });
1239
+ return result.status === 0;
1240
+ }
1241
+ function dotEnv(root) {
1242
+ const file = join5(root, ".env");
1243
+ return existsSync6(file) ? parseEnv(readFileSync2(file, "utf8")) : {};
1244
+ }
1245
+ var isSet = (name, fromFile) => (process.env[name] ?? "") !== "" || (fromFile[name] ?? "") !== "";
1246
+ async function problemsWith(candidate, recipe, root, fromFile) {
1247
+ const problems = [];
1248
+ const missing = recipe.install.filter((pkg) => !isInstalled(pkg, root));
1249
+ if (missing.length > 0) {
1250
+ problems.push(
1251
+ `${missing.join(", ")} ${missing.length === 1 ? "is" : "are"} not installed: ${installLine(missing, process.env, root)}`
1252
+ );
1253
+ }
1254
+ const saved = /* @__PURE__ */ new Map();
1255
+ for (const { name, defaulted } of envCalls(candidate)) {
1256
+ if ((process.env[name] ?? "") !== "") continue;
1257
+ const value = fromFile[name] || (defaulted ? void 0 : `nymbal-add-placeholder-${name}`);
1258
+ if (value === void 0) continue;
1259
+ saved.set(name, process.env[name]);
1260
+ process.env[name] = value;
1261
+ }
1262
+ const dir = mkdtempSync(join5(root, ".nymbal-add-"));
1263
+ try {
1264
+ writeFileSync(join5(dir, "nymbal.config.ts"), candidate);
1265
+ await loadConfig2(dir);
1266
+ } catch (error) {
1267
+ problems.push(error.message);
1268
+ } finally {
1269
+ rmSync(dir, { recursive: true, force: true });
1270
+ for (const [name, value] of saved) {
1271
+ if (value === void 0) delete process.env[name];
1272
+ else process.env[name] = value;
1273
+ }
1274
+ }
1275
+ return problems;
1276
+ }
1277
+ function lineDiff(before, after) {
1278
+ const a = before.split("\n");
1279
+ const b = after.split("\n");
1280
+ const t = Array.from({ length: a.length + 1 }, () => new Array(b.length + 1).fill(0));
1281
+ for (let i2 = a.length - 1; i2 >= 0; i2--)
1282
+ for (let j2 = b.length - 1; j2 >= 0; j2--)
1283
+ t[i2][j2] = a[i2] === b[j2] ? t[i2 + 1][j2 + 1] + 1 : Math.max(t[i2 + 1][j2], t[i2][j2 + 1]);
1284
+ const lines = [];
1285
+ let i = 0;
1286
+ let j = 0;
1287
+ while (i < a.length || j < b.length) {
1288
+ if (i < a.length && j < b.length && a[i] === b[j]) {
1289
+ i++;
1290
+ j++;
1291
+ } else if (j < b.length && (i === a.length || t[i][j + 1] >= t[i + 1][j])) {
1292
+ lines.push(pc3.green(`+ ${b[j++]}`));
1293
+ } else {
1294
+ lines.push(pc3.red(`- ${a[i++]}`));
1295
+ }
1296
+ }
1297
+ return lines;
1298
+ }
1299
+ var show = (value) => typeof value === "string" ? `'${value}'` : JSON.stringify(value);
1300
+ async function add(opts, cwd = process.cwd()) {
1301
+ const name = opts.recipe;
1302
+ if (!isKnownRecipe(name)) {
1303
+ return fail(`unknown recipe '${name}'. The recipes are: ${knownRecipes().join(", ")}`);
1304
+ }
1305
+ const configPath = findConfigFile2(cwd);
1306
+ if (configPath === null) return fail(`no nymbal.config.ts in ${cwd} or any parent directory`);
1307
+ const root = dirname6(configPath);
1308
+ const holder = RECIPE_HOLDERS[name];
1309
+ const installed = [];
1310
+ if (recipeFile(name, root) === null) {
1311
+ const line = installLine([holder], process.env, root);
1312
+ if (!opts.install || opts.dryRun) {
1313
+ return fail(
1314
+ `the ${name} recipe ships in ${holder}, which is not installed. Install it: ${line}`
1315
+ );
1316
+ }
1317
+ if (!runInstall(line, root)) return fail(`${line} failed`);
1318
+ installed.push(holder);
1319
+ }
1320
+ const recipe = await readRecipe(name, root);
1321
+ const source = readFileSync2(configPath, "utf8");
1322
+ let edited;
1323
+ try {
1324
+ const applied = applyRecipeToConfig(source, recipe, { force: opts.force });
1325
+ if ("conflict" in applied) {
1326
+ const { path, current, wanted } = applied.conflict;
1327
+ return fail(
1328
+ `${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.`
1329
+ );
1330
+ }
1331
+ edited = applied;
1332
+ } catch (error) {
1333
+ return fail(error.message);
1334
+ }
1335
+ const examplePath = join5(root, ".env.example");
1336
+ const example = existsSync6(examplePath) ? readFileSync2(examplePath, "utf8") : "";
1337
+ const nextExample = appendRecipeEnv(example, recipe);
1338
+ if (opts.dryRun) {
1339
+ out(pc3.bold(`nymbal add ${name} --dry-run: nothing is written`));
1340
+ out();
1341
+ out(
1342
+ recipe.install.length > 0 ? `install: ${installLine(recipe.install, process.env, root)}` : "install: nothing"
1343
+ );
1344
+ out();
1345
+ out("nymbal.config.ts:");
1346
+ const diff = lineDiff(source, edited.source);
1347
+ for (const line of diff.length > 0 ? diff : ["(already set)"]) out(` ${line}`);
1348
+ out();
1349
+ out(".env.example:");
1350
+ const added = nextExample.slice(example.length).split(/\r?\n/).filter((l) => l !== "");
1351
+ for (const line of added.length > 0 ? added : ["(every variable already listed)"])
1352
+ out(` ${line}`);
1353
+ return 0;
1354
+ }
1355
+ const pending = recipe.install.filter((pkg) => !isInstalled(pkg, root));
1356
+ if (pending.length > 0 && opts.install) {
1357
+ const line = installLine(pending, process.env, root);
1358
+ if (!runInstall(line, root)) return fail(`${line} failed`);
1359
+ installed.push(...pending);
1360
+ }
1361
+ const fromFile = dotEnv(root);
1362
+ const problems = await problemsWith(edited.source, recipe, root, fromFile);
1363
+ if (problems.length > 0) {
1364
+ return fail(
1365
+ `the ${name} recipe would leave a config that does not load, so nymbal.config.ts is unchanged:
1366
+ ${problems.join("\n ")}`
1367
+ );
1368
+ }
1369
+ const written = [];
1370
+ if (installed.length > 0) written.push(`package.json and its lockfile (${installed.join(", ")})`);
1371
+ if (edited.source !== source) {
1372
+ writeFileSync(configPath, edited.source);
1373
+ written.push(`nymbal.config.ts (${edited.changed.join(", ")})`);
1374
+ }
1375
+ if (nextExample !== example) {
1376
+ writeFileSync(examplePath, nextExample);
1377
+ written.push(
1378
+ `.env.example (${missingEnv(example, recipe).map((v) => v.name).join(", ")})`
1379
+ );
1380
+ }
1381
+ out(pc3.green(`\u2713 ${name}: ${recipe.summary}`));
1382
+ out(written.length > 0 ? ` changed ${written.join(", ")}` : " already added: nothing changed");
1383
+ const unset = recipe.env.filter((v) => v.required && !isSet(v.name, fromFile));
1384
+ if (unset.length > 0 || recipe.next.length > 0) {
1385
+ out();
1386
+ out("Next:");
1387
+ for (const v of unset) out(` - set ${v.name} in .env`);
1388
+ for (const line of recipe.next) out(` - ${line}`);
1389
+ }
1390
+ return 0;
1391
+ }
1392
+ var addCommand = defineCommand7({
1393
+ meta: {
1394
+ name: "add",
1395
+ description: `Put the store on a service: ${knownRecipes().join(", ")}`
1396
+ },
1397
+ args: {
1398
+ recipe: { type: "positional", required: true, description: knownRecipes().join(", ") },
1399
+ install: {
1400
+ type: "boolean",
1401
+ default: true,
1402
+ description: "Install the packages (--no-install: they must already be installed)"
1403
+ },
1404
+ force: {
1405
+ type: "boolean",
1406
+ default: false,
1407
+ description: "Overwrite a config value the recipe conflicts with"
1408
+ },
1409
+ "dry-run": {
1410
+ type: "boolean",
1411
+ default: false,
1412
+ description: "Print the config change, the .env.example lines and the install line, and write nothing"
1413
+ }
1414
+ },
1415
+ async run({ args }) {
1416
+ process.exitCode = await add({
1417
+ recipe: args.recipe,
1418
+ install: args.install,
1419
+ force: args.force,
1420
+ dryRun: args["dry-run"]
1421
+ });
1422
+ }
1423
+ });
1424
+
1425
+ // src/commands/doctor.ts
1426
+ import { existsSync as existsSync7 } from "fs";
1427
+ import { defineCommand as defineCommand8 } from "citty";
1428
+ import { createLogger as createLogger2, diagnose } from "@nymbalhq/platform";
1429
+ import { errorText, HEALTH_CHECK_TIMEOUT_MS } from "@nymbalhq/types";
1430
+
1431
+ // src/commands/doctor-report.ts
1432
+ import pc4 from "picocolors";
1433
+ import { connectionSecrets, displayUrl as displayUrl2, SECRET_MIN_LENGTH } from "@nymbalhq/types";
1434
+ var TEXT_STATUS = {
1435
+ ok: "ok",
1436
+ healthy: "ok",
1437
+ degraded: "warn",
1438
+ down: "down"
1439
+ };
1440
+ var JSON_STATUS = {
1441
+ ok: "ok",
1442
+ healthy: "ok",
1443
+ degraded: "degraded",
1444
+ down: "down"
1445
+ };
1446
+ var COLOUR = { ok: pc4.green, warn: pc4.yellow, down: pc4.red };
1447
+ var SECRET_KEY = /secret|passw(?:or)?d|^pass$|token|api[-_]?key|private[-_]?key|credential|access[-_]?key/i;
1448
+ var SECRET_ENV = /SECRET|PASSWORD|PASSWD|(?:^|_)PASS(?:$|_)|TOKEN|API_KEY|PRIVATE_KEY|CREDENTIAL|ACCESS_KEY/i;
1449
+ var URL_IN_TEXT = /\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`<>]+/gi;
1450
+ var TRAILING_PUNCTUATION = /[.,;:)\]}]+$/;
1451
+ function addSecret(into, value) {
1452
+ if (value.length >= SECRET_MIN_LENGTH) into.add(value);
1453
+ }
1454
+ function collectFromConfig(value, key, into, seen) {
1455
+ if (typeof value === "string") {
1456
+ if (SECRET_KEY.test(key)) addSecret(into, value);
1457
+ if (value.includes("://")) for (const s of connectionSecrets(value, { user: false })) addSecret(into, s);
1458
+ return;
1459
+ }
1460
+ if (value === null || typeof value !== "object" || seen.has(value)) return;
1461
+ seen.add(value);
1462
+ for (const [k, v] of Object.entries(value)) collectFromConfig(v, k, into, seen);
1463
+ }
1464
+ function secretsOf(config, env) {
1465
+ const found = /* @__PURE__ */ new Set();
1466
+ collectFromConfig(config, "", found, /* @__PURE__ */ new WeakSet());
1467
+ for (const [name, value] of Object.entries(env)) {
1468
+ if (value === void 0) continue;
1469
+ if (SECRET_ENV.test(name)) addSecret(found, value);
1470
+ if (value.includes("://")) for (const s of connectionSecrets(value, { user: false })) addSecret(found, s);
1471
+ }
1472
+ return [...found].sort((a, b) => b.length - a.length);
1473
+ }
1474
+ function scrub(detail, secrets) {
1475
+ let text2 = detail.replace(URL_IN_TEXT, (match) => {
1476
+ const trailing = TRAILING_PUNCTUATION.exec(match)?.[0] ?? "";
1477
+ return displayUrl2(match.slice(0, match.length - trailing.length)) + trailing;
1478
+ });
1479
+ for (const secret of secrets) text2 = text2.split(secret).join("***");
1480
+ return text2.replace(/\s*\n\s*(?:•\s*)?/g, " ").trim();
1481
+ }
1482
+ function formatDoctorReport(report, options) {
1483
+ const secrets = secretsOf(options.config, options.env ?? {});
1484
+ const checks = report.checks.map((check) => ({
1485
+ name: check.name,
1486
+ status: check.status,
1487
+ detail: scrub(check.detail, secrets)
1488
+ }));
1489
+ if (options.json) {
1490
+ return JSON.stringify(
1491
+ {
1492
+ checks: checks.map((c) => ({ name: c.name, status: JSON_STATUS[c.status], detail: c.detail }))
1493
+ },
1494
+ null,
1495
+ 2
1496
+ );
1497
+ }
1498
+ const width = Math.max(...checks.map((c) => c.name.length), 0);
1499
+ const lines = checks.map((c) => {
1500
+ const status = TEXT_STATUS[c.status];
1501
+ return `${COLOUR[status](status.padEnd(4))} ${c.name.padEnd(width)} ${c.detail}`.trimEnd();
1502
+ });
1503
+ 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");
1505
+ }
1506
+
1507
+ // src/commands/doctor.ts
1508
+ function timeoutOf(flag2) {
1509
+ if (flag2 === void 0 || flag2.trim() === "") return HEALTH_CHECK_TIMEOUT_MS;
1510
+ const ms = Number(flag2);
1511
+ if (!Number.isInteger(ms) || ms < 1) {
1512
+ throw new Error(`--timeout must be a whole number of milliseconds, got "${flag2}"`);
1513
+ }
1514
+ return ms;
1515
+ }
1516
+ function migrationsOf(projectRoot) {
1517
+ const root = resolveMigrations(projectRoot);
1518
+ return existsSync7(root) ? { migrationsRoot: root } : {};
1519
+ }
1520
+ var doctorCommand = defineCommand8({
1521
+ meta: {
1522
+ name: "doctor",
1523
+ description: "Check the project without changing it: config, packages, command store, migrations, slots and primitives"
1524
+ },
1525
+ args: {
1526
+ json: { type: "boolean", description: "Print the checks as one JSON document", default: false },
1527
+ timeout: {
1528
+ type: "string",
1529
+ description: `How long each check may take, in ms (default ${HEALTH_CHECK_TIMEOUT_MS})`
1530
+ }
1531
+ },
1532
+ async run({ args }) {
1533
+ const timeoutMs = timeoutOf(args.timeout);
1534
+ let loaded;
1535
+ try {
1536
+ loaded = await loadProject();
1537
+ } catch (error) {
1538
+ loaded = { error };
1539
+ }
1540
+ const report = "error" in loaded ? (
1541
+ // Nothing else can be checked without a config, so it is the only line.
1542
+ { checks: [{ name: "config", status: "down", detail: errorText(loaded.error) }] }
1543
+ ) : await diagnose(loaded.config, {
1544
+ ...loaded.adapters !== void 0 && { adapters: loaded.adapters },
1545
+ // The report carries every failure; a log line would only repeat one, and break `--json`.
1546
+ logger: createLogger2({ level: "silent", pretty: false }),
1547
+ timeoutMs,
1548
+ ...migrationsOf(loaded.projectRoot)
1549
+ });
1550
+ const text2 = formatDoctorReport(report, {
1551
+ json: args.json,
1552
+ config: "error" in loaded ? void 0 : loaded.config,
1553
+ env: process.env
1554
+ });
1555
+ const down = report.checks.some((check) => check.status === "down");
1556
+ process.stdout.write(`${text2}
1557
+ `, () => process.exit(down ? 1 : 0));
1558
+ }
1559
+ });
1560
+
1561
+ // src/commands/test.ts
1562
+ import { defineCommand as defineCommand9 } from "citty";
1563
+ import { loadConfig as loadConfig3 } from "@nymbalhq/config";
1564
+ var testCommand = defineCommand9({
702
1565
  meta: { name: "test", description: "Run unit + integration tests (vitest)" },
703
1566
  async run() {
704
1567
  await runOrFail("pnpm", ["vitest", "run"]);
705
1568
  }
706
1569
  });
707
- var testE2eCommand = defineCommand7({
1570
+ var testE2eCommand = defineCommand9({
708
1571
  meta: { name: "test:e2e", description: "Run Playwright E2E against the configured template" },
709
1572
  async run() {
710
- const { config } = await loadConfig2();
1573
+ const { config } = await loadConfig3();
711
1574
  await runOrFail("pnpm", [
712
1575
  "--filter",
713
1576
  "@nymbalhq/e2e",
@@ -721,7 +1584,7 @@ var testE2eCommand = defineCommand7({
721
1584
  ]);
722
1585
  }
723
1586
  });
724
- var testVisualCommand = defineCommand7({
1587
+ var testVisualCommand = defineCommand9({
725
1588
  meta: { name: "test:visual", description: "Run visual regression tests (Playwright toHaveScreenshot)" },
726
1589
  args: {
727
1590
  "update-baselines": {
@@ -731,7 +1594,7 @@ var testVisualCommand = defineCommand7({
731
1594
  }
732
1595
  },
733
1596
  async run({ args }) {
734
- const { config } = await loadConfig2();
1597
+ const { config } = await loadConfig3();
735
1598
  const isCI = !!process.env.CI;
736
1599
  const updateBaselines = args["update-baselines"];
737
1600
  if (updateBaselines && !isCI) {
@@ -766,7 +1629,7 @@ var testVisualCommand = defineCommand7({
766
1629
  }
767
1630
  }
768
1631
  });
769
- var testContractsCommand = defineCommand7({
1632
+ var testContractsCommand = defineCommand9({
770
1633
  meta: { name: "test:contracts", description: "Run adapter contract tests" },
771
1634
  args: {
772
1635
  adapter: { type: "string", description: "Run only contracts for a specific adapter (e.g. payments, email)" }
@@ -784,10 +1647,10 @@ var testContractsCommand = defineCommand7({
784
1647
  await runOrFail("pnpm", vArgs);
785
1648
  }
786
1649
  });
787
- var testPerformanceCommand = defineCommand7({
1650
+ var testPerformanceCommand = defineCommand9({
788
1651
  meta: { name: "test:performance", description: "Run Lighthouse CI against the configured template" },
789
1652
  async run() {
790
- const { config } = await loadConfig2();
1653
+ const { config } = await loadConfig3();
791
1654
  const port = config.template === "nextjs" ? 3e3 : 4321;
792
1655
  await runOrFail("pnpm", ["lhci", "autorun"], {
793
1656
  env: {
@@ -798,7 +1661,7 @@ var testPerformanceCommand = defineCommand7({
798
1661
  });
799
1662
  }
800
1663
  });
801
- var testAllCommand = defineCommand7({
1664
+ var testAllCommand = defineCommand9({
802
1665
  meta: { name: "test:all", description: "Run unit, contract, e2e, visual, and performance tests" },
803
1666
  async run() {
804
1667
  const steps = [
@@ -813,7 +1676,7 @@ var testAllCommand = defineCommand7({
813
1676
  process.exit(code);
814
1677
  }
815
1678
  }
816
- const { config } = await loadConfig2();
1679
+ const { config } = await loadConfig3();
817
1680
  await runOrFail("pnpm", [
818
1681
  "--filter",
819
1682
  "@nymbalhq/e2e",
@@ -843,7 +1706,7 @@ var testAllCommand = defineCommand7({
843
1706
  });
844
1707
 
845
1708
  // src/index.ts
846
- var main = defineCommand8({
1709
+ var main = defineCommand10({
847
1710
  meta: {
848
1711
  name: "nymbal",
849
1712
  version: getCliVersion(),
@@ -856,6 +1719,8 @@ var main = defineCommand8({
856
1719
  seed: seedCommand,
857
1720
  import: importCommand,
858
1721
  "config:validate": configValidateCommand,
1722
+ add: addCommand,
1723
+ doctor: doctorCommand,
859
1724
  test: testCommand,
860
1725
  "test:e2e": testE2eCommand,
861
1726
  "test:visual": testVisualCommand,