@beignet/cli 0.0.47 → 0.0.49

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.
Files changed (144) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +126 -42
  3. package/dist/analysis/contract-factories.d.ts +17 -0
  4. package/dist/analysis/contract-factories.d.ts.map +1 -0
  5. package/dist/analysis/contract-factories.js +176 -0
  6. package/dist/analysis/contract-factories.js.map +1 -0
  7. package/dist/analysis/layers.d.ts +5 -0
  8. package/dist/analysis/layers.d.ts.map +1 -0
  9. package/dist/analysis/layers.js +172 -0
  10. package/dist/analysis/layers.js.map +1 -0
  11. package/dist/analysis/port-wiring.d.ts +23 -0
  12. package/dist/analysis/port-wiring.d.ts.map +1 -0
  13. package/dist/analysis/port-wiring.js +379 -0
  14. package/dist/analysis/port-wiring.js.map +1 -0
  15. package/dist/analysis/source-index.d.ts +5 -0
  16. package/dist/analysis/source-index.d.ts.map +1 -1
  17. package/dist/analysis/source-index.js +171 -42
  18. package/dist/analysis/source-index.js.map +1 -1
  19. package/dist/analysis/workspace.d.ts +10 -4
  20. package/dist/analysis/workspace.d.ts.map +1 -1
  21. package/dist/analysis/workspace.js +66 -6
  22. package/dist/analysis/workspace.js.map +1 -1
  23. package/dist/app-map.d.ts.map +1 -1
  24. package/dist/app-map.js +311 -92
  25. package/dist/app-map.js.map +1 -1
  26. package/dist/check.d.ts +8 -1
  27. package/dist/check.d.ts.map +1 -1
  28. package/dist/check.js +55 -9
  29. package/dist/check.js.map +1 -1
  30. package/dist/db.d.ts +38 -0
  31. package/dist/db.d.ts.map +1 -1
  32. package/dist/db.js +52 -0
  33. package/dist/db.js.map +1 -1
  34. package/dist/explain.js +4 -4
  35. package/dist/explain.js.map +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +73 -6
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts.map +1 -1
  40. package/dist/inspect.js +454 -170
  41. package/dist/inspect.js.map +1 -1
  42. package/dist/lib.d.ts +2 -2
  43. package/dist/lib.d.ts.map +1 -1
  44. package/dist/lib.js +1 -1
  45. package/dist/lib.js.map +1 -1
  46. package/dist/lint.d.ts.map +1 -1
  47. package/dist/lint.js +309 -487
  48. package/dist/lint.js.map +1 -1
  49. package/dist/make/inbox.d.ts.map +1 -1
  50. package/dist/make/inbox.js +12 -5
  51. package/dist/make/inbox.js.map +1 -1
  52. package/dist/make/payments.js +3 -3
  53. package/dist/make/payments.js.map +1 -1
  54. package/dist/make/tenancy.js +1 -1
  55. package/dist/make/tenancy.js.map +1 -1
  56. package/dist/make.d.ts.map +1 -1
  57. package/dist/make.js +144 -8
  58. package/dist/make.js.map +1 -1
  59. package/dist/mcp.d.ts +1 -1
  60. package/dist/mcp.d.ts.map +1 -1
  61. package/dist/mcp.js +56 -19
  62. package/dist/mcp.js.map +1 -1
  63. package/dist/preflight.d.ts +11 -3
  64. package/dist/preflight.d.ts.map +1 -1
  65. package/dist/preflight.js +139 -16
  66. package/dist/preflight.js.map +1 -1
  67. package/dist/provider-add.d.ts.map +1 -1
  68. package/dist/provider-add.js +149 -9
  69. package/dist/provider-add.js.map +1 -1
  70. package/dist/provider-audit.d.ts +13 -0
  71. package/dist/provider-audit.d.ts.map +1 -1
  72. package/dist/provider-audit.js +122 -8
  73. package/dist/provider-audit.js.map +1 -1
  74. package/dist/registry-edits.d.ts +7 -0
  75. package/dist/registry-edits.d.ts.map +1 -1
  76. package/dist/registry-edits.js +237 -53
  77. package/dist/registry-edits.js.map +1 -1
  78. package/dist/templates/agents.d.ts.map +1 -1
  79. package/dist/templates/agents.js +29 -7
  80. package/dist/templates/agents.js.map +1 -1
  81. package/dist/templates/base.d.ts +1 -0
  82. package/dist/templates/base.d.ts.map +1 -1
  83. package/dist/templates/base.js +20 -6
  84. package/dist/templates/base.js.map +1 -1
  85. package/dist/templates/db/index.d.ts +1 -0
  86. package/dist/templates/db/index.d.ts.map +1 -1
  87. package/dist/templates/db/index.js.map +1 -1
  88. package/dist/templates/db/migration-status.d.ts +9 -0
  89. package/dist/templates/db/migration-status.d.ts.map +1 -0
  90. package/dist/templates/db/migration-status.js +117 -0
  91. package/dist/templates/db/migration-status.js.map +1 -0
  92. package/dist/templates/db/mysql.d.ts.map +1 -1
  93. package/dist/templates/db/mysql.js +23 -0
  94. package/dist/templates/db/mysql.js.map +1 -1
  95. package/dist/templates/db/postgres.d.ts.map +1 -1
  96. package/dist/templates/db/postgres.js +21 -0
  97. package/dist/templates/db/postgres.js.map +1 -1
  98. package/dist/templates/db/sqlite.d.ts.map +1 -1
  99. package/dist/templates/db/sqlite.js +53 -0
  100. package/dist/templates/db/sqlite.js.map +1 -1
  101. package/dist/templates/index.d.ts.map +1 -1
  102. package/dist/templates/index.js +5 -0
  103. package/dist/templates/index.js.map +1 -1
  104. package/dist/templates/server.d.ts.map +1 -1
  105. package/dist/templates/server.js +2 -0
  106. package/dist/templates/server.js.map +1 -1
  107. package/dist/templates/shared.d.ts +1 -0
  108. package/dist/templates/shared.d.ts.map +1 -1
  109. package/dist/templates/shared.js +4 -2
  110. package/dist/templates/shared.js.map +1 -1
  111. package/package.json +7 -6
  112. package/skills/app-structure/SKILL.md +56 -11
  113. package/src/analysis/contract-factories.ts +256 -0
  114. package/src/analysis/layers.ts +266 -0
  115. package/src/analysis/port-wiring.ts +506 -0
  116. package/src/analysis/source-index.ts +225 -57
  117. package/src/analysis/workspace.ts +115 -6
  118. package/src/app-map.ts +418 -109
  119. package/src/check.ts +86 -10
  120. package/src/db.ts +97 -0
  121. package/src/explain.ts +4 -4
  122. package/src/index.ts +108 -7
  123. package/src/inspect.ts +659 -216
  124. package/src/lib.ts +9 -1
  125. package/src/lint.ts +393 -643
  126. package/src/make/inbox.ts +12 -5
  127. package/src/make/payments.ts +3 -3
  128. package/src/make/tenancy.ts +1 -1
  129. package/src/make.ts +225 -8
  130. package/src/mcp.ts +73 -21
  131. package/src/preflight.ts +195 -16
  132. package/src/provider-add.ts +193 -9
  133. package/src/provider-audit.ts +210 -18
  134. package/src/registry-edits.ts +313 -63
  135. package/src/templates/agents.ts +29 -7
  136. package/src/templates/base.ts +21 -4
  137. package/src/templates/db/index.ts +1 -0
  138. package/src/templates/db/migration-status.ts +126 -0
  139. package/src/templates/db/mysql.ts +23 -0
  140. package/src/templates/db/postgres.ts +21 -0
  141. package/src/templates/db/sqlite.ts +53 -0
  142. package/src/templates/index.ts +5 -0
  143. package/src/templates/server.ts +2 -0
  144. package/src/templates/shared.ts +4 -2
