nymbal 0.2.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);
@@ -217,6 +219,102 @@ function resolveAdminRoot() {
217
219
  }
218
220
  }
219
221
 
222
+ // src/utils/shutdown.ts
223
+ var SHUTDOWN_BOUND_MS = 3e3;
224
+ var running = false;
225
+ async function runShutdown(steps, opts) {
226
+ if (running) {
227
+ opts.logger.error({}, "Shutdown already running; exiting now");
228
+ return opts.exit(1);
229
+ }
230
+ running = true;
231
+ let pending;
232
+ let failed = false;
233
+ const work = (async () => {
234
+ for (const step of steps) {
235
+ pending = step.name;
236
+ try {
237
+ await step.run();
238
+ } catch (err2) {
239
+ failed = true;
240
+ opts.logger.error({ err: err2, step: step.name }, `Shutdown step "${step.name}" failed`);
241
+ }
242
+ }
243
+ pending = void 0;
244
+ })();
245
+ let timer;
246
+ const bound = new Promise((resolve6) => {
247
+ timer = setTimeout(() => resolve6("bound"), opts.boundMs);
248
+ });
249
+ const outcome = await Promise.race([work.then(() => "done"), bound]);
250
+ clearTimeout(timer);
251
+ if (outcome === "bound") {
252
+ opts.logger.error(
253
+ { step: pending, boundMs: opts.boundMs },
254
+ `Shutdown step "${pending}" did not finish within ${opts.boundMs} ms; exiting`
255
+ );
256
+ return opts.exit(1);
257
+ }
258
+ return opts.exit(failed ? 1 : 0);
259
+ }
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
+
220
318
  // src/commands/dev.ts
221
319
  function defaultStorefrontPort(template) {
222
320
  return template === "astro" ? 4321 : 3e3;
@@ -230,13 +328,7 @@ function preferredStorefrontPort(template, flagPort, env) {
230
328
  }
231
329
  return defaultStorefrontPort(template);
232
330
  }
