@beignet/cli 0.0.32 → 0.0.33

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 (89) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +26 -16
  3. package/dist/choices.d.ts +3 -3
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +2 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/config.d.ts +1 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/db.js +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +279 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/inspect.d.ts.map +1 -1
  16. package/dist/inspect.js +797 -58
  17. package/dist/inspect.js.map +1 -1
  18. package/dist/make/inbox.d.ts.map +1 -1
  19. package/dist/make/inbox.js +5 -3
  20. package/dist/make/inbox.js.map +1 -1
  21. package/dist/make/payments.d.ts.map +1 -1
  22. package/dist/make/payments.js +58 -29
  23. package/dist/make/payments.js.map +1 -1
  24. package/dist/make/shared.d.ts +11 -2
  25. package/dist/make/shared.d.ts.map +1 -1
  26. package/dist/make/shared.js +44 -10
  27. package/dist/make/shared.js.map +1 -1
  28. package/dist/make/tenancy.d.ts.map +1 -1
  29. package/dist/make/tenancy.js +6 -4
  30. package/dist/make/tenancy.js.map +1 -1
  31. package/dist/make.d.ts.map +1 -1
  32. package/dist/make.js +129 -55
  33. package/dist/make.js.map +1 -1
  34. package/dist/outbox.d.ts +114 -1
  35. package/dist/outbox.d.ts.map +1 -1
  36. package/dist/outbox.js +190 -0
  37. package/dist/outbox.js.map +1 -1
  38. package/dist/preflight.js +1 -1
  39. package/dist/preflight.js.map +1 -1
  40. package/dist/provider-add.d.ts.map +1 -1
  41. package/dist/provider-add.js +46 -0
  42. package/dist/provider-add.js.map +1 -1
  43. package/dist/task.d.ts.map +1 -1
  44. package/dist/task.js +2 -3
  45. package/dist/task.js.map +1 -1
  46. package/dist/templates/agents.d.ts.map +1 -1
  47. package/dist/templates/agents.js +6 -3
  48. package/dist/templates/agents.js.map +1 -1
  49. package/dist/templates/base.js +3 -3
  50. package/dist/templates/base.js.map +1 -1
  51. package/dist/templates/index.d.ts.map +1 -1
  52. package/dist/templates/index.js +2 -0
  53. package/dist/templates/index.js.map +1 -1
  54. package/dist/templates/server.d.ts.map +1 -1
  55. package/dist/templates/server.js +6 -1
  56. package/dist/templates/server.js.map +1 -1
  57. package/dist/templates/shared.d.ts +3 -1
  58. package/dist/templates/shared.d.ts.map +1 -1
  59. package/dist/templates/shared.js +11 -1
  60. package/dist/templates/shared.js.map +1 -1
  61. package/dist/templates/testing.d.ts +5 -0
  62. package/dist/templates/testing.d.ts.map +1 -0
  63. package/dist/templates/testing.js +542 -0
  64. package/dist/templates/testing.js.map +1 -0
  65. package/dist/templates/todos.js +2 -2
  66. package/package.json +3 -2
  67. package/skills/app-structure/SKILL.md +12 -4
  68. package/src/choices.ts +3 -0
  69. package/src/config.ts +2 -0
  70. package/src/db.ts +1 -1
  71. package/src/index.ts +437 -1
  72. package/src/inspect.ts +1247 -154
  73. package/src/make/inbox.ts +5 -3
  74. package/src/make/payments.ts +58 -29
  75. package/src/make/shared.ts +78 -11
  76. package/src/make/tenancy.ts +6 -4
  77. package/src/make.ts +172 -54
  78. package/src/outbox.ts +363 -0
  79. package/src/preflight.ts +1 -1
  80. package/src/provider-add.ts +47 -0
  81. package/src/task.ts +2 -3
  82. package/src/templates/agents.ts +6 -3
  83. package/src/templates/base.ts +3 -3
  84. package/src/templates/index.ts +2 -0
  85. package/src/templates/server.ts +6 -1
  86. package/src/templates/shared.ts +13 -1
  87. package/src/templates/testing.ts +545 -0
  88. package/src/templates/todos.ts +2 -2
  89. package/src/test-helpers/generated-app.ts +10 -6
