@beignet/cli 0.0.6 → 0.0.8

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/src/make.ts CHANGED
@@ -10,6 +10,15 @@ import {
10
10
  type ResolvedBeignetConfig,
11
11
  resolveConfig,
12
12
  } from "./config.js";
13
+ import {
14
+ appendToArrayExpression,
15
+ appendToNamedArray,
16
+ appendToOutboxRegistryArray,
17
+ arrayInitializerInfo,
18
+ identifiersFromArrayExpression,
19
+ insertAfterImports,
20
+ matchingDelimiterIndex,
21
+ } from "./registry-edits.js";
13
22
 
14
23
  type MakeResourceOptions = {
15
24
  name: string;
@@ -450,6 +459,21 @@ async function makeResourceSlice(
450
459
  generationOptions,
451
460
  },
452
461
  );
462
+ // Generated event-publishing use cases depend on ctx.ports.eventBus, so
463
+ // --events also wires the port, a deferred key, the in-memory dev provider,
464
+ // and the provider dependency. Plan the wiring before writing any files so
465
+ // an anchor miss aborts the generator without leaving partial output.
466
+ const eventBusWirings = generationOptions.events
467
+ ? [eventBusPortWiring("make resource --events")]
468
+ : [];
469
+ const plannedEventBusUpdates =
470
+ eventBusWirings.length > 0
471
+ ? (
472
+ await wirePortProviders(targetDir, config, eventBusWirings, {
473
+ dryRun: true,
474
+ })
475
+ ).updatedFiles
476
+ : [];
453
477
  const createdFiles = plannedFiles
454
478
  .filter((file) => file.result === "created")
455
479
  .map((file) => file.path);
@@ -461,7 +485,7 @@ async function makeResourceSlice(
461
485
  .map((file) => file.path);
462
486
 
463
487
  if (options.dryRun) {
464
- updatedFiles.push(...plannedWiringUpdates);
488
+ updatedFiles.push(...plannedWiringUpdates, ...plannedEventBusUpdates);
465
489
  } else {
466
490
  for (const file of plannedFiles) {
467
491
  await writePlannedGeneratedFile(targetDir, file);
@@ -475,6 +499,16 @@ async function makeResourceSlice(
475
499
  generationOptions,
476
500
  })),
477
501
  );
502
+
503
+ if (eventBusWirings.length > 0) {
504
+ updatedFiles.push(
505
+ ...(
506
+ await wirePortProviders(targetDir, config, eventBusWirings, {
507
+ dryRun: false,
508
+ })
509
+ ).updatedFiles,
510
+ );
511
+ }
478
512
  }
479
513
 
480
514
  return {
@@ -484,7 +518,7 @@ async function makeResourceSlice(
484
518
  dryRun: Boolean(options.dryRun),
485
519
  files: generatedFiles.map((file) => file.path),
486
520
  createdFiles,
487
- updatedFiles,
521
+ updatedFiles: uniqueStrings(updatedFiles),
488
522
  skippedFiles,
489
523
  };
490
524
  }