233
- function storefrontEnv(config, apiUrl, storefrontPort, baseEnv = process.env) {
234
- const stubPayments = config.commerce.payments.provider === "stripe" ? {} : {
235
- PUBLIC_NYMBAL_PAYMENTS_PROVIDER: "native-stub",
236
- // Astro
237
- NEXT_PUBLIC_NYMBAL_PAYMENTS_PROVIDER: "native-stub"
238
- // Next.js
239
- };
331
+ function storefrontEnv(_config, apiUrl, storefrontPort, baseEnv = process.env) {
240
332
  return {
241
333
  ...baseEnv,
242
334
  NYMBAL_API_URL: apiUrl,
@@ -246,10 +338,41 @@ function storefrontEnv(config, apiUrl, storefrontPort, baseEnv = process.env) {
246
338
  // Next.js
247
339
  // The framework also reads the port flag; the env var keeps the scaffold
248
340
  // config (astro.config.mjs) in sync when it honours NYMBAL_STOREFRONT_PORT.
249
- NYMBAL_STOREFRONT_PORT: String(storefrontPort),
250
- ...stubPayments
341
+ NYMBAL_STOREFRONT_PORT: String(storefrontPort)
251
342
  };
252
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
+ }))
366
+ };
367
+ }
368
+ function apiPort(config, flagPort) {
369
+ if (flagPort === void 0 || flagPort.trim() === "") return config.http.port;
370
+ const port = Number(flagPort);
371
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
372
+ throw new Error(`--port must be an integer from 1 to 65535, got "${flagPort}"`);
373
+ }
374
+ return port;
375
+ }
253
376
  var devCommand = defineCommand2({
254
377
  meta: {
255
378
  name: "dev",
@@ -273,17 +396,37 @@ var devCommand = defineCommand2({
273
396
  description: "Start only the API server (skip template dev server)",
274
397
  default: false
275
398
  },
399
+ port: {
400
+ type: "string",
401
+ description: "Port for the API server (default: the config's http.port)"
402
+ },
276
403
  storefrontPort: {
277
404
  type: "string",
278
405
  alias: "storefront-port",
279
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
280
412
  }
281
413
  },
282
414
  async run({ args }) {
283
- const { config, projectRoot } = await loadProject();
415
+ const loaded = await loadProject();
416
+ const { projectRoot } = loaded;
417
+ const port = apiPort(loaded.config, args.port);
418
+ const config = { ...loaded.config, http: { ...loaded.config.http, port } };
284
419
  const version = getCliVersion();
285
420
  await migrateProject(config, projectRoot);
286
- 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
+ });
287
430
  const { platform } = app;
288
431
  if (args.seed) {
289
432
  const existing = await platform.documentStore.query("products", {
@@ -309,23 +452,26 @@ var devCommand = defineCommand2({
309
452
  version
310
453
  });
311
454
  const adminRoot = resolveAdminRoot();
312
- if (adminRoot === null) {
313
- console.log(
314
- pc.yellow(
315
- "\u2192 The admin is not built, so /admin/ answers 404: run `pnpm --filter @nymbalhq/admin build`."
316
- )
317
- );
318
- }
319
455
  await app.attachHttp(http.adapter, { adminRoot });
320
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
+ };
461
+ const stopApi = [
462
+ { name: "http.stop", run: () => http.stop() },
463
+ { name: "app.stop", run: () => app.stop() }
464
+ ];
465
+ const shutdownWith = (steps) => runShutdown(steps, {
466
+ boundMs: SHUTDOWN_BOUND_MS,
467
+ logger: platform.logger,
468
+ exit: (code) => process.exit(code)
469
+ });
321
470
  if (args.apiOnly) {
322
- const shutdown2 = async (signal) => {
471
+ printSummary(null);
472
+ const shutdown2 = (signal) => {
323
473
  platform.logger.info({ signal }, "Shutting down API");
324
- await http.stop().catch(() => {
325
- });
326
- await app.stop().catch(() => {
327
- });
328
- process.exit(0);
474
+ return shutdownWith(stopApi);
329
475
  };
330
476
  process.on("SIGINT", () => void shutdown2("SIGINT"));
331
477
  process.on("SIGTERM", () => void shutdown2("SIGTERM"));
@@ -343,34 +489,31 @@ var devCommand = defineCommand2({
343
489
  );
344
490
  }
345
491
  const launch = planTemplateLaunch(projectRoot, config.template, storefrontPort);
346
- const apiUrl = `http://localhost:${config.http.port}`;
347
492
  const storefrontUrl = `http://localhost:${storefrontPort}`;
348
- console.log(
349
- pc.cyan(
350
- `\u2192 Starting storefront (${pc.bold(config.template)}, ${launch.mode}) on ${pc.bold(storefrontUrl)}`
351
- )
352
- );
353
- console.log(pc.dim(` API: ${apiUrl}`));
354
493
  const templateProc = run(launch.command, launch.args, {
355
494
  cwd: launch.cwd,
356
495
  env: storefrontEnv(config, apiUrl, storefrontPort)
357
496
  });
358
- const shutdown = async (signal) => {
497
+ printSummary({ template: `${config.template}, ${launch.mode}`, url: storefrontUrl });
498
+ let shuttingDown = false;
499
+ const shutdown = (signal) => {
500
+ shuttingDown = true;
359
501
  platform.logger.info({ signal }, "Shutting down");
360
- try {
361
- templateProc.child.kill("SIGINT");
362
- } catch {
363
- }
364
- await http.stop().catch(() => {
365
- });
366
- await app.stop().catch(() => {
367
- });
368
- process.exit(0);
502
+ return shutdownWith([
503
+ {
504
+ name: "storefront.kill",
505
+ run: async () => {
506
+ templateProc.child.kill("SIGINT");
507
+ }
508
+ },
509
+ ...stopApi
510
+ ]);
369
511
  };
370
512
  process.on("SIGINT", () => void shutdown("SIGINT"));
371
513
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
372
514
  const templateExit = await templateProc.done;
373
515
  platform.logger.info({ code: templateExit }, "Template process exited");
516
+ if (shuttingDown) return;
374
517
  await shutdown("template-exit");
375
518
  }
376
519
  });
@@ -400,13 +543,13 @@ var buildCommand = defineCommand3({
400
543
 
401
544
  // src/commands/seed.ts
402
545
  import { defineCommand as defineCommand4 } from "citty";
403
- import { createPlatform, runSeed as runSeed2 } from "@nymbalhq/platform";
546
+ import { createPlatform, resolvePrimitives, runSeed as runSeed2 } from "@nymbalhq/platform";
404
547
  var seedCommand = defineCommand4({
405
548
  meta: { name: "seed", description: "Re-seed demo content (products, categories)" },
406
549
  async run() {
407
550
  const { config, projectRoot } = await loadProject();
408
551
  await migrateProject(config, projectRoot);
409
- const platform = createPlatform(config);
552
+ const platform = createPlatform(config, await resolvePrimitives(config));
410
553
  try {
411
554
  const result = await runSeed2({
412
555
  config,
@@ -428,7 +571,7 @@ var seedCommand = defineCommand4({
428
571
  // src/commands/import.ts
429
572
  import { defineCommand as defineCommand5 } from "citty";
430
573
  import { intro, outro, text, select, spinner, isCancel, cancel } from "@clack/prompts";
431
- import * as p from "picocolors";
574
+ import p from "picocolors";
432
575
  import { createApp as createApp2 } from "@nymbalhq/platform";
433
576
  import { runWooCommerceImport, writeReport } from "@nymbalhq/importers";
434
577
  import { join as join3 } from "path";
@@ -573,6 +716,7 @@ var importCommand = defineCommand5({
573
716
  services: app.services,
574
717
  repos: app.repos,
575
718
  adapters: { ai: app.adapters.ai, reviews: app.adapters.reviews },
719
+ media: app.platform.media,
576
720
  onProgress(event) {
577
721
  if (event.kind === "phase") {
578
722
  console.log(p.cyan(` \u2192 ${event.phase}`));
@@ -618,34 +762,815 @@ var configValidateCommand = defineCommand6({
618
762
  const { config, path } = await loadProject();
619
763
  console.log(pc2.green(`\u2713 config valid`) + ` (${path})`);
620
764
  console.log(
621
- ` 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}`
622
766
  );
623
767
  process.exit(0);
624
- } catch (err) {
625
- if (err instanceof NymbalError) {
768
+ } catch (err2) {
769
+ if (err2 instanceof NymbalError) {
626
770
  console.error(pc2.red("\u2717 config invalid"));
627
- console.error(err.message);
771
+ console.error(err2.message);
628
772
  } else {
629
- console.error(err);
773
+ console.error(err2);
630
774
  }
631
775
  process.exit(1);
632
776
  }
633
777
  }
634
778
  });
635
779
 
636
- // 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";
637
785
  import { defineCommand as defineCommand7 } from "citty";
638
- import { loadConfig as loadConfig2 } from "@nymbalhq/config";
639
- 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({
640
1565
  meta: { name: "test", description: "Run unit + integration tests (vitest)" },
641
1566
  async run() {
642
1567
  await runOrFail("pnpm", ["vitest", "run"]);
643
1568
  }
644
1569
  });
645
- var testE2eCommand = defineCommand7({
1570
+ var testE2eCommand = defineCommand9({
646
1571
  meta: { name: "test:e2e", description: "Run Playwright E2E against the configured template" },
647
1572
  async run() {
648
- const { config } = await loadConfig2();
1573
+ const { config } = await loadConfig3();
649
1574
  await runOrFail("pnpm", [
650
1575
  "--filter",
651
1576
  "@nymbalhq/e2e",
@@ -659,7 +1584,7 @@ var testE2eCommand = defineCommand7({
659
1584
  ]);
660
1585
  }
661
1586
  });
662
- var testVisualCommand = defineCommand7({
1587
+ var testVisualCommand = defineCommand9({
663
1588
  meta: { name: "test:visual", description: "Run visual regression tests (Playwright toHaveScreenshot)" },
664
1589
  args: {
665
1590
  "update-baselines": {
@@ -669,7 +1594,7 @@ var testVisualCommand = defineCommand7({
669
1594
  }
670
1595
  },
671
1596
  async run({ args }) {
672
- const { config } = await loadConfig2();
1597
+ const { config } = await loadConfig3();
673
1598
  const isCI = !!process.env.CI;
674
1599
  const updateBaselines = args["update-baselines"];
675
1600
  if (updateBaselines && !isCI) {
@@ -704,7 +1629,7 @@ var testVisualCommand = defineCommand7({
704
1629
  }
705
1630
  }
706
1631
  });
707
- var testContractsCommand = defineCommand7({
1632
+ var testContractsCommand = defineCommand9({
708
1633
  meta: { name: "test:contracts", description: "Run adapter contract tests" },
709
1634
  args: {
710
1635
  adapter: { type: "string", description: "Run only contracts for a specific adapter (e.g. payments, email)" }
@@ -722,10 +1647,10 @@ var testContractsCommand = defineCommand7({
722
1647
  await runOrFail("pnpm", vArgs);
723
1648
  }
724
1649
  });
725
- var testPerformanceCommand = defineCommand7({
1650
+ var testPerformanceCommand = defineCommand9({
726
1651
  meta: { name: "test:performance", description: "Run Lighthouse CI against the configured template" },
727
1652
  async run() {
728
- const { config } = await loadConfig2();
1653
+ const { config } = await loadConfig3();
729
1654
  const port = config.template === "nextjs" ? 3e3 : 4321;
730
1655
  await runOrFail("pnpm", ["lhci", "autorun"], {
731
1656
  env: {
@@ -736,7 +1661,7 @@ var testPerformanceCommand = defineCommand7({
736
1661
  });
737
1662
  }
738
1663
  });
739
- var testAllCommand = defineCommand7({
1664
+ var testAllCommand = defineCommand9({
740
1665
  meta: { name: "test:all", description: "Run unit, contract, e2e, visual, and performance tests" },
741
1666
  async run() {
742
1667
  const steps = [
@@ -751,7 +1676,7 @@ var testAllCommand = defineCommand7({
751
1676
  process.exit(code);
752
1677
  }
753
1678
  }
754
- const { config } = await loadConfig2();
1679
+ const { config } = await loadConfig3();
755
1680
  await runOrFail("pnpm", [
756
1681
  "--filter",
757
1682
  "@nymbalhq/e2e",
@@ -781,7 +1706,7 @@ var testAllCommand = defineCommand7({
781
1706
  });
782
1707
 
783
1708
  // src/index.ts
784
- var main = defineCommand8({
1709
+ var main = defineCommand10({
785
1710
  meta: {
786
1711
  name: "nymbal",
787
1712
  version: getCliVersion(),
@@ -794,6 +1719,8 @@ var main = defineCommand8({
794
1719
  seed: seedCommand,
795
1720
  import: importCommand,
796
1721
  "config:validate": configValidateCommand,
1722
+ add: addCommand,
1723
+ doctor: doctorCommand,
797
1724
  test: testCommand,
798
1725
  "test:e2e": testE2eCommand,
799
1726
  "test:visual": testVisualCommand,