package/src/preflight.ts CHANGED
@@ -2,12 +2,15 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { parseProviderPackageMetadata } from "@beignet/core/providers";
4
4
  import { createJiti } from "jiti";
5
+ import { runDatabaseStatus } from "./db.js";
5
6
  import {
6
7
  type InspectDiagnostic,
7
8
  inspectApp,
8
9
  productionHardeningDiagnosticCodes,
9
10
  } from "./inspect.js";
10
11
  import {
12
+ detectedProviderVariantsForRules,
13
+ formatProviderEnvAlternatives,
11
14
  installedPackageNames,
12
15
  type ProviderPackageJson,
13
16
  providerDoctorRulesForInstalledPackages,
@@ -46,8 +49,9 @@ export interface PreflightOptions {
46
49
  */
47
50
  doctor?: boolean;
48
51
  /**
49
- * Boot the app server and run every port's `checkHealth()`. Needs network
50
- * access and real credentials, so it is opt-in.
52
+ * Inspect database migration status, then boot the app server and run every
53
+ * port's `checkHealth()`. Needs network access and real credentials, so it
54
+ * is opt-in.
51
55
  */
52
56
  connect?: boolean;
53
57
  /**
@@ -56,9 +60,12 @@ export interface PreflightOptions {
56
60
  */
57
61
  serverModule?: string;
58
62
  /**
59
- * Per-dependency health check timeout in milliseconds. Defaults to 5000.
63
+ * Per migration-status or dependency health check timeout in milliseconds.
64
+ * Defaults to 5000.
60
65
  */
61
66
  connectTimeoutMs?: number;
67
+ /** Cancel connected migration inspection and health checks on abort. */
68
+ signal?: AbortSignal;
62
69
  }
63
70
 
64
71
  export interface PreflightResult {
@@ -79,6 +86,15 @@ export interface PreflightResult {
79
86
  * Ports whose `checkHealth()` ran during a `connect` pass.
80
87
  */
81
88
  checkedDependencies: string[];
89
+ /**
90
+ * Database migration state observed before dependency checks.
91
+ */
92
+ migrationStatus:
93
+ | "not-checked"
94
+ | "current"
95
+ | "pending"
96
+ | "failed"
97
+ | "unavailable";
82
98
  }
83
99
 
84
100
  /**
@@ -175,6 +191,46 @@ function applyEnvToProcess(
175
191
  };
176
192
  }
177
193
 
194
+ function appendProviderEnvFindings(options: {
195
+ owner: string;
196
+ required: readonly string[];
197
+ alternatives: readonly (readonly string[])[];
198
+ env: Readonly<Record<string, string | undefined>>;
199
+ requiredEnvKeys: Set<string>;
200
+ findings: PreflightFinding[];
201
+ }): void {
202
+ for (const key of options.required) {
203
+ options.requiredEnvKeys.add(key);
204
+ const value = options.env[key];
205
+ if (value === undefined || value.trim() === "") {
206
+ options.findings.push({
207
+ severity: "error",
208
+ code: "BEIGNET_PREFLIGHT_ENV_MISSING",
209
+ message: `${key} is required by ${options.owner} but is not set.`,
210
+ });
211
+ }
212
+ }
213
+
214
+ for (const key of options.alternatives.flat()) {
215
+ options.requiredEnvKeys.add(key);
216
+ }
217
+ if (
218
+ options.alternatives.length > 0 &&
219
+ !options.alternatives.some((alternative) =>
220
+ alternative.every((key) => {
221
+ const value = options.env[key];
222
+ return value !== undefined && value.trim() !== "";
223
+ }),
224
+ )
225
+ ) {
226
+ options.findings.push({
227
+ severity: "error",
228
+ code: "BEIGNET_PREFLIGHT_ENV_MISSING",
229
+ message: `${options.owner} requires one complete environment configuration: ${formatProviderEnvAlternatives(options.alternatives)}.`,
230
+ });
231
+ }
232
+ }
233
+
178
234
  /**
179
235
  * Validate the deploy environment against installed provider manifests, the
180
236
  * app's `.env.example` placeholders, and the app env schema module.
@@ -221,6 +277,7 @@ export async function runPreflight(
221
277
  envModuleChecked: false,
222
278
  doctorChecked: false,
223
279
  checkedDependencies: [],
280
+ migrationStatus: "not-checked",
224
281
  };
225
282
  }
226
283
 
@@ -230,19 +287,34 @@ export async function runPreflight(
230
287
  );
231
288
  const checkedProviders = rules.map((rule) => rule.packageName);
232
289
  const requiredEnvKeys = new Set<string>();
290
+ const detectedVariants = await detectedProviderVariantsForRules(
291
+ options.cwd,
292
+ rules,
293
+ );
233
294
 
234
295
  for (const rule of rules) {
235
- for (const key of rule.requiredEnv ?? []) {
236
- requiredEnvKeys.add(key);
237
- const value = env[key];
238
- if (value === undefined || value.trim() === "") {
239
- findings.push({
240
- severity: "error",
241
- code: "BEIGNET_PREFLIGHT_ENV_MISSING",
242
- message: `${key} is required by ${rule.packageName} but is not set.`,
296
+ if (rule.variants !== undefined) {
297
+ for (const variant of detectedVariants.get(rule.packageName) ?? []) {
298
+ appendProviderEnvFindings({
299
+ owner: `${rule.packageName} (${variant.displayName})`,
300
+ required: variant.requiredEnv,
301
+ alternatives: variant.requiredEnvAlternatives,
302
+ env,
303
+ requiredEnvKeys,
304
+ findings,
243
305
  });
244
306
  }
307
+ continue;
245
308
  }
309
+
310
+ appendProviderEnvFindings({
311
+ owner: rule.packageName,
312
+ required: rule.requiredEnv ?? [],
313
+ alternatives: rule.requiredEnvAlternatives ?? [],
314
+ env,
315
+ requiredEnvKeys,
316
+ findings,
317
+ });
246
318
  }
247
319
 
248
320
  const inngestDev = env.INNGEST_DEV?.trim().toLowerCase();
@@ -303,6 +375,7 @@ export async function runPreflight(
303
375
  let envModuleChecked = false;
304
376
  let doctorChecked = false;
305
377
  const checkedDependencies: string[] = [];
378
+ let migrationStatus: PreflightResult["migrationStatus"] = "not-checked";
306
379
  const restoreProcessEnv = applyEnvToProcess(env);
307
380
 
308
381
  try {
@@ -344,7 +417,67 @@ export async function runPreflight(
344
417
  }
345
418
 
346
419
  if (options.connect) {
347
- await runDependencyChecks(options, findings, checkedDependencies);
420
+ if (!packageJson.scripts?.["db:status"]) {
421
+ migrationStatus = "unavailable";
422
+ findings.push({
423
+ severity: "note",
424
+ code: "BEIGNET_PREFLIGHT_DATABASE_STATUS_UNAVAILABLE",
425
+ message:
426
+ 'No app-owned "db:status" script is configured; skipping pending migration detection.',
427
+ });
428
+ } else {
429
+ try {
430
+ const connectTimeoutMs = options.connectTimeoutMs ?? 5000;
431
+ const result = await runDatabaseStatus({
432
+ cwd: options.cwd,
433
+ captureOutput: true,
434
+ maxOutputBytes: 8 * 1024,
435
+ signal: options.signal,
436
+ timeoutMs: connectTimeoutMs,
437
+ });
438
+ migrationStatus = result.status;
439
+
440
+ if (result.status === "pending") {
441
+ findings.push({
442
+ severity: "error",
443
+ code: "BEIGNET_PREFLIGHT_DATABASE_MIGRATIONS_PENDING",
444
+ message: databaseStatusFindingMessage(
445
+ "Database migrations are pending. Run `beignet db migrate` in the serialized migration job before booting the app.",
446
+ result.stdout,
447
+ result.stderr,
448
+ result.outputTruncated,
449
+ ),
450
+ });
451
+ } else if (result.status === "failed") {
452
+ findings.push({
453
+ severity: "error",
454
+ code: "BEIGNET_PREFLIGHT_DATABASE_STATUS_FAILED",
455
+ message: databaseStatusFindingMessage(
456
+ result.timedOut
457
+ ? `Database migration status inspection timed out after ${connectTimeoutMs}ms.`
458
+ : "Database migration status inspection failed.",
459
+ result.stdout,
460
+ result.stderr,
461
+ result.outputTruncated,
462
+ ),
463
+ });
464
+ }
465
+ } catch (error) {
466
+ options.signal?.throwIfAborted();
467
+ migrationStatus = "failed";
468
+ findings.push({
469
+ severity: "error",
470
+ code: "BEIGNET_PREFLIGHT_DATABASE_STATUS_FAILED",
471
+ message: `Database migration status inspection failed: ${
472
+ error instanceof Error ? error.message : String(error)
473
+ }`,
474
+ });
475
+ }
476
+ }
477
+
478
+ if (migrationStatus !== "pending" && migrationStatus !== "failed") {
479
+ await runDependencyChecks(options, findings, checkedDependencies);
480
+ }
348
481
  }
349
482
  } finally {
350
483
  restoreProcessEnv();
@@ -356,9 +489,26 @@ export async function runPreflight(
356
489
  envModuleChecked,
357
490
  doctorChecked,
358
491
  checkedDependencies,
492
+ migrationStatus,
359
493
  };
360
494
  }
361
495
 
496
+ function databaseStatusFindingMessage(
497
+ summary: string,
498
+ stdout?: string,
499
+ stderr?: string,
500
+ outputTruncated?: boolean,
501
+ ): string {
502
+ const details = [
503
+ stdout?.trim() ? `stdout:\n${stdout.trim()}` : undefined,
504
+ stderr?.trim() ? `stderr:\n${stderr.trim()}` : undefined,
505
+ ].filter((value): value is string => value !== undefined);
506
+ if (outputTruncated) {
507
+ details.push("Output truncated; showing the retained tail.");
508
+ }
509
+ return [summary, ...details].join("\n");
510
+ }
511
+
362
512
  interface HealthCheckable {
363
513
  checkHealth(): Promise<unknown>;
364
514
  }
@@ -381,10 +531,13 @@ async function withTimeout<T>(
381
531
  work: Promise<T>,
382
532
  ms: number,
383
533
  label: string,
534
+ signal?: AbortSignal,
384
535
  ): Promise<T> {
536
+ signal?.throwIfAborted();
385
537
  let timer: ReturnType<typeof setTimeout> | undefined;
538
+ let onAbort: (() => void) | undefined;
386
539
  try {
387
- return await Promise.race([
540
+ const contenders: Promise<T>[] = [
388
541
  work,
389
542
  new Promise<never>((_, reject) => {
390
543
  timer = setTimeout(
@@ -392,9 +545,25 @@ async function withTimeout<T>(
392
545
  ms,
393
546
  );
394
547
  }),
395
- ]);
548
+ ];
549
+ if (signal) {
550
+ contenders.push(
551
+ new Promise<never>((_, reject) => {
552
+ onAbort = () =>
553
+ reject(
554
+ signal.reason instanceof Error
555
+ ? signal.reason
556
+ : new Error("Connected preflight cancelled."),
557
+ );
558
+ signal.addEventListener("abort", onAbort, { once: true });
559
+ if (signal.aborted) onAbort();
560
+ }),
561
+ );
562
+ }
563
+ return await Promise.race(contenders);
396
564
  } finally {
397
565
  if (timer !== undefined) clearTimeout(timer);
566
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
398
567
  }
399
568
  }
400
569
 
@@ -407,6 +576,7 @@ async function runDependencyChecks(
407
576
  findings: PreflightFinding[],
408
577
  checkedDependencies: string[],
409
578
  ): Promise<void> {
579
+ options.signal?.throwIfAborted();
410
580
  const serverModule = options.serverModule ?? "server/index.ts";
411
581
  const serverModulePath = path.resolve(options.cwd, serverModule);
412
582
  const timeoutMs = options.connectTimeoutMs ?? 5000;
@@ -424,6 +594,7 @@ async function runDependencyChecks(
424
594
  | { ports: Record<string, unknown>; stop?: () => Promise<void> }
425
595
  | undefined;
426
596
  try {
597
+ options.signal?.throwIfAborted();
427
598
  const jiti = createJiti(serverModulePath, {
428
599
  interopDefault: true,
429
600
  moduleCache: false,
@@ -452,6 +623,7 @@ async function runDependencyChecks(
452
623
  stop?: () => Promise<void>;
453
624
  };
454
625
  } catch (error) {
626
+ options.signal?.throwIfAborted();
455
627
  findings.push({
456
628
  severity: "error",
457
629
  code: "BEIGNET_PREFLIGHT_SERVER_BOOT_FAILED",
@@ -464,6 +636,7 @@ async function runDependencyChecks(
464
636
 
465
637
  try {
466
638
  for (const [name, port] of Object.entries(server.ports)) {
639
+ options.signal?.throwIfAborted();
467
640
  if (!isHealthCheckable(port)) continue;
468
641
  checkedDependencies.push(name);
469
642
  try {
@@ -471,6 +644,7 @@ async function runDependencyChecks(
471
644
  port.checkHealth(),
472
645
  timeoutMs,
473
646
  `${name}.checkHealth()`,
647
+ options.signal,
474
648
  );
475
649
  if (!healthResultOk(result)) {
476
650
  const message = (result as { message?: unknown }).message;
@@ -483,6 +657,7 @@ async function runDependencyChecks(
483
657
  });
484
658
  }
485
659
  } catch (error) {
660
+ options.signal?.throwIfAborted();
486
661
  findings.push({
487
662
  severity: "error",
488
663
  code: "BEIGNET_PREFLIGHT_DEPENDENCY_UNHEALTHY",
@@ -534,14 +709,18 @@ export function formatPreflight(result: PreflightResult, cwd: string): string {
534
709
  const dependencySummary = result.checkedDependencies.length
535
710
  ? `, ${result.checkedDependencies.length} dependency health check(s) run`
536
711
  : "";
712
+ const migrationSummary =
713
+ result.migrationStatus === "not-checked"
714
+ ? "migration status not checked"
715
+ : `migration status ${result.migrationStatus}`;
537
716
 
538
717
  if (errors.length === 0) {
539
718
  lines.push(
540
- `Preflight passed in ${cwd}: ${providerSummary} checked, ${envSummary}, ${doctorSummary}${dependencySummary}.`,
719
+ `Preflight passed in ${cwd}: ${providerSummary} checked, ${envSummary}, ${doctorSummary}, ${migrationSummary}${dependencySummary}.`,
541
720
  );
542
721
  } else {
543
722
  lines.push(
544
- `Preflight failed in ${cwd}: ${errors.length} error(s) across ${providerSummary}, ${envSummary}, ${doctorSummary}${dependencySummary}.`,
723
+ `Preflight failed in ${cwd}: ${errors.length} error(s) across ${providerSummary}, ${envSummary}, ${doctorSummary}, ${migrationSummary}${dependencySummary}.`,
545
724
  );
546
725
  }
547
726
 
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import ts from "typescript";
3
4
  import {
4
5
  type ProviderPresetName,
5
6
  providerPresetChoices,
@@ -164,7 +165,10 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
164
165
  "Add a worker entrypoint that runs createBullMQJobWorker(...) in a worker process; dispatched jobs do not execute without one. See the jobs docs page.",
165
166
  "Run beignet provider audit and beignet doctor --strict.",
166
167
  ],
167
- conflictingProviderEntries: ["createInngestJobsProvider"],
168
+ conflictingProviderEntries: [
169
+ "createInngestJobsProvider",
170
+ "inlineJobsProvider",
171
+ ],
168
172
  },
169
173
  "jobs-inngest": {
170
174
  name: "jobs-inngest",
@@ -215,7 +219,10 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
215
219
  "For non-Next runtimes, call createAppInngestFunctions(...) after creating the app server, then mount the result with Inngest's host adapter.",
216
220
  "Run beignet provider audit and beignet doctor --strict.",
217
221
  ],
218
- conflictingProviderEntries: ["createBullMQJobsProvider"],
222
+ conflictingProviderEntries: [
223
+ "createBullMQJobsProvider",
224
+ "inlineJobsProvider",
225
+ ],
219
226
  },
220
227
  "mail-resend": {
221
228
  name: "mail-resend",
@@ -276,7 +283,14 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
276
283
  importName: "MailerPort",
277
284
  importFrom: "@beignet/core/mail",
278
285
  },
279
- envVars: ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"],
286
+ envVars: [
287
+ "MAIL_HOST",
288
+ "MAIL_PORT",
289
+ "MAIL_USER",
290
+ "MAIL_PASS",
291
+ "MAIL_FROM",
292
+ "MAIL_REQUIRE_TLS",
293
+ ],
280
294
  envExample: [
281
295
  "# SMTP mail provider",
282
296
  "MAIL_HOST=smtp.example.com",
@@ -284,6 +298,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
284
298
  "MAIL_USER=dev-placeholder-change-me",
285
299
  "MAIL_PASS=dev-placeholder-change-me",
286
300
  "MAIL_FROM=Beignet App <noreply@example.com>",
301
+ "MAIL_REQUIRE_TLS=true",
287
302
  "",
288
303
  ].join("\n"),
289
304
  docs: [
@@ -293,12 +308,13 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
293
308
  "- Peer dependency: `nodemailer`",
294
309
  "- The preset wires `createSmtpMailProvider()` in `server/providers.ts` and defers `mailer` in `infra/port-wiring.ts`.",
295
310
  "- `.env.example` ships dev placeholders so the app boots; set real `MAIL_*` values before sending mail.",
311
+ "- Encrypted transport is required by default: port 465 uses implicit TLS and other ports must negotiate STARTTLS.",
296
312
  "- Use `ctx.ports.mailer` in app code; `ctx.ports.smtp` is available as an escape hatch.",
297
313
  "",
298
314
  ].join("\n"),
299
315
  nextSteps: [
300
316
  "Run your package manager install command.",
301
- "Set MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM in .env.local or your deployment environment.",
317
+ "Set MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM in .env.local or your deployment environment; keep MAIL_REQUIRE_TLS=true outside trusted local capture services.",
302
318
  "Run beignet provider audit and beignet doctor --strict.",
303
319
  ],
304
320
  conflictingProviderEntries: [
@@ -561,6 +577,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
561
577
  dependencies: (packageJson) => ({
562
578
  "@beignet/provider-storage-s3": beignetDependencyVersion(packageJson),
563
579
  "@aws-sdk/client-s3": externalVersions.awsSdkS3,
580
+ "@aws-sdk/lib-storage": externalVersions.awsSdkLibStorage,
564
581
  "@aws-sdk/s3-request-presigner": externalVersions.awsSdkS3,
565
582
  }),
566
583
  appPort: {
@@ -601,7 +618,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
601
618
  "## S3-compatible storage",
602
619
  "",
603
620
  "- Package: `@beignet/provider-storage-s3`",
604
- "- Peer dependencies: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`",
621
+ "- Peer dependencies: `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, and `@aws-sdk/s3-request-presigner`",
605
622
  "- The preset wires `createS3StorageProvider()` in `server/providers.ts` and defers `storage` in `infra/port-wiring.ts`.",
606
623
  "- Set `STORAGE_S3_BUCKET` before booting the app; set endpoint and credentials when using an S3-compatible host outside AWS defaults.",
607
624
  "- Use `ctx.ports.storage` in app code; `ctx.ports.s3Storage` is available as an escape hatch.",
@@ -639,6 +656,8 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
639
656
  "# Vercel Blob storage provider. Vercel sets BLOB_READ_WRITE_TOKEN",
640
657
  "# automatically on deployments with a connected Blob store.",
641
658
  "# BLOB_READ_WRITE_TOKEN=vercel_blob_rw_...",
659
+ "# OIDC alternative on Vercel: VERCEL_OIDC_TOKEN plus BLOB_STORE_ID.",
660
+ "# BLOB_STORE_ID=store_...",
642
661
  "# BLOB_ACCESS=private",
643
662
  "# BLOB_KEY_PREFIX=",
644
663
  "",
@@ -649,14 +668,14 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
649
668
  "- Package: `@beignet/provider-storage-vercel-blob`",
650
669
  "- Peer dependency: `@vercel/blob`",
651
670
  "- The preset wires `createVercelBlobStorageProvider()` in `server/providers.ts` and defers `storage` in `infra/port-wiring.ts`.",
652
- "- On Vercel, connect a Blob store to the project and `BLOB_READ_WRITE_TOKEN` is set automatically; set it manually elsewhere.",
671
+ "- On Vercel, connect a Blob store to the project. Use its `BLOB_READ_WRITE_TOKEN`, or Vercel OIDC with `VERCEL_OIDC_TOKEN` plus `BLOB_STORE_ID`; set a read-write token manually outside Vercel.",
653
672
  "- The store is uniform-visibility (`BLOB_ACCESS`, default `private`): Vercel Blob does not report per-object access back, so mixed visibility needs a second store.",
654
673
  "- Use `ctx.ports.storage` in app code; `ctx.ports.vercelBlob` is available as an escape hatch.",
655
674
  "",
656
675
  ].join("\n"),
657
676
  nextSteps: [
658
677
  "Run your package manager install command.",
659
- "Connect a Vercel Blob store to the project (or set BLOB_READ_WRITE_TOKEN manually).",
678
+ "Connect a Vercel Blob store and configure either BLOB_READ_WRITE_TOKEN or Vercel OIDC with BLOB_STORE_ID plus VERCEL_OIDC_TOKEN.",
660
679
  "Run beignet provider audit and beignet doctor --strict.",
661
680
  ],
662
681
  conflictingProviderEntries: [
@@ -689,6 +708,8 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
689
708
  "UPSTASH_REDIS_REST_TOKEN=dev-placeholder-change-me",
690
709
  "UPSTASH_PREFIX=beignet:ratelimit",
691
710
  "# UPSTASH_ALGORITHM=fixed-window",
711
+ "# UPSTASH_TIMEOUT_MS=5000",
712
+ "# UPSTASH_TIMEOUT_POLICY=fail-closed",
692
713
  "",
693
714
  ].join("\n"),
694
715
  docs: [
@@ -698,6 +719,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
698
719
  "- Peer dependencies: `@upstash/ratelimit` and `@upstash/redis`",
699
720
  "- The preset wires `createUpstashRateLimitProvider()` in `server/providers.ts` and defers `rateLimit` in `infra/port-wiring.ts`.",
700
721
  "- `.env.example` ships dev placeholders so the app boots; set real `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` values before rate limiting requests.",
722
+ "- Rate-limit checks time out after 5000ms and fail closed by default. Set `UPSTASH_TIMEOUT_POLICY=fail-open` only when availability is more important than enforcement during an Upstash outage.",
701
723
  "- Use `ctx.ports.rateLimit` in app code; `ctx.ports.upstash` is available as an escape hatch.",
702
724
  "",
703
725
  ].join("\n"),
@@ -1174,9 +1196,13 @@ function updatePackageJson(
1174
1196
  }
1175
1197
 
1176
1198
  function updateServerProviders(source: string, preset: ProviderPreset): string {
1177
- assertNoConflictingProviders(source, preset);
1199
+ const withoutGeneratedFallback =
1200
+ preset.appPort.key === "jobs"
1201
+ ? removeGeneratedInlineJobsProvider(source)
1202
+ : source;
1203
+ assertNoConflictingProviders(withoutGeneratedFallback, preset);
1178
1204
 
1179
- let next = source;
1205
+ let next = withoutGeneratedFallback;
1180
1206
  for (const importLine of preset.additionalImports ?? []) {
1181
1207
  if (!next.includes(importLine)) next = insertAfterImports(next, importLine);
1182
1208
  }
@@ -1200,6 +1226,164 @@ function updateServerProviders(source: string, preset: ProviderPreset): string {
1200
1226
  return result.kind === "updated" ? result.source : next;
1201
1227
  }
1202
1228
 
1229
+ function removeGeneratedInlineJobsProvider(source: string): string {
1230
+ const marker = "// beignet:generated-inline-jobs-provider";
1231
+ const sourceFile = ts.createSourceFile(
1232
+ "providers.ts",
1233
+ source,
1234
+ ts.ScriptTarget.Latest,
1235
+ true,
1236
+ ts.ScriptKind.TS,
1237
+ );
1238
+ const statement = sourceFile.statements.find((candidate) => {
1239
+ if (!ts.isVariableStatement(candidate)) return false;
1240
+ if (candidate.declarationList.declarations.length !== 1) return false;
1241
+ const statementStart = candidate.getStart(sourceFile);
1242
+ const markerStart = source.lastIndexOf(marker, statementStart);
1243
+ if (
1244
+ markerStart < 0 ||
1245
+ source.slice(markerStart + marker.length, statementStart).trim() !== ""
1246
+ ) {
1247
+ return false;
1248
+ }
1249
+ const declaration = candidate.declarationList.declarations.find(
1250
+ (item) =>
1251
+ ts.isIdentifier(item.name) && item.name.text === "inlineJobsProvider",
1252
+ );
1253
+ if (!declaration?.initializer) return false;
1254
+ const text = declaration.initializer.getText(sourceFile);
1255
+ return (
1256
+ /\bcreateProvider\b/.test(text) &&
1257
+ /\bcreateInlineJobDispatcher\b/.test(text) &&
1258
+ /\bname\s*:\s*["']inline-jobs["']/.test(text)
1259
+ );
1260
+ });
1261
+ if (!statement) return source;
1262
+
1263
+ const statementStart = statement.getStart(sourceFile);
1264
+ const markerStart = source.lastIndexOf(marker, statementStart);
1265
+ const removeStart =
1266
+ markerStart >= 0 &&
1267
+ source.slice(markerStart + marker.length, statementStart).trim() === ""
1268
+ ? markerStart
1269
+ : statementStart;
1270
+ let next = `${source.slice(0, removeStart)}${source.slice(statement.end)}`;
1271
+ const registry = arrayInitializerInfo(next, "providers");
1272
+ if (!registry) {
1273
+ throw new Error(
1274
+ "Could not remove the generated inline jobs provider because `export const providers = [...]` is missing.",
1275
+ );
1276
+ }
1277
+ const lines = registry.text.split(/\r?\n/);
1278
+ const entryIndex = lines.findIndex(
1279
+ (line) =>
1280
+ line.trim() === "inlineJobsProvider," ||
1281
+ line.trim() === "inlineJobsProvider",
1282
+ );
1283
+ let nextRegistry: string | undefined;
1284
+ if (entryIndex >= 0) {
1285
+ lines.splice(entryIndex, 1);
1286
+ nextRegistry = lines.join(registry.text.includes("\r\n") ? "\r\n" : "\n");
1287
+ } else if (!registry.text.includes("\n")) {
1288
+ const beforeFollowingEntry = /\binlineJobsProvider\s*,\s*/;
1289
+ const afterPreviousEntry = /,\s*inlineJobsProvider\b/;
1290
+ const onlyEntry = /^\[\s*inlineJobsProvider\s*\]$/;
1291
+ if (onlyEntry.test(registry.text)) nextRegistry = "[]";
1292
+ else if (beforeFollowingEntry.test(registry.text)) {
1293
+ nextRegistry = registry.text.replace(beforeFollowingEntry, "");
1294
+ } else if (afterPreviousEntry.test(registry.text)) {
1295
+ nextRegistry = registry.text.replace(afterPreviousEntry, "");
1296
+ }
1297
+ }
1298
+ if (nextRegistry === undefined) {
1299
+ throw new Error(
1300
+ "Could not remove the generated inline jobs provider from `export const providers = [...]`.",
1301
+ );
1302
+ }
1303
+ next = `${next.slice(0, registry.start)}${nextRegistry}${next.slice(registry.end)}`;
1304
+
1305
+ return removeNamedProviderImport(
1306
+ next,
1307
+ "createInlineJobDispatcher",
1308
+ "@beignet/core/jobs",
1309
+ );
1310
+ }
1311
+
1312
+ function removeNamedProviderImport(
1313
+ source: string,
1314
+ name: string,
1315
+ moduleName: string,
1316
+ ): string {
1317
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1318
+ const pattern = new RegExp(
1319
+ `import \\{([^}]*)\\} from ["']${escapedModule}["'];`,
1320
+ );
1321
+ const match = pattern.exec(source);
1322
+ if (!match) return source;
1323
+ const names = match[1]
1324
+ .split(",")
1325
+ .map((entry) => entry.trim())
1326
+ .filter(Boolean);
1327
+ if (!names.includes(name)) return source;
1328
+ if (hasIdentifierReferenceOutsideImport(source, name, moduleName)) {
1329
+ return source;
1330
+ }
1331
+ const remaining = names.filter((entry) => entry !== name);
1332
+ if (remaining.length === 0) {
1333
+ const lineBreak = source.startsWith("\r\n", match.index + match[0].length)
1334
+ ? "\r\n"
1335
+ : source[match.index + match[0].length] === "\n"
1336
+ ? "\n"
1337
+ : "";
1338
+ return `${source.slice(0, match.index)}${source.slice(match.index + match[0].length + lineBreak.length)}`;
1339
+ }
1340
+ const indent = match[1].match(/\n([\t ]+)/)?.[1] ?? "\t";
1341
+ const replacement = match[1].includes("\n")
1342
+ ? `import {\n${indent}${remaining.join(`,\n${indent}`)},\n} from "${moduleName}";`
1343
+ : `import { ${remaining.join(", ")} } from "${moduleName}";`;
1344
+ return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
1345
+ }
1346
+
1347
+ function hasIdentifierReferenceOutsideImport(
1348
+ source: string,
1349
+ name: string,
1350
+ moduleName: string,
1351
+ ): boolean {
1352
+ const sourceFile = ts.createSourceFile(
1353
+ "providers.ts",
1354
+ source,
1355
+ ts.ScriptTarget.Latest,
1356
+ true,
1357
+ ts.ScriptKind.TS,
1358
+ );
1359
+ const importDeclaration = sourceFile.statements.find(
1360
+ (statement): statement is ts.ImportDeclaration =>
1361
+ ts.isImportDeclaration(statement) &&
1362
+ ts.isStringLiteral(statement.moduleSpecifier) &&
1363
+ statement.moduleSpecifier.text === moduleName &&
1364
+ Boolean(
1365
+ statement.importClause?.namedBindings &&
1366
+ ts.isNamedImports(statement.importClause.namedBindings) &&
1367
+ statement.importClause.namedBindings.elements.some(
1368
+ (element) => !element.propertyName && element.name.text === name,
1369
+ ),
1370
+ ),
1371
+ );
1372
+ if (!importDeclaration) return true;
1373
+
1374
+ let referenced = false;
1375
+ const visit = (node: ts.Node): void => {
1376
+ if (node === importDeclaration || referenced) return;
1377
+ if (ts.isIdentifier(node) && node.text === name) {
1378
+ referenced = true;
1379
+ return;
1380
+ }
1381
+ ts.forEachChild(node, visit);
1382
+ };
1383
+ ts.forEachChild(sourceFile, visit);
1384
+ return referenced;
1385
+ }
1386
+
1203
1387
  function assertNoConflictingProviders(
1204
1388
  source: string,
1205
1389
  preset: ProviderPreset,