package/src/outbox.ts CHANGED
@@ -3,6 +3,10 @@ import path from "node:path";
3
3
  import type {
4
4
  DrainOutboxOptions,
5
5
  DrainOutboxResult,
6
+ OutboxAdminPort,
7
+ OutboxMessage,
8
+ OutboxMessageKind,
9
+ OutboxMessageStatus,
6
10
  OutboxRegistry,
7
11
  } from "@beignet/core/outbox";
8
12
  import { createJiti } from "jiti";
@@ -17,6 +21,61 @@ export type RunOutboxDrainOptions = {
17
21
  batchSize?: number;
18
22
  };
19
23
 
24
+ /**
25
+ * Options for listing outbox messages.
26
+ */
27
+ export type RunOutboxListOptions = {
28
+ cwd?: string;
29
+ modulePath?: string;
30
+ status?: OutboxMessageStatus;
31
+ kind?: OutboxMessageKind;
32
+ name?: string;
33
+ limit?: number;
34
+ };
35
+
36
+ /**
37
+ * Options for showing one outbox message.
38
+ */
39
+ export type RunOutboxShowOptions = {
40
+ cwd?: string;
41
+ modulePath?: string;
42
+ id: string;
43
+ };
44
+
45
+ /**
46
+ * Options for requeueing one dead-lettered outbox message.
47
+ */
48
+ export type RunOutboxRequeueOptions = {
49
+ cwd?: string;
50
+ modulePath?: string;
51
+ id: string;
52
+ availableAt?: Date;
53
+ resetAttempts?: boolean;
54
+ };
55
+
56
+ /**
57
+ * Options for purging dead-lettered outbox messages.
58
+ */
59
+ export type RunOutboxPurgeOptions = {
60
+ cwd?: string;
61
+ modulePath?: string;
62
+ before?: Date;
63
+ all?: boolean;
64
+ limit?: number;
65
+ dryRun?: boolean;
66
+ };
67
+
68
+ /**
69
+ * Options for pruning delivered outbox messages.
70
+ */
71
+ export type RunOutboxPruneOptions = {
72
+ cwd?: string;
73
+ modulePath?: string;
74
+ before: Date;
75
+ limit?: number;
76
+ dryRun?: boolean;
77
+ };
78
+
20
79
  /**
21
80
  * Result returned by `beignet outbox drain`.
22
81
  */
@@ -28,6 +87,53 @@ export type RunOutboxDrainResult = {
28
87
  durationMs: number;
29
88
  };
30
89
 
90
+ /**
91
+ * Result returned by `beignet outbox list`.
92
+ */
93
+ export type RunOutboxListResult = {
94
+ schemaVersion: 1;
95
+ cwd: string;
96
+ modulePath: string;
97
+ messages: readonly OutboxMessage[];
98
+ count: number;
99
+ durationMs: number;
100
+ };
101
+
102
+ /**
103
+ * Result returned by `beignet outbox show`.
104
+ */
105
+ export type RunOutboxShowResult = {
106
+ schemaVersion: 1;
107
+ cwd: string;
108
+ modulePath: string;
109
+ message: OutboxMessage;
110
+ durationMs: number;
111
+ };
112
+
113
+ /**
114
+ * Result returned by `beignet outbox requeue`.
115
+ */
116
+ export type RunOutboxRequeueResult = {
117
+ schemaVersion: 1;
118
+ cwd: string;
119
+ modulePath: string;
120
+ message: OutboxMessage;
121
+ durationMs: number;
122
+ };
123
+
124
+ /**
125
+ * Result returned by destructive outbox admin commands.
126
+ */
127
+ export type RunOutboxDeleteResult = {
128
+ schemaVersion: 1;
129
+ cwd: string;
130
+ modulePath: string;
131
+ dryRun: boolean;
132
+ matched: number;
133
+ deleted: number;
134
+ durationMs: number;
135
+ };
136
+
31
137
  type OutboxDrainLogger = {
32
138
  error(message: string, meta?: Record<string, unknown>): unknown;
33
139
  };
