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