@@ -881,7 +915,16 @@ export async function makeEvent(
881
915
 
882
916
  await assertFeatureArtifactApp(targetDir, config, "event");
883
917
 
884
- return makeFeatureArtifact({
918
+ // Events are published through ctx.ports.eventBus, so the generator wires
919
+ // the port and an in-memory dev provider when the app has no bus yet. Plan
920
+ // the wiring before writing any files so an anchor miss aborts the
921
+ // generator without leaving partial output.
922
+ const eventBusWirings = [eventBusPortWiring("make event")];
923
+ await wirePortProviders(targetDir, config, eventBusWirings, {
924
+ dryRun: true,
925
+ });
926
+
927
+ const result = await makeFeatureArtifact({
885
928
  targetDir,
886
929
  config,
887
930
  force: Boolean(options.force),
@@ -893,6 +936,16 @@ export async function makeEvent(
893
936
  "@beignet/core": beignetDependencyVersion,
894
937
  },
895
938
  });
939
+
940
+ await applyOutboxRegistryUpdate(result, targetDir, names, "events", config, {
941
+ dryRun: Boolean(options.dryRun),
942
+ });
943
+
944
+ await applyPortProviderWiring(result, targetDir, config, eventBusWirings, {
945
+ dryRun: Boolean(options.dryRun),
946
+ });
947
+
948
+ return result;
896
949
  }
897
950
 
898
951
  export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
@@ -904,7 +957,7 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
904
957
 
905
958
  await assertFeatureArtifactApp(targetDir, config, "job");
906
959
 
907
- return makeFeatureArtifact({
960
+ const result = await makeFeatureArtifact({
908
961
  targetDir,
909
962
  config,
910
963
  force: Boolean(options.force),
@@ -919,6 +972,12 @@ export async function makeJob(options: MakeJobOptions): Promise<MakeJobResult> {
919
972
  "@beignet/core": beignetDependencyVersion,
920
973
  },
921
974
  });
975
+
976
+ await applyOutboxRegistryUpdate(result, targetDir, names, "jobs", config, {
977
+ dryRun: Boolean(options.dryRun),
978
+ });
979
+
980
+ return result;
922
981
  }
923
982
 
924
983
  export async function makeTask(
@@ -1025,7 +1084,19 @@ export async function makeNotification(
1025
1084
 
1026
1085
  await assertFeatureArtifactApp(targetDir, config, "notification");
1027
1086
 
1028
- return makeFeatureArtifact({
1087
+ // The generated mail channel sends through ctx.ports.mailer and sending
1088
+ // notifications uses ctx.ports.notifications, so the generator wires both
1089
+ // ports with dev-default providers. Each port is wired independently and
1090
+ // skipped when the app already has it, for example when the resend
1091
+ // integration contributed the mailer. Plan the wiring before writing any
1092
+ // files so an anchor miss aborts the generator without leaving partial
1093
+ // output.
1094
+ const notificationWirings = notificationPortWirings("make notification");
1095
+ await wirePortProviders(targetDir, config, notificationWirings, {
1096
+ dryRun: true,
1097
+ });
1098
+
1099
+ const result = await makeFeatureArtifact({
1029
1100
  targetDir,
1030
1101
  config,
1031
1102
  force: Boolean(options.force),
@@ -1044,6 +1115,16 @@ export async function makeNotification(
1044
1115
  "@beignet/core": beignetDependencyVersion,
1045
1116
  },
1046
1117
  });
1118
+
1119
+ await applyPortProviderWiring(
1120
+ result,
1121
+ targetDir,
1122
+ config,
1123
+ notificationWirings,
1124
+ { dryRun: Boolean(options.dryRun) },
1125
+ );
1126
+
1127
+ return result;
1047
1128
  }
1048
1129
 
1049
1130
  export async function makeListener(
@@ -1265,6 +1346,236 @@ async function applyCronSecretWiring(
1265
1346
  };
1266
1347
  }
1267
1348
 
1349
+ /**
1350
+ * A provider-backed app port a generator depends on.
1351
+ *
1352
+ * Generated code that publishes events or sends notifications needs the
1353
+ * matching port in `AppPorts`, the key deferred in `infra/app-ports.ts`, and a
1354
+ * dev-default provider registered in `server/providers.ts`. Wiring is
1355
+ * generation-time by design: the starter stays lean and each generator wires
1356
+ * its own dependencies.
1357
+ */
1358
+ type PortProviderWiring = {
1359
+ /** Generator command named in manual instructions, e.g. "make event". */
1360
+ command: string;
1361
+ /** AppPorts key the provider contributes. */
1362
+ portKey: string;
1363
+ /** Port interface name added to the ports file type imports. */
1364
+ portType: string;
1365
+ /** Module the port interface is imported from. */
1366
+ portTypeModule: string;
1367
+ /** Provider factory registered in the providers array. */
1368
+ providerFactory: string;
1369
+ /** Module the provider factory is imported from. */
1370
+ providerModule: string;
1371
+ /** package.json dependency for providers that live outside @beignet/core. */
1372
+ dependency?: string;
1373
+ };
1374
+
1375
+ function eventBusPortWiring(command: string): PortProviderWiring {
1376
+ return {
1377
+ command,
1378
+ portKey: "eventBus",
1379
+ portType: "EventBusPort",
1380
+ portTypeModule: "@beignet/core/ports",
1381
+ providerFactory: "createInMemoryEventBusProvider",
1382
+ providerModule: "@beignet/provider-event-bus-memory",
1383
+ dependency: "@beignet/provider-event-bus-memory",
1384
+ };
1385
+ }
1386
+
1387
+ function notificationPortWirings(command: string): PortProviderWiring[] {
1388
+ return [
1389
+ {
1390
+ command,
1391
+ portKey: "mailer",
1392
+ portType: "MailerPort",
1393
+ portTypeModule: "@beignet/core/mail",
1394
+ providerFactory: "createMemoryMailerProvider",
1395
+ providerModule: "@beignet/core/mail",
1396
+ },
1397
+ {
1398
+ command,
1399
+ portKey: "notifications",
1400
+ portType: "NotificationPort",
1401
+ portTypeModule: "@beignet/core/notifications",
1402
+ providerFactory: "createInlineNotificationsProvider",
1403
+ providerModule: "@beignet/core/notifications",
1404
+ },
1405
+ ];
1406
+ }
1407
+
1408
+ function providersFilePath(config: ResolvedBeignetConfig): string {
1409
+ return `${directoryPath(path.dirname(config.paths.server))}/providers.ts`;
1410
+ }
1411
+
1412
+ /**
1413
+ * Wire provider-backed app ports a generator depends on.
1414
+ *
1415
+ * Each wiring is independent and skipped silently when its port key already
1416
+ * appears in the ports file, so apps that wired their own adapter (or scaffolds
1417
+ * whose integrations already contribute the port) are left untouched. All
1418
+ * anchors are computed before any file is written; an anchor miss aborts with a
1419
+ * copy-pasteable manual instruction so the generator never leaves partial
1420
+ * wiring behind. Callers validate anchors with `dryRun: true` before writing
1421
+ * artifact files.
1422
+ */
1423
+ async function wirePortProviders(
1424
+ targetDir: string,
1425
+ config: ResolvedBeignetConfig,
1426
+ wirings: readonly PortProviderWiring[],
1427
+ options: { dryRun: boolean },
1428
+ ): Promise<{ updatedFiles: string[] }> {
1429
+ const portsFile = config.paths.ports;
1430
+ const infrastructureFile = config.paths.infrastructurePorts;
1431
+ const providersFile = providersFilePath(config);
1432
+ const manualFor = (wiring: PortProviderWiring) =>
1433
+ `Wire the ${wiring.portKey} port manually: add ${wiring.portKey}: ${wiring.portType}; to AppPorts in ${portsFile}, defer "${wiring.portKey}" in ${infrastructureFile}, and register ${wiring.providerFactory}() from ${wiring.providerModule} in ${providersFile}.`;
1434
+
1435
+ const portsOriginal = await readOptionalFile(path.join(targetDir, portsFile));
1436
+ if (portsOriginal === undefined) {
1437
+ if (wirings.length === 0) return { updatedFiles: [] };
1438
+ throw new Error(
1439
+ `Could not find the generated ports file ${portsFile}. ${manualFor(wirings[0])}`,
1440
+ );
1441
+ }
1442
+
1443
+ // A port key that already appears in the ports file means the app wired its
1444
+ // own adapter or an integration already contributes the port.
1445
+ const active = wirings.filter(
1446
+ (wiring) => !new RegExp(`\\b${wiring.portKey}\\b`).test(portsOriginal),
1447
+ );
1448
+ if (active.length === 0) return { updatedFiles: [] };
1449
+
1450
+ let portsNext = portsOriginal;
1451
+ for (const wiring of active) {
1452
+ portsNext = addNamedTypeImport(
1453
+ portsNext,
1454
+ wiring.portType,
1455
+ wiring.portTypeModule,
1456
+ );
1457
+ portsNext = insertTypeProperty(
1458
+ portsNext,
1459
+ "AppPorts",
1460
+ `${wiring.portKey}: ${wiring.portType};`,
1461
+ `Could not find AppPorts in ${portsFile}. ${manualFor(wiring)} Or restore the generated ports file before running beignet ${wiring.command}.`,
1462
+ );
1463
+ }
1464
+
1465
+ const infrastructureOriginal = await readOptionalFile(
1466
+ path.join(targetDir, infrastructureFile),
1467
+ );
1468
+ if (infrastructureOriginal === undefined) {
1469
+ throw new Error(
1470
+ `Could not find the generated infrastructure ports file ${infrastructureFile}. ${manualFor(active[0])}`,
1471
+ );
1472
+ }
1473
+
1474
+ let infrastructureNext = infrastructureOriginal;
1475
+ for (const wiring of active) {
1476
+ const appended = appendDeferredPortKey(infrastructureNext, wiring.portKey);
1477
+ if (
1478
+ appended === infrastructureNext &&
1479
+ !new RegExp(`["']${wiring.portKey}["']`).test(infrastructureNext)
1480
+ ) {
1481
+ throw new Error(
1482
+ `Could not find the deferred port list in ${infrastructureFile}. ${manualFor(wiring)} Or restore the generated infrastructure ports file before running beignet ${wiring.command}.`,
1483
+ );
1484
+ }
1485
+ infrastructureNext = appended;
1486
+ }
1487
+
1488
+ const providersOriginal = await readOptionalFile(
1489
+ path.join(targetDir, providersFile),
1490
+ );
1491
+ if (providersOriginal === undefined) {
1492
+ throw new Error(
1493
+ `Could not find the generated providers file ${providersFile}. ${manualFor(active[0])}`,
1494
+ );
1495
+ }
1496
+
1497
+ let providersNext = providersOriginal;
1498
+ for (const wiring of active) {
1499
+ if (new RegExp(`\\b${wiring.providerFactory}\\b`).test(providersNext)) {
1500
+ continue;
1501
+ }
1502
+ const appended = appendToNamedArray(
1503
+ providersNext,
1504
+ "providers",
1505
+ `${wiring.providerFactory}()`,
1506
+ `import { ${wiring.providerFactory} } from "${wiring.providerModule}";`,
1507
+ );
1508
+ if (appended.kind === "missing") {
1509
+ throw new Error(
1510
+ `Could not find the exported providers array in ${providersFile}. ${manualFor(wiring)} Or restore the generated providers file before running beignet ${wiring.command}.`,
1511
+ );
1512
+ }
1513
+ if (appended.kind === "updated") providersNext = appended.source;
1514
+ }
1515
+
1516
+ const dependencies = active.filter(
1517
+ (wiring): wiring is PortProviderWiring & { dependency: string } =>
1518
+ wiring.dependency !== undefined,
1519
+ );
1520
+ let packageJsonNext: string | undefined;
1521
+ if (dependencies.length > 0) {
1522
+ const packageJsonOriginal = await readFile(
1523
+ path.join(targetDir, "package.json"),
1524
+ "utf8",
1525
+ );
1526
+ const packageJson = JSON.parse(packageJsonOriginal) as PackageJsonLike;
1527
+ packageJson.dependencies = packageJson.dependencies ?? {};
1528
+ for (const wiring of dependencies) {
1529
+ packageJson.dependencies[wiring.dependency] ??=
1530
+ beignetDependencyVersion(packageJson);
1531
+ }
1532
+ const next = `${JSON.stringify(packageJson, null, "\t")}\n`;
1533
+ if (next !== packageJsonOriginal) packageJsonNext = next;
1534
+ }
1535
+
1536
+ const plannedFiles: Array<{ path: string; content: string }> = [];
1537
+ if (portsNext !== portsOriginal) {
1538
+ plannedFiles.push({ path: portsFile, content: portsNext });
1539
+ }
1540
+ if (infrastructureNext !== infrastructureOriginal) {
1541
+ plannedFiles.push({
1542
+ path: infrastructureFile,
1543
+ content: infrastructureNext,
1544
+ });
1545
+ }
1546
+ if (providersNext !== providersOriginal) {
1547
+ plannedFiles.push({ path: providersFile, content: providersNext });
1548
+ }
1549
+ if (packageJsonNext !== undefined) {
1550
+ plannedFiles.push({ path: "package.json", content: packageJsonNext });
1551
+ }
1552
+
1553
+ if (!options.dryRun) {
1554
+ for (const file of plannedFiles) {
1555
+ await writeFile(path.join(targetDir, file.path), file.content);
1556
+ }
1557
+ }
1558
+
1559
+ return { updatedFiles: plannedFiles.map((file) => file.path) };
1560
+ }
1561
+
1562
+ /**
1563
+ * Record port provider wiring updates on a make result.
1564
+ */
1565
+ async function applyPortProviderWiring(
1566
+ result: { files: string[]; updatedFiles: string[] },
1567
+ targetDir: string,
1568
+ config: ResolvedBeignetConfig,
1569
+ wirings: readonly PortProviderWiring[],
1570
+ options: { dryRun: boolean },
1571
+ ): Promise<void> {
1572
+ const wired = await wirePortProviders(targetDir, config, wirings, options);
1573
+ for (const file of wired.updatedFiles) {
1574
+ if (!result.updatedFiles.includes(file)) result.updatedFiles.push(file);
1575
+ if (!result.files.includes(file)) result.files.push(file);
1576
+ }
1577
+ }
1578
+
1268
1579
  export async function makeUpload(
1269
1580
  options: MakeUploadOptions,
1270
1581
  ): Promise<MakeUploadResult> {
@@ -2720,65 +3031,6 @@ function trimmedSlice(
2720
3031
  };
2721
3032
  }
2722
3033
 
2723
- function identifiersFromArrayExpression(expression: string): Set<string> {
2724
- const withoutBrackets = expression.replace(/^\[/, "").replace(/\]$/, "");
2725
-
2726
- return new Set(
2727
- Array.from(
2728
- withoutBrackets.matchAll(/\b[A-Za-z_$][\w$]*\b/g),
2729
- ([name]) => name,
2730
- ),
2731
- );
2732
- }
2733
-
2734
- function appendToArrayExpression(expression: string, names: string[]): string {
2735
- const closingBracket = /\]\s*$/.exec(expression);
2736
- if (!closingBracket) return expression;
2737
-
2738
- const beforeClosingBracket = expression.slice(0, closingBracket.index);
2739
- const closingBracketText = expression.slice(closingBracket.index);
2740
- const inner = beforeClosingBracket.replace(/^\[/, "");
2741
- if (!inner.trim()) return `[${names.join(", ")}]`;
2742
-
2743
- if (!expression.includes("\n")) {
2744
- const separator = /,\s*$/.test(inner) ? " " : ", ";
2745
- return `${beforeClosingBracket}${separator}${names.join(", ")}${closingBracketText}`;
2746
- }
2747
-
2748
- const itemIndent =
2749
- inner
2750
- .split("\n")
2751
- .find((line) => line.trim())
2752
- ?.match(/^[\t ]*/)?.[0] ?? "\t";
2753
- const closingIndent = inner.match(/\n([\t ]*)$/)?.[1] ?? "";
2754
- const trimmedBeforeClosingBracket = beforeClosingBracket.replace(/\s*$/, "");
2755
- const appendedNames = names.join(`,\n${itemIndent}`);
2756
-
2757
- if (/,\s*$/.test(inner)) {
2758
- return `${trimmedBeforeClosingBracket}\n${itemIndent}${appendedNames},\n${closingIndent}${closingBracketText}`;
2759
- }
2760
-
2761
- return `${trimmedBeforeClosingBracket},\n${itemIndent}${appendedNames}${closingBracketText}`;
2762
- }
2763
-
2764
- function insertAfterImports(source: string, line: string): string {
2765
- const lines = source.split("\n");
2766
- let lastImportIndex = -1;
2767
-
2768
- for (let index = 0; index < lines.length; index++) {
2769
- if (lines[index].startsWith("import ")) {
2770
- lastImportIndex = index;
2771
- }
2772
- }
2773
-
2774
- if (lastImportIndex === -1) {
2775
- return `${line}\n${source}`;
2776
- }
2777
-
2778
- lines.splice(lastImportIndex + 1, 0, line);
2779
- return lines.join("\n");
2780
- }
2781
-
2782
3034
  function replaceRequired(
2783
3035
  source: string,
2784
3036
  searchValue: RegExp,
@@ -3059,6 +3311,50 @@ function appendDeferredPortKey(source: string, portKey: string): string {
3059
3311
  )}`;
3060
3312
  }
3061
3313
 
3314
+ /**
3315
+ * Add a name to an `import type { ... } from "<module>";` statement, creating
3316
+ * the import when the module is not imported yet. Names are inserted in sorted
3317
+ * position and single-line versus multiline formatting is preserved.
3318
+ */
3319
+ function addNamedTypeImport(
3320
+ source: string,
3321
+ name: string,
3322
+ moduleName: string,
3323
+ ): string {
3324
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3325
+ const importPattern = new RegExp(
3326
+ `import type \\{([^}]*)\\} from "${escapedModule}";`,
3327
+ );
3328
+ const match = importPattern.exec(source);
3329
+ if (!match) {
3330
+ return insertAfterImports(
3331
+ source,
3332
+ `import type { ${name} } from "${moduleName}";`,
3333
+ );
3334
+ }
3335
+
3336
+ const names = match[1]
3337
+ .split(",")
3338
+ .map((entry) => entry.trim())
3339
+ .filter(Boolean);
3340
+ if (names.includes(name)) return source;
3341
+
3342
+ const insertIndex = names.findIndex(
3343
+ (existing) => existing.localeCompare(name) > 0,
3344
+ );
3345
+ if (insertIndex === -1) names.push(name);
3346
+ else names.splice(insertIndex, 0, name);
3347
+
3348
+ const indent = match[1].match(/\n([\t ]+)/)?.[1] ?? "\t";
3349
+ const replacement = match[1].includes("\n")
3350
+ ? `import type {\n${indent}${names.join(`,\n${indent}`)},\n} from "${moduleName}";`
3351
+ : `import type { ${names.join(", ")} } from "${moduleName}";`;
3352
+
3353
+ return `${source.slice(0, match.index)}${replacement}${source.slice(
3354
+ match.index + match[0].length,
3355
+ )}`;
3356
+ }
3357
+
3062
3358
  function topLevelBoundObjectRange(
3063
3359
  source: string,
3064
3360
  openBrace: number,
@@ -3280,48 +3576,6 @@ function matchingBraceIndex(source: string, openBrace: number): number {
3280
3576
  return matchingDelimiterIndex(source, openBrace, "{", "}");
3281
3577
  }
3282
3578
 
3283
- function matchingDelimiterIndex(
3284
- source: string,
3285
- openIndex: number,
3286
- open: string,
3287
- close: string,
3288
- ): number {
3289
- let depth = 0;
3290
- let inString: string | undefined;
3291
- let escaped = false;
3292
-
3293
- for (let index = openIndex; index < source.length; index++) {
3294
- const char = source[index];
3295
- if (inString) {
3296
- if (escaped) {
3297
- escaped = false;
3298
- } else if (char === "\\") {
3299
- escaped = true;
3300
- } else if (char === inString) {
3301
- inString = undefined;
3302
- }
3303
- continue;
3304
- }
3305
-
3306
- if (char === '"' || char === "'" || char === "`") {
3307
- inString = char;
3308
- continue;
3309
- }
3310
-
3311
- if (char === open) {
3312
- depth++;
3313
- continue;
3314
- }
3315
-
3316
- if (char === close) {
3317
- depth--;
3318
- if (depth === 0) return index;
3319
- }
3320
- }
3321
-
3322
- return -1;
3323
- }
3324
-
3325
3579
  function replaceTransactionPortsRequired(
3326
3580
  source: string,
3327
3581
  entry: string,
@@ -4932,24 +5186,82 @@ export async function stopScheduleContext(): Promise<void> {
4932
5186
  return "updated";
4933
5187
  }
4934
5188
 
4935
- function arrayInitializerInfo(
4936
- source: string,
4937
- constName: string,
4938
- ): { text: string; start: number; end: number } | undefined {
4939
- const match = new RegExp(`\\bconst\\s+${constName}\\s*=`).exec(source);
4940
- if (!match) return undefined;
5189
+ /**
5190
+ * Append a feature event or job registry to `server/outbox.ts`.
5191
+ *
5192
+ * The outbox registry is opt-in, so this never creates the file; it returns
5193
+ * `undefined` when the app has no outbox module. It also bails to "skipped"
5194
+ * instead of writing when the `defineOutboxRegistry({...})` anchor is missing
5195
+ * or the matching array is not editable.
5196
+ */
5197
+ async function updateOutboxRegistry(
5198
+ targetDir: string,
5199
+ names: EventNames | JobNames,
5200
+ kind: "events" | "jobs",
5201
+ config: ResolvedBeignetConfig,
5202
+ options: { dryRun: boolean },
5203
+ ): Promise<WriteGeneratedFileResult | undefined> {
5204
+ const destination = path.join(targetDir, config.paths.outbox);
5205
+ const existing = await readOptionalFile(destination);
5206
+ if (existing === undefined) return undefined;
4941
5207
 
4942
- const openBracket = source.indexOf("[", match.index);
4943
- if (openBracket === -1) return undefined;
5208
+ const featureIndexPath = path.join(
5209
+ featureArtifactDir(names, kind, config),
5210
+ "index.ts",
5211
+ );
5212
+ const registryName = `${names.featureSingularCamel}${
5213
+ kind === "events" ? "Events" : "Jobs"
5214
+ }`;
5215
+ const importLine = `import { ${registryName} } from "${aliasModule(featureIndexPath)}";`;
5216
+ const appended = appendToOutboxRegistryArray(
5217
+ existing,
5218
+ kind,
5219
+ `...${registryName}`,
5220
+ importLine,
5221
+ );
4944
5222
 
4945
- const closeBracket = matchingDelimiterIndex(source, openBracket, "[", "]");
4946
- if (closeBracket === -1) return undefined;
5223
+ if (appended.kind === "missing" || appended.kind === "unchanged") {
5224
+ return "skipped";
5225
+ }
4947
5226
 
4948
- return {
4949
- text: source.slice(openBracket, closeBracket + 1),
4950
- start: openBracket,
4951
- end: closeBracket + 1,
4952
- };
5227
+ if (!options.dryRun) await writeFile(destination, appended.source);
5228
+ return "updated";
5229
+ }
5230
+
5231
+ /**
5232
+ * Record an outbox registry update on a make result using the same
5233
+ * bookkeeping as the task and schedule registries. No-op when the app has no
5234
+ * outbox module.
5235
+ */
5236
+ async function applyOutboxRegistryUpdate(
5237
+ result: {
5238
+ files: string[];
5239
+ createdFiles: string[];
5240
+ updatedFiles: string[];
5241
+ skippedFiles: string[];
5242
+ },
5243
+ targetDir: string,
5244
+ names: EventNames | JobNames,
5245
+ kind: "events" | "jobs",
5246
+ config: ResolvedBeignetConfig,
5247
+ options: { dryRun: boolean },
5248
+ ): Promise<void> {
5249
+ const registryResult = await updateOutboxRegistry(
5250
+ targetDir,
5251
+ names,
5252
+ kind,
5253
+ config,
5254
+ options,
5255
+ );
5256
+ if (registryResult === undefined) return;
5257
+
5258
+ if (registryResult === "updated")
5259
+ result.updatedFiles.push(config.paths.outbox);
5260
+ if (registryResult === "skipped")
5261
+ result.skippedFiles.push(config.paths.outbox);
5262
+ if (!result.files.includes(config.paths.outbox)) {
5263
+ result.files.push(config.paths.outbox);
5264
+ }
4953
5265
  }
4954
5266
 
4955
5267
  function defineUploadsInitializerInfo(