@@ -47,6 +153,7 @@ type OutboxDrainInstrumentation = {
47
153
 
48
154
  type OutboxDrainPorts = {
49
155
  outbox: DrainOutboxOptions["outbox"];
156
+ outboxAdmin?: OutboxAdminPort;
50
157
  eventBus?: DrainOutboxOptions["eventBus"];
51
158
  jobs?: DrainOutboxOptions["jobs"];
52
159
  logger?: OutboxDrainLogger;
@@ -65,13 +172,24 @@ type OutboxDrainContextArgs = {
65
172
  batchSize?: number;
66
173
  };
67
174
 
175
+ type OutboxAdminOperation = "list" | "show" | "requeue" | "purge" | "prune";
176
+
177
+ type OutboxAdminContextArgs = {
178
+ operation: OutboxAdminOperation;
179
+ };
180
+
68
181
  type OutboxModule = {
69
182
  outboxRegistry?: unknown;
70
183
  createOutboxDrainContext?: (args: OutboxDrainContextArgs) => unknown;
184
+ createOutboxAdminContext?: (args: OutboxAdminContextArgs) => unknown;
71
185
  stopOutboxDrainContext?: (
72
186
  ctx: OutboxDrainContext,
73
187
  args: OutboxDrainContextArgs,
74
188
  ) => unknown;
189
+ stopOutboxAdminContext?: (
190
+ ctx: OutboxDrainContext,
191
+ args: OutboxAdminContextArgs,
192
+ ) => unknown;
75
193
  };
76
194
 
77
195
  /**
@@ -138,6 +256,190 @@ export async function runOutboxDrain(
138
256
  }
139
257
  }
140
258
 
259
+ /**
260
+ * List outbox messages through the app's outbox admin port.
261
+ */
262
+ export async function runOutboxList(
263
+ options: RunOutboxListOptions = {},
264
+ ): Promise<RunOutboxListResult> {
265
+ return withOutboxAdminContext(options, "list", async ({ admin, base }) => {
266
+ const query = {
267
+ status: options.status,
268
+ kind: options.kind,
269
+ name: options.name,
270
+ };
271
+ const messages = await admin.listMessages({
272
+ ...query,
273
+ limit: options.limit,
274
+ });
275
+ const count = await admin.countMessages(query);
276
+
277
+ return {
278
+ ...base,
279
+ messages,
280
+ count,
281
+ };
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Show one outbox message through the app's outbox admin port.
287
+ */
288
+ export async function runOutboxShow(
289
+ options: RunOutboxShowOptions,
290
+ ): Promise<RunOutboxShowResult> {
291
+ return withOutboxAdminContext(options, "show", async ({ admin, base }) => {
292
+ const message = await admin.getMessage(options.id);
293
+ if (!message) {
294
+ throw new Error(`Outbox message "${options.id}" does not exist.`);
295
+ }
296
+
297
+ return {
298
+ ...base,
299
+ message,
300
+ };
301
+ });
302
+ }
303
+
304
+ /**
305
+ * Requeue one dead-lettered outbox message.
306
+ */
307
+ export async function runOutboxRequeue(
308
+ options: RunOutboxRequeueOptions,
309
+ ): Promise<RunOutboxRequeueResult> {
310
+ return withOutboxAdminContext(options, "requeue", async ({ admin, base }) => {
311
+ const message = await admin.requeueMessage({
312
+ id: options.id,
313
+ availableAt: options.availableAt,
314
+ resetAttempts: options.resetAttempts,
315
+ });
316
+
317
+ return {
318
+ ...base,
319
+ message,
320
+ };
321
+ });
322
+ }
323
+
324
+ /**
325
+ * Purge dead-lettered outbox messages.
326
+ */
327
+ export async function runOutboxPurge(
328
+ options: RunOutboxPurgeOptions = {},
329
+ ): Promise<RunOutboxDeleteResult> {
330
+ if (!options.before && !options.all) {
331
+ throw new Error(
332
+ "Pass --before <date> to purge old dead-lettered messages, or pass --all to purge every dead-lettered message.",
333
+ );
334
+ }
335
+
336
+ return withOutboxAdminContext(options, "purge", async ({ admin, base }) => {
337
+ const query = {
338
+ status: "deadLettered" as const,
339
+ updatedBefore: options.before,
340
+ };
341
+ const matched = await admin.countMessages(query);
342
+ const result = options.dryRun
343
+ ? { deleted: dryRunDeleteCount(matched, options.limit) }
344
+ : await admin.purgeDeadLettered({
345
+ before: options.before,
346
+ limit: options.limit,
347
+ });
348
+
349
+ return {
350
+ ...base,
351
+ dryRun: Boolean(options.dryRun),
352
+ matched,
353
+ deleted: result.deleted,
354
+ };
355
+ });
356
+ }
357
+
358
+ /**
359
+ * Prune delivered outbox messages.
360
+ */
361
+ export async function runOutboxPrune(
362
+ options: RunOutboxPruneOptions,
363
+ ): Promise<RunOutboxDeleteResult> {
364
+ return withOutboxAdminContext(options, "prune", async ({ admin, base }) => {
365
+ const query = {
366
+ status: "delivered" as const,
367
+ deliveredBefore: options.before,
368
+ };
369
+ const matched = await admin.countMessages(query);
370
+ const result = options.dryRun
371
+ ? { deleted: dryRunDeleteCount(matched, options.limit) }
372
+ : await admin.pruneDelivered({
373
+ before: options.before,
374
+ limit: options.limit,
375
+ });
376
+
377
+ return {
378
+ ...base,
379
+ dryRun: Boolean(options.dryRun),
380
+ matched,
381
+ deleted: result.deleted,
382
+ };
383
+ });
384
+ }
385
+
386
+ async function withOutboxAdminContext<T extends object>(
387
+ options: { cwd?: string; modulePath?: string },
388
+ operation: OutboxAdminOperation,
389
+ run: (args: {
390
+ admin: OutboxAdminPort;
391
+ base: {
392
+ schemaVersion: 1;
393
+ cwd: string;
394
+ modulePath: string;
395
+ };
396
+ }) => Promise<T>,
397
+ ): Promise<T & { durationMs: number }> {
398
+ const cwd = path.resolve(options.cwd ?? process.cwd());
399
+ const config = await loadBeignetConfig(cwd);
400
+ const modulePath = normalizePath(options.modulePath ?? config.paths.outbox);
401
+ const startedAt = performance.now();
402
+ const outboxModule = await loadOutboxModule(cwd, modulePath);
403
+ const contextArgs = { operation };
404
+ const rawContext = outboxModule.createOutboxAdminContext
405
+ ? await outboxModule.createOutboxAdminContext(contextArgs)
406
+ : await createFallbackOutboxAdminContext(outboxModule);
407
+ const ctx = assertOutboxAdminContext(rawContext, modulePath);
408
+ const admin = resolveOutboxAdminPort(ctx, modulePath);
409
+
410
+ try {
411
+ const result = await run({
412
+ admin,
413
+ base: {
414
+ schemaVersion: 1,
415
+ cwd,
416
+ modulePath,
417
+ },
418
+ });
419
+ return {
420
+ ...result,
421
+ durationMs: Math.round(performance.now() - startedAt),
422
+ };
423
+ } finally {
424
+ if (outboxModule.stopOutboxAdminContext) {
425
+ await outboxModule.stopOutboxAdminContext(ctx, contextArgs);
426
+ } else {
427
+ await outboxModule.stopOutboxDrainContext?.(ctx, {
428
+ registry: maybeOutboxRegistry(outboxModule.outboxRegistry),
429
+ });
430
+ }
431
+ }
432
+ }
433
+
434
+ async function createFallbackOutboxAdminContext(
435
+ outboxModule: OutboxModule,
436
+ ): Promise<unknown> {
437
+ if (!outboxModule.createOutboxDrainContext) return undefined;
438
+ return outboxModule.createOutboxDrainContext({
439
+ registry: maybeOutboxRegistry(outboxModule.outboxRegistry),
440
+ });
441
+ }
442
+
141
443
  async function loadOutboxModule(
142
444
  cwd: string,
143
445
  modulePath: string,
@@ -197,6 +499,19 @@ function assertOutboxDrainContext(
197
499
  return ctx as OutboxDrainContext;
198
500
  }
199
501
 
502
+ function assertOutboxAdminContext(
503
+ ctx: unknown,
504
+ modulePath: string,
505
+ ): OutboxDrainContext {
506
+ if (!isRecord(ctx) || !isRecord(ctx.ports)) {
507
+ throw new Error(
508
+ `${modulePath} must export createOutboxAdminContext or createOutboxDrainContext that returns an app context with ports.outboxAdmin.`,
509
+ );
510
+ }
511
+
512
+ return ctx as OutboxDrainContext;
513
+ }
514
+
200
515
  function isOutboxPort(value: unknown): value is DrainOutboxOptions["outbox"] {
201
516
  return (
202
517
  isRecord(value) &&
@@ -207,6 +522,48 @@ function isOutboxPort(value: unknown): value is DrainOutboxOptions["outbox"] {
207
522
  );
208
523
  }
209
524
 
525
+ function isOutboxAdminPort(value: unknown): value is OutboxAdminPort {
526
+ return (
527
+ isRecord(value) &&
528
+ typeof value.listMessages === "function" &&
529
+ typeof value.countMessages === "function" &&
530
+ typeof value.getMessage === "function" &&
531
+ typeof value.requeueMessage === "function" &&
532
+ typeof value.purgeDeadLettered === "function" &&
533
+ typeof value.pruneDelivered === "function"
534
+ );
535
+ }
536
+
537
+ function resolveOutboxAdminPort(
538
+ ctx: OutboxDrainContext,
539
+ modulePath: string,
540
+ ): OutboxAdminPort {
541
+ if (isOutboxAdminPort(ctx.ports.outboxAdmin)) return ctx.ports.outboxAdmin;
542
+ if (isOutboxAdminPort(ctx.ports.outbox)) return ctx.ports.outbox;
543
+
544
+ throw new Error(
545
+ `${modulePath} createOutboxAdminContext must return ports.outboxAdmin, or ports.outbox must implement OutboxAdminPort.`,
546
+ );
547
+ }
548
+
549
+ function maybeOutboxRegistry(registry: unknown): OutboxRegistry {
550
+ if (
551
+ isRecord(registry) &&
552
+ registry.events instanceof Map &&
553
+ registry.jobs instanceof Map
554
+ ) {
555
+ return {
556
+ events: registry.events,
557
+ jobs: registry.jobs,
558
+ } as unknown as OutboxRegistry;
559
+ }
560
+
561
+ return {
562
+ events: new Map(),
563
+ jobs: new Map(),
564
+ };
565
+ }
566
+
210
567
  async function recordOutboxDrain(
211
568
  ctx: OutboxDrainContext,
212
569
  result: DrainOutboxResult,
@@ -235,6 +592,12 @@ function assertPositiveInteger(name: string, value: number): void {
235
592
  }
236
593
  }
237
594
 
595
+ function dryRunDeleteCount(matched: number, limit: number | undefined): number {
596
+ if (limit === undefined) return matched;
597
+ assertPositiveInteger("limit", limit);
598
+ return Math.min(matched, limit);
599
+ }
600
+
238
601
  function isRecord(value: unknown): value is Record<string, unknown> {
239
602
  return typeof value === "object" && value !== null;
240
603
  }
package/src/preflight.ts CHANGED
@@ -283,7 +283,7 @@ export async function runPreflight(
283
283
  severity: "note",
284
284
  code: "BEIGNET_PREFLIGHT_MANUAL_REVIEW",
285
285
  message:
286
- "Manual review (no framework primitives yet): CSRF protection, secure headers, and tracing export — see the deployment docs hardening checklist.",
286
+ "Manual review: CSRF policy, CSP/HSTS values, trusted proxy topology, and tracing export — see the deployment docs hardening checklist.",
287
287
  });
288
288
 
289
289
  let envModuleChecked = false;
@@ -455,6 +455,53 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
455
455
  "Run beignet providers audit and beignet doctor --strict.",
456
456
  ],
457
457
  },
458
+ "event-bus-redis": {
459
+ name: "event-bus-redis",
460
+ displayName: "Redis event bus",
461
+ importName: "redisEventBusProvider",
462
+ importFrom: "@beignet/provider-event-bus-redis",
463
+ providerEntry: "redisEventBusProvider",
464
+ dependencies: (packageJson) => ({
465
+ "@beignet/provider-event-bus-redis":
466
+ beignetDependencyVersion(packageJson),
467
+ ioredis: externalVersions.ioredis,
468
+ }),
469
+ appPort: {
470
+ key: "eventBus",
471
+ typeName: "EventBusPort",
472
+ importName: "EventBusPort",
473
+ importFrom: "@beignet/core/ports",
474
+ },
475
+ envVars: ["REDIS_EVENT_BUS_URL"],
476
+ envExample: [
477
+ "# Redis event bus provider",
478
+ "REDIS_EVENT_BUS_URL=redis://localhost:6379",
479
+ "# REDIS_EVENT_BUS_DB=0",
480
+ "# REDIS_EVENT_BUS_CHANNEL_PREFIX=beignet:event-bus:",
481
+ "",
482
+ ].join("\n"),
483
+ docs: [
484
+ "## Redis event bus",
485
+ "",
486
+ "- Package: `@beignet/provider-event-bus-redis`",
487
+ "- Peer dependency: `ioredis`",
488
+ "- The preset wires `redisEventBusProvider` in `server/providers.ts` and defers `eventBus` in `infra/app-ports.ts`.",
489
+ "- Set `REDIS_EVENT_BUS_URL` before booting the app.",
490
+ "- Redis Pub/Sub delivery is best-effort and cross-process, but not durable: use outbox/jobs for side effects that need retry, replay, or dead-letter handling.",
491
+ "- Use `ctx.ports.eventBus` in app code; `ctx.ports.redisEventBus` is available as an escape hatch.",
492
+ "",
493
+ ].join("\n"),
494
+ nextSteps: [
495
+ "Run your package manager install command.",
496
+ "Set REDIS_EVENT_BUS_URL in .env.local or your deployment environment.",
497
+ "Keep durable side effects on the outbox/jobs path; Redis Pub/Sub does not replay missed events.",
498
+ "Run beignet providers audit and beignet doctor --strict.",
499
+ ],
500
+ conflictingProviderEntries: [
501
+ "createInMemoryEventBusProvider",
502
+ "createRedisEventBusProvider",
503
+ ],
504
+ },
458
505
  "redis-locks": {
459
506
  name: "redis-locks",
460
507
  displayName: "Redis locks",
package/src/task.ts CHANGED
@@ -50,7 +50,7 @@ export async function runAppTask(
50
50
  const task = findTask(taskModule.tasks, options.name, modulePath);
51
51
  // Loaded lazily so CLI commands that never touch the app runtime, such as
52
52
  // `beignet lint`, work without a built @beignet/core.
53
- const { parseTaskInput } = await import("@beignet/core/tasks");
53
+ const { parseTaskInput, runTask } = await import("@beignet/core/tasks");
54
54
  const parsedInput = await parseTaskInput(task, rawInput);
55
55
  const contextArgs: TaskRunContextArgs = {
56
56
  task,
@@ -63,8 +63,7 @@ export async function runAppTask(
63
63
  : {};
64
64
 
65
65
  try {
66
- const output = await task.handle({
67
- task,
66
+ const output = await runTask(task, {
68
67
  input: parsedInput,
69
68
  ctx,
70
69
  });
@@ -27,7 +27,9 @@ export function agentsMd(ctx: TemplateContext): string {
27
27
  const intent = intentRunner(ctx);
28
28
  const capabilityClientRow = ctx.api
29
29
  ? ""
30
- : "\n| Query cache keys and invalidation | `rq(contract).invalidate(queryClient, params?)`, `.key(...)`, `.filter(...)` from `client/` (`@beignet/react-query`). Thin named wrappers in `features/<feature>/client/` are the convention — hand-built key arrays are not. |";
30
+ : "\n| Query cache keys and invalidation | `rq(contract).invalidate(queryClient, params?)`, `.key(...)`, `.filter(...)` from `client/` (`@beignet/react-query`). Thin named wrappers in `features/<feature>/client/` are the convention — hand-built key arrays are not. |" +
31
+ "\n| Server Component request context | Use an app-owned server-only helper such as `lib/server-context.ts` that wraps `server.createContextFromNext()` in React `cache(...)`. Layouts may read `ctx.auth`, `ctx.tenant`, and request metadata for redirects and shell state; feature data still belongs behind use cases. |" +
32
+ "\n| Server Component React Query prefetch | Use an app-owned helper such as `lib/server-react-query.ts` on top of the request context: keep the `rq(contract).queryOptions(...)` key, replace only the server `queryFn` with the direct use case call, and do not create a parallel `rqServer` abstraction. |";
31
33
  const specificSkills = ctx.api
32
34
  ? "`@beignet/next#routes-server`, `@beignet/provider-db-drizzle#database-provider`, `@beignet/provider-auth-better-auth#auth-provider`, or `@beignet/cli#app-structure`"
33
35
  : "`@beignet/next#routes-server`, `@beignet/react-query#client`, `@beignet/react-hook-form#forms`, `@beignet/provider-db-drizzle#database-provider`, `@beignet/provider-auth-better-auth#auth-provider`, or `@beignet/cli#app-structure`";
@@ -50,8 +52,8 @@ is a silent failure — the file exists but never runs:
50
52
  - Tasks must be added to \`defineTasks([...])\` in \`server/tasks.ts\`.
51
53
  - Outbox events and jobs must be registered in \`defineOutboxRegistry({...})\`
52
54
  in \`server/outbox.ts\`.
53
- - Listeners must be wired through a \`registerListeners(...)\` call in infra
54
- wiring.
55
+ - Listeners must be added to the \`listeners\` array in \`server/listeners.ts\`
56
+ and wired through a \`registerListeners(...)\` call in server provider wiring.
55
57
 
56
58
  The starter ships no workflow registries — generators create them on first
57
59
  use, so their absence is fine. \`${cli} make event\`, \`${cli} make job\`,
@@ -81,6 +83,7 @@ apps have reimplemented by accident:
81
83
  | Ports outside a request — auth callbacks, module-level helpers | \`const { ports } = await getServer()\` (dynamic \`import("@/server")\` breaks module cycles). Do not construct parallel provider clients or fall back to \`console.*\` when \`ports.logger\` exists. |
82
84
  | Routes that cannot be contracts — webhooks, third-party callbacks, streaming | \`createWebhookRoute\`, \`createPaymentWebhookRoute\`, \`createScheduleRoute\`, \`createOutboxDrainRoute\` from \`@beignet/next\`; \`server.rawRoute(...)\` for anything else. All run the hooks pipeline — never hand-enforce rate limits in a route body. |
83
85
  | Rate limiting or idempotency on a route | Declare \`metadata.rateLimit\` / \`metadata.idempotency\` on the contract (or the \`pipeline\` option on raw routes); hooks enforce it. |
86
+ | Tenant-owned repository access | Pass \`TenantScope\` through app-facing repository methods and unwrap it with \`tenantScopeId(scope)\` in adapters. Raw provider-correlation lookups such as webhook customer IDs are not tenant authorization. |
84
87
  | Route-level tests that exercise real hooks | \`createTestApp\` / \`createTestRequester\` from \`@beignet/web/testing\`. Bind the rate-limit and idempotency ports in the test app when asserting 429s or replay. |
85
88
  | Environment configuration | \`lib/env.ts\` (\`createEnv\`), never ad-hoc \`process.env\` reads in app code. |
86
89
  | The same lookup runs several times in one request — context, policy, use case | Wrap the repository read in \`createMemo(...)\` from \`@beignet/core/memo\` where the adapter is wired in infra. The cache lives for exactly one request; pair mutations with \`.invalidate(...)\`. Never hand-roll per-request caches. |
@@ -67,11 +67,11 @@ export function packageJson(ctx: TemplateContext): string {
67
67
  "@beignet/cli": ctx.beignetVersion,
68
68
  "@beignet/web": ctx.beignetVersion,
69
69
  "@biomejs/biome": externalVersions.biome,
70
- "@types/bun": externalVersions.typesBun,
71
70
  "@types/node": externalVersions.typesNode,
72
71
  "@types/react": externalVersions.typesReact,
73
72
  "@types/react-dom": externalVersions.typesReactDom,
74
73
  "drizzle-kit": externalVersions.drizzleKit,
74
+ tsx: externalVersions.tsx,
75
75
  typescript: externalVersions.typescript,
76
76
  };
77
77
 
@@ -97,11 +97,11 @@ export function packageJson(ctx: TemplateContext): string {
97
97
  start: "next start",
98
98
  lint: "biome lint .",
99
99
  format: "biome format --write .",
100
- test: "bun test",
100
+ test: "tsx lib/beignet-test-runner.ts",
101
101
  typecheck: "tsc --noEmit",
102
102
  "db:generate": "drizzle-kit generate",
103
103
  "db:migrate": "drizzle-kit migrate",
104
- "db:reset": "bun infra/db/reset.ts",
104
+ "db:reset": "tsx infra/db/reset.ts",
105
105
  };
106
106
 
107
107
  return json({
@@ -28,6 +28,7 @@ import {
28
28
  import { shadcnTemplateFiles } from "./shadcn.js";
29
29
  import type { TemplateContext, TemplateFile } from "./shared.js";
30
30
  import { shellTemplateFiles } from "./shell.js";
31
+ import { testSupportTemplateFiles } from "./testing.js";
31
32
  import { todosFiles } from "./todos.js";
32
33
 
33
34
  /**
@@ -133,6 +134,7 @@ export function getTemplateFiles(ctx: TemplateContext): TemplateFile[] {
133
134
  { path: "lib/auth.ts", content: serverFiles.authHelpers },
134
135
  { path: "lib/better-auth.ts", content: betterAuth(ctx) },
135
136
  { path: "lib/tenant.ts", content: serverFiles.tenantHelper },
137
+ ...testSupportTemplateFiles,
136
138
  { path: "lib/use-case.ts", content: serverFiles.useCaseBuilder },
137
139
  { path: "server/index.ts", content: server(ctx) },
138
140
  { path: "server/context.ts", content: serverFiles.serverContext },
@@ -297,7 +297,11 @@ export function server(ctx: TemplateContext): string {
297
297
 
298
298
  return `import "@beignet/core/server-only";
299
299
  import { createNextServer, createNextServerLoader } from "@beignet/next";
300
- import { createErrorReportingHooks, createIdempotencyHooks } from "@beignet/core/server";
300
+ import {
301
+ createErrorReportingHooks,
302
+ createIdempotencyHooks,
303
+ createSecurityHeadersHooks,
304
+ } from "@beignet/core/server";
301
305
  import type { AppContext } from "@/app-context";
302
306
  import { appPorts } from "@/infra/app-ports";
303
307
  import { env } from "@/lib/env";
@@ -314,6 +318,7 @@ export const getServer = createNextServerLoader(async () => {
314
318
  ${providerConfigEntries}
315
319
  },
316
320
  hooks: [
321
+ createSecurityHeadersHooks<AppContext>(),
317
322
  createErrorReportingHooks<AppContext>(),
318
323
  createIdempotencyHooks<AppContext>(),
319
324
  ],
@@ -35,8 +35,8 @@ export const externalVersions = {
35
35
  next: "^16.2.4",
36
36
  react: "^19.2.5",
37
37
  reactDom: "^19.2.5",
38
+ tsx: "^4.22.4",
38
39
  typescript: "^5.3.0",
39
- typesBun: "^1.3.13",
40
40
  typesNode: "^20.10.0",
41
41
  typesReact: "^19.0.0",
42
42
  typesReactDom: "^19.0.0",
@@ -67,6 +67,18 @@ export const externalVersions = {
67
67
  upstashRedis: "^1.0.0",
68
68
  };
69
69
 
70
+ export const legacyGeneratedPackageScripts: Record<string, string> = {
71
+ "db:reset": "bun infra/db/reset.ts",
72
+ "db:seed": "bun server/seed.ts",
73
+ test: "bun test",
74
+ };
75
+
76
+ export const currentGeneratedPackageScripts: Record<string, string> = {
77
+ "db:reset": "tsx infra/db/reset.ts",
78
+ "db:seed": "tsx server/seed.ts",
79
+ test: "tsx lib/beignet-test-runner.ts",
80
+ };
81
+
70
82
  /**
71
83
  * Dialect-specific names used when rendering database-aware templates.
72
84
  */