@beignet/cli 0.0.39 → 0.0.40

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beignet/cli",
3
- "version": "0.0.39",
3
+ "version": "0.0.40",
4
4
  "type": "module",
5
5
  "description": "CLI for creating and maintaining Beignet apps.",
6
6
  "main": "./dist/lib.js",
@@ -69,7 +69,7 @@
69
69
  "tsx": "^4.22.4"
70
70
  },
71
71
  "dependencies": {
72
- "@beignet/core": "^0.0.39",
72
+ "@beignet/core": "^0.0.40",
73
73
  "@clack/prompts": "^1.5.1",
74
74
  "@modelcontextprotocol/sdk": "^1.29.0",
75
75
  "@stricli/core": "^1.2.7",
@@ -41,6 +41,7 @@ beignet make job billing.sync
41
41
  beignet make schedule billing.nightly --cron "0 2 * * *"
42
42
  beignet make task admin.backfill
43
43
  beignet make seed demo.users
44
+ beignet make outbox
44
45
  beignet make upload issues.attachment
45
46
  beignet make upload issues.attachment --ui
46
47
  ```
@@ -75,6 +76,12 @@ Hand-written files do not run until registered:
75
76
  every job worker or outbox registry that can receive it
76
77
  - seeds -> the app-owned seed entrypoint, usually `server/seed.ts`
77
78
 
79
+ For generated Next.js 15.1+ apps, `make outbox` also adds a server-local
80
+ provider that wraps the database Unit of Work with a push-assisted `after()`
81
+ trigger. Keep the generated cron route: it is the recovery path for missed
82
+ callbacks, delayed messages, retries, and additional batches. Older Next.js
83
+ apps remain cron-only.
84
+
78
85
  When an app opts into runtime integrity, keep `server/runtime-integrity.ts`
79
86
  aligned with those central registries. The boot check is pure and
80
87
  serverless-safe: it compares declared listeners, schedules, tasks, and outbox
@@ -89,7 +96,11 @@ low-risk route-group, schedule, task, outbox, and listener registry repair.
89
96
 
90
97
  ## Path Config
91
98
 
92
- `beignet.config.*` moves paths; it does not change architecture.
99
+ `beignet.config.*` selects the inspected server adapter and moves paths; it
100
+ does not change the application architecture. `framework: "next"` is the
101
+ default. Use `framework: "web"` for `@beignet/web` Fetch servers so `routes`
102
+ and `doctor` inspect the central `defineRoutes(...)` registry instead of
103
+ requiring Next.js `app/api/` handlers.
93
104
 
94
105
  Common `src/` apps configure:
95
106
 
@@ -107,6 +118,10 @@ export default defineConfig({
107
118
  });
108
119
  ```
109
120
 
121
+ Framework-neutral generators work in either profile. `make payments`,
122
+ `make upload`, `make outbox`, and `make schedule --route` still generate
123
+ Next.js handlers and reject the web profile before writing files.
124
+
110
125
  When `appContext` moves under `src/`, root client setup follows that source
111
126
  root at `src/client/index.ts`. Other registries and app-bound builders are not
112
127
  relocated implicitly; override each path the app has moved, such as
package/src/config.ts CHANGED
@@ -6,7 +6,7 @@ import { createJiti } from "jiti";
6
6
  /**
7
7
  * Supported application framework targets for Beignet config.
8
8
  */
9
- export type BeignetFramework = "next";
9
+ export type BeignetFramework = "next" | "web";
10
10
 
11
11
  /**
12
12
  * Configurable app paths used by CLI generators, inspection, linting, and
@@ -306,11 +306,15 @@ function assertConfigObject(
306
306
  throw new Error(`${configFile} must export a Beignet config object.`);
307
307
  }
308
308
 
309
- if (config.framework !== undefined && config.framework !== "next") {
309
+ if (
310
+ config.framework !== undefined &&
311
+ config.framework !== "next" &&
312
+ config.framework !== "web"
313
+ ) {
310
314
  throw new Error(
311
315
  `${configFile} uses unsupported framework "${String(
312
316
  config.framework,
313
- )}". Supported framework: "next".`,
317
+ )}". Supported frameworks: "next", "web".`,
314
318
  );
315
319
  }
316
320
 
@@ -0,0 +1,13 @@
1
+ import type { ResolvedBeignetConfig } from "./config.js";
2
+
3
+ export function assertNextFramework(
4
+ config: ResolvedBeignetConfig,
5
+ command: string,
6
+ generatedSurface: string,
7
+ ): void {
8
+ if (config.framework === "next") return;
9
+
10
+ throw new Error(
11
+ `${command} currently supports framework: "next" because it generates ${generatedSurface}. For framework: "web", define the framework-neutral artifact directly and mount its HTTP entrypoint through @beignet/web.`,
12
+ );
13
+ }
package/src/inspect.ts CHANGED
@@ -105,7 +105,7 @@ type InspectedCatalogErrorRef = {
105
105
  export type InspectedRoute = InspectedContract & {
106
106
  handlerFile?: string;
107
107
  handlerExport?: HttpMethod;
108
- handlerSource: "next-route" | "route-local" | "missing";
108
+ handlerSource: "next-route" | "route-local" | "web-server" | "missing";
109
109
  };
110
110
 
111
111
  /**
@@ -132,11 +132,13 @@ export type InspectFix = {
132
132
  * Detected app convention and missing convention files.
133
133
  */
134
134
  export type InspectConvention = {
135
- kind: "standard" | "next" | "custom";
135
+ kind: "standard" | "next" | "web" | "custom";
136
136
  nextLayout: boolean;
137
+ webLayout: boolean;
137
138
  resourceGenerator: boolean;
138
139
  configFile?: string;
139
140
  missingNextLayout: string[];
141
+ missingWebLayout: string[];
140
142
  missingResourceGenerator: string[];
141
143
  };
142
144
 
@@ -204,13 +206,12 @@ export async function inspectApp(
204
206
  : await loadBeignetConfig(targetDir, files);
205
207
  const convention = inspectConvention(files, config);
206
208
  const contracts = await readContracts(targetDir, files, config);
207
- const routeFiles = files.filter(
208
- (file) =>
209
- file.startsWith(`${directoryPath(config.paths.routes)}/`) &&
210
- file.endsWith("/route.ts"),
209
+ const matchedRoutes = await inspectRouteSurface(
210
+ targetDir,
211
+ files,
212
+ config,
213
+ contracts,
211
214
  );
212
- const routeExports = await readRouteExports(targetDir, routeFiles, config);
213
- const matchedRoutes = matchRoutes(contracts, routeExports);
214
215
  const diagnostics = await inspectDiagnostics(
215
216
  targetDir,
216
217
  files,
@@ -246,13 +247,12 @@ export async function applyDoctorFixes(
246
247
  : await loadBeignetConfig(targetDir, files);
247
248
  const convention = inspectConvention(files, config);
248
249
  const contracts = await readContracts(targetDir, files, config);
249
- const routeFiles = files.filter(
250
- (file) =>
251
- file.startsWith(`${directoryPath(config.paths.routes)}/`) &&
252
- file.endsWith("/route.ts"),
250
+ const matchedRoutes = await inspectRouteSurface(
251
+ targetDir,
252
+ files,
253
+ config,
254
+ contracts,
253
255
  );
254
- const routeExports = await readRouteExports(targetDir, routeFiles, config);
255
- const matchedRoutes = matchRoutes(contracts, routeExports);
256
256
  const fixes: InspectFix[] = [];
257
257
 
258
258
  const packageFix = await fixPackageScripts(targetDir, files, convention);
@@ -318,7 +318,9 @@ export function formatRoutes(result: InspectAppResult): string {
318
318
  handler:
319
319
  route.handlerSource === "missing"
320
320
  ? "missing"
321
- : `${route.handlerFile ?? "unknown"}:${route.handlerExport ?? route.method}`,
321
+ : route.handlerSource === "web-server"
322
+ ? `${route.handlerFile ?? "unknown"}:registry`
323
+ : `${route.handlerFile ?? "unknown"}:${route.handlerExport ?? route.method}`,
322
324
  }));
323
325
 
324
326
  return table([
@@ -556,6 +558,10 @@ function inspectConvention(
556
558
  `${directoryPath(config.paths.routes)}/`,
557
559
  config.paths.server,
558
560
  ]);
561
+ const missingWebLayout = missingRequirements(files, [
562
+ `${directoryPath(config.paths.contracts)}/`,
563
+ config.paths.server,
564
+ ]);
559
565
  const missingResourceGenerator = missingRequirements(files, [
560
566
  config.paths.appContext,
561
567
  config.paths.portWiring,
@@ -564,14 +570,23 @@ function inspectConvention(
564
570
  config.paths.useCaseBuilder,
565
571
  ]);
566
572
  const nextLayout = missingNextLayout.length === 0;
573
+ const webLayout = missingWebLayout.length === 0;
567
574
  const resourceGenerator = missingResourceGenerator.length === 0;
568
575
 
569
576
  return {
570
- kind: resourceGenerator ? "standard" : nextLayout ? "next" : "custom",
577
+ kind: resourceGenerator
578
+ ? "standard"
579
+ : config.framework === "web" && webLayout
580
+ ? "web"
581
+ : nextLayout
582
+ ? "next"
583
+ : "custom",
571
584
  nextLayout,
585
+ webLayout,
572
586
  resourceGenerator,
573
587
  configFile: config.configFile,
574
588
  missingNextLayout,
589
+ missingWebLayout,
575
590
  missingResourceGenerator,
576
591
  };
577
592
  }
@@ -1289,6 +1304,73 @@ function matchRoutes(
1289
1304
  };
1290
1305
  }
1291
1306
 
1307
+ async function inspectRouteSurface(
1308
+ targetDir: string,
1309
+ files: string[],
1310
+ config: ResolvedBeignetConfig,
1311
+ contracts: InspectedContract[],
1312
+ ): Promise<MatchedRoutesResult> {
1313
+ if (config.framework === "next") {
1314
+ const routeFiles = files.filter(
1315
+ (file) =>
1316
+ file.startsWith(`${directoryPath(config.paths.routes)}/`) &&
1317
+ file.endsWith("/route.ts"),
1318
+ );
1319
+ const routeExports = await readRouteExports(targetDir, routeFiles, config);
1320
+ return matchRoutes(contracts, routeExports);
1321
+ }
1322
+
1323
+ if (!files.includes(config.paths.server)) {
1324
+ return {
1325
+ routes: contracts.map((contract) => ({
1326
+ ...contract,
1327
+ handlerSource: "missing",
1328
+ })),
1329
+ unmatchedRouteHandlers: [],
1330
+ };
1331
+ }
1332
+
1333
+ const serverSource = await readFile(
1334
+ path.join(targetDir, config.paths.server),
1335
+ "utf8",
1336
+ );
1337
+ const routeGroups = await readFeatureRouteGroups(targetDir, files, config);
1338
+ const registeredGroups = await registeredRouteGroupsForServer(
1339
+ targetDir,
1340
+ files,
1341
+ config,
1342
+ serverSource,
1343
+ );
1344
+ const registeredContracts = contractsForRegisteredRouteGroups(
1345
+ contracts,
1346
+ routeGroups,
1347
+ registeredGroups,
1348
+ );
1349
+ const registeredKeys = new Set(registeredContracts.map(contractKey));
1350
+ const handlerFile =
1351
+ routeRegistryFileFromServerSource(
1352
+ serverSource,
1353
+ config.paths.server,
1354
+ files,
1355
+ ) ?? config.paths.server;
1356
+
1357
+ return {
1358
+ routes: contracts.map((contract) =>
1359
+ registeredKeys.has(contractKey(contract))
1360
+ ? {
1361
+ ...contract,
1362
+ handlerFile,
1363
+ handlerSource: "web-server" as const,
1364
+ }
1365
+ : {
1366
+ ...contract,
1367
+ handlerSource: "missing" as const,
1368
+ },
1369
+ ),
1370
+ unmatchedRouteHandlers: [],
1371
+ };
1372
+ }
1373
+
1292
1374
  function findContract(
1293
1375
  contracts: InspectedContract[],
1294
1376
  routeExport: RouteExport,
@@ -1349,28 +1431,53 @@ async function inspectDiagnostics(
1349
1431
  strict: boolean,
1350
1432
  ): Promise<InspectDiagnostic[]> {
1351
1433
  const diagnostics: InspectDiagnostic[] = [];
1352
-
1353
- if (!convention.nextLayout) {
1434
+ const frameworkLayout =
1435
+ config.framework === "next" ? convention.nextLayout : convention.webLayout;
1436
+ const missingFrameworkLayout =
1437
+ config.framework === "next"
1438
+ ? convention.missingNextLayout
1439
+ : convention.missingWebLayout;
1440
+
1441
+ if (!frameworkLayout) {
1442
+ const expectedLayout =
1443
+ config.framework === "next"
1444
+ ? `${directoryPath(config.paths.contracts)}/, ${directoryPath(config.paths.routes)}/, and ${config.paths.server}`
1445
+ : `${directoryPath(config.paths.contracts)}/ and ${config.paths.server}`;
1446
+ const layoutName =
1447
+ config.framework === "next"
1448
+ ? "standard Beignet app layout"
1449
+ : "standard Beignet web app layout";
1354
1450
  diagnostics.push({
1355
1451
  severity: "warning",
1356
1452
  code: "BEIGNET_APP_LAYOUT_NOT_FOUND",
1357
- message: `This directory does not match the standard Beignet app layout. CLI inspection expects ${directoryPath(config.paths.contracts)}/, ${directoryPath(config.paths.routes)}/, and ${config.paths.server}. Missing: ${convention.missingNextLayout.join(", ")}. Add the missing app files or configure their paths in beignet.config.ts.`,
1453
+ message: `This directory does not match the ${layoutName}. CLI inspection expects ${expectedLayout}. Missing: ${missingFrameworkLayout.join(", ")}. Add the missing app files or configure their paths in beignet.config.ts.`,
1358
1454
  });
1359
1455
  }
1360
1456
 
1361
- for (const route of matchedRoutes.routes) {
1362
- if (route.handlerSource !== "missing") continue;
1363
- diagnostics.push({
1364
- severity: "error",
1365
- code: "BEIGNET_ROUTE_MISSING",
1366
- file: route.file,
1367
- contract: route.exportName,
1368
- message: `${route.exportName} (${route.method} ${route.path}) has no matching Next route handler. Add ${methodArticle(route.method)} ${route.method} export to ${directoryPath(config.paths.routes)}/[[...path]]/route.ts or a matching route file, or remove the unused contract.`,
1369
- });
1457
+ diagnostics.push(
1458
+ ...(await inspectFrameworkAdapter(targetDir, files, config)),
1459
+ );
1460
+
1461
+ if (config.framework === "next" || !convention.resourceGenerator) {
1462
+ for (const route of matchedRoutes.routes) {
1463
+ if (route.handlerSource !== "missing") continue;
1464
+ diagnostics.push({
1465
+ severity: "error",
1466
+ code: "BEIGNET_ROUTE_MISSING",
1467
+ file: route.file,
1468
+ contract: route.exportName,
1469
+ message:
1470
+ config.framework === "next"
1471
+ ? `${route.exportName} (${route.method} ${route.path}) has no matching Next route handler. Add ${methodArticle(route.method)} ${route.method} export to ${directoryPath(config.paths.routes)}/[[...path]]/route.ts or a matching route file, or remove the unused contract.`
1472
+ : `${route.exportName} (${route.method} ${route.path}) is not registered in the route surface passed to createFetchServer(...). Register the contract through defineRoutes(...) or remove the unused contract.`,
1473
+ });
1474
+ }
1370
1475
  }
1371
1476
 
1372
- for (const routeHandler of matchedRoutes.unmatchedRouteHandlers) {
1373
- diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
1477
+ if (config.framework === "next") {
1478
+ for (const routeHandler of matchedRoutes.unmatchedRouteHandlers) {
1479
+ diagnostics.push(unmatchedRouteDiagnostic(routeHandler));
1480
+ }
1374
1481
  }
1375
1482
 
1376
1483
  diagnostics.push(
@@ -1460,6 +1567,39 @@ async function inspectDiagnostics(
1460
1567
  return dedupeDiagnostics(diagnostics);
1461
1568
  }
1462
1569
 
1570
+ async function inspectFrameworkAdapter(
1571
+ targetDir: string,
1572
+ files: string[],
1573
+ config: ResolvedBeignetConfig,
1574
+ ): Promise<InspectDiagnostic[]> {
1575
+ if (!files.includes(config.paths.server)) return [];
1576
+
1577
+ const source = await readFile(
1578
+ path.join(targetDir, config.paths.server),
1579
+ "utf8",
1580
+ );
1581
+ const expectedFactory =
1582
+ config.framework === "next" ? "createNextServer" : "createFetchServer";
1583
+ const otherFactory =
1584
+ config.framework === "next" ? "createFetchServer" : "createNextServer";
1585
+
1586
+ if (
1587
+ containsCallExpression(source, expectedFactory) ||
1588
+ !containsCallExpression(source, otherFactory)
1589
+ ) {
1590
+ return [];
1591
+ }
1592
+
1593
+ return [
1594
+ {
1595
+ severity: "error",
1596
+ code: "BEIGNET_FRAMEWORK_MISMATCH",
1597
+ file: config.paths.server,
1598
+ message: `${config.configFile ?? "beignet.config.*"} selects framework: "${config.framework}", but ${config.paths.server} calls ${otherFactory}(...). Use ${expectedFactory}(...) or update the configured framework profile.`,
1599
+ },
1600
+ ];
1601
+ }
1602
+
1463
1603
  async function inspectCanonicalConformance(
1464
1604
  targetDir: string,
1465
1605
  files: string[],
@@ -1626,11 +1766,13 @@ async function inspectCanonicalConformance(
1626
1766
  }
1627
1767
 
1628
1768
  if (!importsAppContext(serverSource, config.paths.server, config, files)) {
1769
+ const serverFactory =
1770
+ config.framework === "next" ? "createNextServer" : "createFetchServer";
1629
1771
  diagnostics.push({
1630
1772
  severity: "warning",
1631
1773
  code: "BEIGNET_SERVER_APP_CONTEXT",
1632
1774
  file: config.paths.server,
1633
- message: `${config.paths.server} should import type { AppContext } from ${config.paths.appContext} and thread that context through createNextServer and hooks.`,
1775
+ message: `${config.paths.server} should import type { AppContext } from ${config.paths.appContext} and thread that context through ${serverFactory} and hooks.`,
1634
1776
  });
1635
1777
  }
1636
1778
 
@@ -2333,7 +2475,7 @@ async function inspectProductionReadiness(
2333
2475
  }
2334
2476
 
2335
2477
  if (installedPackages.has("@beignet/provider-auth-better-auth")) {
2336
- if (!hasBetterAuthRoute(files, config)) {
2478
+ if (config.framework === "next" && !hasBetterAuthRoute(files, config)) {
2337
2479
  diagnostics.push({
2338
2480
  severity: "warning",
2339
2481
  code: "BEIGNET_AUTH_ROUTE_MISSING",
@@ -2589,7 +2731,10 @@ async function inspectPaymentsProductionReadiness(
2589
2731
  });
2590
2732
  }
2591
2733
 
2592
- if (!(await hasPaymentWebhookRoute(targetDir, files, config, sourceCache))) {
2734
+ if (
2735
+ config.framework === "next" &&
2736
+ !(await hasPaymentWebhookRoute(targetDir, files, config, sourceCache))
2737
+ ) {
2593
2738
  diagnostics.push({
2594
2739
  severity: "warning",
2595
2740
  code: "BEIGNET_PAYMENTS_WEBHOOK_ROUTE_MISSING",
@@ -4260,6 +4405,7 @@ async function inspectServerlessFootguns(
4260
4405
  const source = await readFile(path.join(targetDir, file), "utf8");
4261
4406
 
4262
4407
  if (
4408
+ config.framework === "next" &&
4263
4409
  convention.resourceGenerator &&
4264
4410
  isNextRouteFile(file, config) &&
4265
4411
  importsEagerServer(source, file, files, config)
@@ -6461,15 +6607,19 @@ async function registeredRouteGroupsForServer(
6461
6607
  serverSource: string,
6462
6608
  ): Promise<Set<string>> {
6463
6609
  const registeredGroups = registeredRouteGroups(serverSource);
6464
- const imports = parseNamedImports(serverSource, config, config.paths.server);
6610
+ const imports = parseNamedImportSources(serverSource);
6465
6611
 
6466
6612
  for (const identifier of routeOptionIdentifiers(serverSource)) {
6467
6613
  const imported = imports.get(identifier);
6468
- if (!imported?.contractFile || !files.includes(imported.contractFile)) {
6469
- continue;
6470
- }
6614
+ if (!imported) continue;
6615
+ const importedFile = sourceFileFromImport(
6616
+ imported.sourcePath,
6617
+ config.paths.server,
6618
+ files,
6619
+ );
6620
+ if (!importedFile || !files.includes(importedFile)) continue;
6471
6621
 
6472
- const source = await readFile(path.join(targetDir, imported.contractFile), {
6622
+ const source = await readFile(path.join(targetDir, importedFile), {
6473
6623
  encoding: "utf8",
6474
6624
  });
6475
6625
  for (const routeGroup of registeredRouteGroups(source)) {
@@ -7458,6 +7608,7 @@ async function inspectResourceSlices(
7458
7608
  );
7459
7609
  }
7460
7610
  if (
7611
+ config.framework === "next" &&
7461
7612
  !hasRequirement(files, routeFile) &&
7462
7613
  !hasRequirement(files, catchAllRouteFile)
7463
7614
  ) {
@@ -6,6 +6,7 @@ import {
6
6
  type ResolvedBeignetConfig,
7
7
  resolveConfig,
8
8
  } from "../config.js";
9
+ import { assertNextFramework } from "../framework.js";
9
10
  import { appendToNamedArray, insertAfterImports } from "../registry-edits.js";
10
11
  import { testSupportTemplateFiles } from "../templates/testing.js";
11
12
  import {
@@ -71,6 +72,12 @@ export async function makePayments(
71
72
  ? resolveConfig(options.config)
72
73
  : await loadBeignetConfig(targetDir);
73
74
 
75
+ assertNextFramework(
76
+ config,
77
+ "beignet make payments",
78
+ "Next.js App Router billing and webhook handlers",
79
+ );
80
+
74
81
  await assertStandardApp(targetDir, config);
75
82
 
76
83
  const persistence = await detectResourcePersistence(targetDir, config);
package/src/make.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  type ResolvedBeignetConfig,
12
12
  resolveConfig,
13
13
  } from "./config.js";
14
+ import { assertNextFramework } from "./framework.js";
14
15
  import {
15
16
  adapterFilePath,
16
17
  addNamedImport,
@@ -143,6 +144,7 @@ import {
143
144
  } from "./make/shared.js";
144
145
  import {
145
146
  appendToArrayExpression,
147
+ appendToNamedArray,
146
148
  appendToOutboxRegistryArray,
147
149
  arrayInitializerInfo,
148
150
  identifiersFromArrayExpression,
@@ -1499,6 +1501,14 @@ export async function makeSchedule(
1499
1501
  ? resolveConfig(options.config)
1500
1502
  : await loadBeignetConfig(targetDir);
1501
1503
 
1504
+ if (options.route) {
1505
+ assertNextFramework(
1506
+ config,
1507
+ "beignet make schedule --route",
1508
+ "a Next.js App Router cron handler",
1509
+ );
1510
+ }
1511
+
1502
1512
  await assertFeatureArtifactApp(targetDir, config, "schedule", {
1503
1513
  requireServer: Boolean(options.route),
1504
1514
  });
@@ -1714,6 +1724,9 @@ async function updateOutboxPortWiring(
1714
1724
  path.join(infrastructureDir(config), "db/provider.ts"),
1715
1725
  );
1716
1726
  }
1727
+ if (await updateOutboxDrainProvider(targetDir, config, options)) {
1728
+ changes.updatedFiles.push(providersFilePath(config));
1729
+ }
1717
1730
  if (await updateOutboxRepositoriesReturnType(targetDir, config, options)) {
1718
1731
  changes.updatedFiles.push(drizzleRepositoriesPath(config));
1719
1732
  }
@@ -1825,6 +1838,31 @@ async function updateOutboxInfrastructurePorts(
1825
1838
  return true;
1826
1839
  }
1827
1840
 
1841
+ async function projectSupportsNextAfter(targetDir: string): Promise<boolean> {
1842
+ const packageJson = JSON.parse(
1843
+ await readFile(path.join(targetDir, "package.json"), "utf8"),
1844
+ ) as PackageJsonLike;
1845
+ const version =
1846
+ packageJson.dependencies?.next ?? packageJson.devDependencies?.next;
1847
+ if (!version) return false;
1848
+
1849
+ return version.split("||").every((range) => {
1850
+ const lowerBound =
1851
+ /^\s*(\^|~|>=|>|=|<=|<)?\s*v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?([+-][0-9A-Za-z.-]+)?(?=\s|$)/.exec(
1852
+ range,
1853
+ );
1854
+ if (!lowerBound) return false;
1855
+
1856
+ const operator = lowerBound[1];
1857
+ if (operator === "<" || operator === "<=") return false;
1858
+ if (lowerBound[5]?.startsWith("-")) return false;
1859
+
1860
+ const major = Number(lowerBound[2]);
1861
+ const minor = /^\d+$/.test(lowerBound[3] ?? "") ? Number(lowerBound[3]) : 0;
1862
+ return major > 15 || (major === 15 && minor >= 1);
1863
+ });
1864
+ }
1865
+
1828
1866
  async function updateOutboxDatabaseProvider(
1829
1867
  targetDir: string,
1830
1868
  config: ResolvedBeignetConfig,
@@ -1923,6 +1961,95 @@ async function updateOutboxDatabaseProvider(
1923
1961
  return true;
1924
1962
  }
1925
1963
 
1964
+ async function updateOutboxDrainProvider(
1965
+ targetDir: string,
1966
+ config: ResolvedBeignetConfig,
1967
+ options: { dryRun: boolean },
1968
+ ): Promise<boolean> {
1969
+ if (!(await projectSupportsNextAfter(targetDir))) return false;
1970
+
1971
+ const file = providersFilePath(config);
1972
+ const filePath = path.join(targetDir, file);
1973
+ const original = await readOptionalFile(filePath);
1974
+ if (original === undefined) {
1975
+ throw new Error(
1976
+ `Could not find the generated providers file ${file}. Register the outbox drain trigger manually, or restore the generated providers file before running make outbox.`,
1977
+ );
1978
+ }
1979
+
1980
+ let next = addNamedImport(
1981
+ original,
1982
+ "createObservedUnitOfWork",
1983
+ "@beignet/core/ports",
1984
+ );
1985
+ next = addNamedImport(next, "createProvider", "@beignet/core/providers");
1986
+ next = addNamedImport(next, "createNextOutboxDrainTrigger", "@beignet/next");
1987
+ next = addNamedImport(next, "after", "next/server");
1988
+ next = addNamedTypeImport(
1989
+ next,
1990
+ "AppContext",
1991
+ aliasModule(config.paths.appContext),
1992
+ );
1993
+ next = addNamedTypeImport(next, "AppPorts", aliasModule(config.paths.ports));
1994
+ next = addNamedTypeImport(
1995
+ next,
1996
+ "AppServiceContextInput",
1997
+ aliasModule(path.join(path.dirname(config.paths.server), "context.ts")),
1998
+ );
1999
+
2000
+ const providerName = "outboxDrainProvider";
2001
+ if (!new RegExp(`\\bconst\\s+${providerName}\\b`).test(next)) {
2002
+ const outboxModule = aliasModule(config.paths.outbox);
2003
+ const providerDefinition = `const ${providerName} = createProvider<
2004
+ \tPick<AppPorts, "uow">,
2005
+ \tAppContext,
2006
+ \tAppServiceContextInput
2007
+ >()({
2008
+ \tname: "outbox-drain-trigger",
2009
+ \tsetup({ ports, createServiceContext }): { ports: Pick<AppPorts, "uow"> } {
2010
+ \t\tconst triggerOutboxDrain: () => void = createNextOutboxDrainTrigger({
2011
+ \t\t\tdefer: after,
2012
+ \t\t\tcreateContext: () => createServiceContext(undefined),
2013
+ \t\t\tregistry: async () => (await import("${outboxModule}")).outboxRegistry,
2014
+ \t\t\tbatchSize: 100,
2015
+ \t\t});
2016
+
2017
+ \t\treturn {
2018
+ \t\t\tports: {
2019
+ \t\t\t\tuow: createObservedUnitOfWork({
2020
+ \t\t\t\t\tunitOfWork: ports.uow,
2021
+ \t\t\t\t\tafterCommit: triggerOutboxDrain,
2022
+ \t\t\t\t}),
2023
+ \t\t\t},
2024
+ \t\t};
2025
+ \t},
2026
+ });
2027
+ `;
2028
+ const withProvider = next.replace(
2029
+ /\nexport const providers = \[/,
2030
+ `\n${providerDefinition}\nexport const providers = [`,
2031
+ );
2032
+ if (withProvider === next) {
2033
+ throw new Error(
2034
+ `Could not find the exported providers array in ${file}. Register outboxDrainProvider manually, or restore the generated providers file before running make outbox.`,
2035
+ );
2036
+ }
2037
+ next = withProvider;
2038
+ }
2039
+
2040
+ const appended = appendToNamedArray(next, "providers", providerName);
2041
+ if (appended.kind === "missing") {
2042
+ throw new Error(
2043
+ `Could not find the exported providers array in ${file}. Register outboxDrainProvider manually, or restore the generated providers file before running make outbox.`,
2044
+ );
2045
+ }
2046
+ if (appended.kind === "updated") next = appended.source;
2047
+
2048
+ if (next === original) return false;
2049
+ if (!options.dryRun) await writeFile(filePath, next);
2050
+ return true;
2051
+ }
2052
+
1926
2053
  async function updateOutboxRepositoriesReturnType(
1927
2054
  targetDir: string,
1928
2055
  config: ResolvedBeignetConfig,
@@ -2068,6 +2195,12 @@ export async function makeUpload(
2068
2195
  ? resolveConfig(options.config)
2069
2196
  : await loadBeignetConfig(targetDir);
2070
2197
 
2198
+ assertNextFramework(
2199
+ config,
2200
+ "beignet make upload",
2201
+ "a Next.js App Router upload handler",
2202
+ );
2203
+
2071
2204
  await assertFeatureArtifactApp(targetDir, config, "upload");
2072
2205
  if (options.ui) {
2073
2206
  await assertUploadUiApp(targetDir, config);
@@ -2168,6 +2301,12 @@ export async function makeOutbox(
2168
2301
  ? resolveConfig(options.config)
2169
2302
  : await loadBeignetConfig(targetDir);
2170
2303
 
2304
+ assertNextFramework(
2305
+ config,
2306
+ "beignet make outbox",
2307
+ "a Next.js App Router recovery drain handler",
2308
+ );
2309
+
2171
2310
  await assertServerRuntimeApp(targetDir, config, "outbox");
2172
2311
  await updateOutboxPortWiring(targetDir, config, { dryRun: true });
2173
2312
 
@@ -2219,6 +2358,9 @@ export async function makeOutbox(
2219
2358
  config.paths.ports,
2220
2359
  config.paths.portWiring,
2221
2360
  path.join(infrastructureDir(config), "db/provider.ts"),
2361
+ ...((await projectSupportsNextAfter(targetDir))
2362
+ ? [providersFilePath(config)]
2363
+ : []),
2222
2364
  drizzleRepositoriesPath(config),
2223
2365
  outboxDrizzleSchemaFilePath(config),
2224
2366
  drizzleSchemaIndexPath(config),