@beignet/cli 0.0.27 → 0.0.28

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 (49) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +31 -6
  3. package/dist/check.d.ts +61 -0
  4. package/dist/check.d.ts.map +1 -0
  5. package/dist/check.js +164 -0
  6. package/dist/check.js.map +1 -0
  7. package/dist/db.d.ts +8 -0
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +2 -2
  10. package/dist/db.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +47 -8
  13. package/dist/index.js.map +1 -1
  14. package/dist/inspect.d.ts.map +1 -1
  15. package/dist/inspect.js +70 -0
  16. package/dist/inspect.js.map +1 -1
  17. package/dist/make/shared.d.ts +17 -0
  18. package/dist/make/shared.d.ts.map +1 -1
  19. package/dist/make/shared.js +67 -0
  20. package/dist/make/shared.js.map +1 -1
  21. package/dist/make.d.ts.map +1 -1
  22. package/dist/make.js +11 -55
  23. package/dist/make.js.map +1 -1
  24. package/dist/mcp.js +1 -1
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/provider-audit.d.ts.map +1 -1
  27. package/dist/provider-audit.js +77 -1
  28. package/dist/provider-audit.js.map +1 -1
  29. package/dist/templates/agents.d.ts.map +1 -1
  30. package/dist/templates/agents.js +27 -9
  31. package/dist/templates/agents.js.map +1 -1
  32. package/dist/templates/base.d.ts.map +1 -1
  33. package/dist/templates/base.js +42 -12
  34. package/dist/templates/base.js.map +1 -1
  35. package/dist/templates/db/sqlite.d.ts.map +1 -1
  36. package/dist/templates/db/sqlite.js +7 -1
  37. package/dist/templates/db/sqlite.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/check.ts +246 -0
  40. package/src/db.ts +2 -2
  41. package/src/index.ts +62 -8
  42. package/src/inspect.ts +100 -0
  43. package/src/make/shared.ts +88 -0
  44. package/src/make.ts +10 -68
  45. package/src/mcp.ts +1 -1
  46. package/src/provider-audit.ts +95 -3
  47. package/src/templates/agents.ts +27 -9
  48. package/src/templates/base.ts +43 -12
  49. package/src/templates/db/sqlite.ts +7 -1
package/src/check.ts ADDED
@@ -0,0 +1,246 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { createPainter } from "./ansi.js";
4
+ import { detectPackageManager, spawnCommand } from "./db.js";
5
+ import {
6
+ applyDoctorFixes,
7
+ formatDoctor,
8
+ type InspectFix,
9
+ inspectApp,
10
+ } from "./inspect.js";
11
+ import { formatLint, lintApp } from "./lint.js";
12
+
13
+ /**
14
+ * One step of `beignet check`.
15
+ *
16
+ * `output` carries the failing step's formatted diagnostics or captured
17
+ * script output so the summary is actionable without a re-run.
18
+ */
19
+ export type CheckStep = {
20
+ name: string;
21
+ command: string;
22
+ status: "passed" | "failed" | "skipped";
23
+ detail?: string;
24
+ output?: string;
25
+ durationMs: number;
26
+ };
27
+
28
+ export type CheckAppResult = {
29
+ schemaVersion: 1;
30
+ targetDir: string;
31
+ runner: string;
32
+ ok: boolean;
33
+ fixes: InspectFix[];
34
+ steps: CheckStep[];
35
+ };
36
+
37
+ export type CheckAppOptions = {
38
+ cwd?: string;
39
+ fix?: boolean;
40
+ color?: boolean;
41
+ /** Called after each step completes, for incremental progress output. */
42
+ onStep?: (step: CheckStep) => void;
43
+ };
44
+
45
+ const scriptStepNames = ["lint", "typecheck", "test"] as const;
46
+
47
+ /**
48
+ * Run the whole app validation loop as one command: `beignet lint`,
49
+ * `beignet doctor --strict` (optionally with fixes), and the app's own
50
+ * `lint`, `typecheck`, and `test` package scripts through the detected
51
+ * package manager.
52
+ *
53
+ * Every step runs even when an earlier one fails, so a single run reports
54
+ * everything that needs fixing. Missing package scripts are reported as
55
+ * skipped, never as failures.
56
+ */
57
+ export async function checkApp(
58
+ options: CheckAppOptions = {},
59
+ ): Promise<CheckAppResult> {
60
+ const targetDir = path.resolve(options.cwd ?? process.cwd());
61
+ const runner = await detectPackageManager(targetDir);
62
+ const steps: CheckStep[] = [];
63
+ const record = (step: CheckStep): void => {
64
+ steps.push(step);
65
+ options.onStep?.(step);
66
+ };
67
+
68
+ record(await lintStep(targetDir, options));
69
+ const { step: doctorStep, fixes } = await runDoctorStep(targetDir, options);
70
+ record(doctorStep);
71
+
72
+ const scripts = await readPackageScripts(targetDir);
73
+ for (const script of scriptStepNames) {
74
+ record(await scriptStep(targetDir, runner, script, scripts));
75
+ }
76
+
77
+ return {
78
+ schemaVersion: 1,
79
+ targetDir,
80
+ runner,
81
+ ok: steps.every((step) => step.status !== "failed"),
82
+ fixes,
83
+ steps,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Format a completed step as a single status line, with the failing step's
89
+ * output indented underneath.
90
+ */
91
+ export function formatCheckStep(
92
+ step: CheckStep,
93
+ options: { color?: boolean } = {},
94
+ ): string {
95
+ const paint = createPainter(options.color);
96
+ const mark =
97
+ step.status === "passed"
98
+ ? paint("✓", "green")
99
+ : step.status === "failed"
100
+ ? paint("✗", "red")
101
+ : paint("-", "cyan");
102
+ const detail = step.detail ? ` (${step.detail})` : "";
103
+ const line = `${mark} ${step.name}${detail}`;
104
+ if (step.status !== "failed" || !step.output?.trim()) return line;
105
+
106
+ const indented = step.output
107
+ .trimEnd()
108
+ .split("\n")
109
+ .map((outputLine) => ` ${outputLine}`)
110
+ .join("\n");
111
+ return `${line}\n${indented}`;
112
+ }
113
+
114
+ /**
115
+ * Format the closing summary line, e.g. `3 passed, 1 failed, 1 skipped`.
116
+ */
117
+ export function formatCheckSummary(
118
+ result: CheckAppResult,
119
+ options: { color?: boolean } = {},
120
+ ): string {
121
+ const paint = createPainter(options.color);
122
+ const counts = {
123
+ passed: result.steps.filter((step) => step.status === "passed").length,
124
+ failed: result.steps.filter((step) => step.status === "failed").length,
125
+ skipped: result.steps.filter((step) => step.status === "skipped").length,
126
+ };
127
+ const parts = [`${counts.passed} passed`];
128
+ if (counts.failed > 0) parts.push(paint(`${counts.failed} failed`, "red"));
129
+ if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`);
130
+ return parts.join(", ");
131
+ }
132
+
133
+ /**
134
+ * Format a full result at once (JSON-free callers and tests).
135
+ */
136
+ export function formatCheck(
137
+ result: CheckAppResult,
138
+ options: { color?: boolean } = {},
139
+ ): string {
140
+ const lines = result.steps.map((step) => formatCheckStep(step, options));
141
+ return `${lines.join("\n")}\n\n${formatCheckSummary(result, options)}`;
142
+ }
143
+
144
+ async function lintStep(
145
+ targetDir: string,
146
+ options: CheckAppOptions,
147
+ ): Promise<CheckStep> {
148
+ const startedAt = Date.now();
149
+ const result = await lintApp({ cwd: targetDir });
150
+ const failed = result.diagnostics.length > 0;
151
+
152
+ return {
153
+ name: "beignet lint",
154
+ command: "beignet lint",
155
+ status: failed ? "failed" : "passed",
156
+ ...(failed ? { output: formatLint(result, { color: options.color }) } : {}),
157
+ durationMs: Date.now() - startedAt,
158
+ };
159
+ }
160
+
161
+ async function runDoctorStep(
162
+ targetDir: string,
163
+ options: CheckAppOptions,
164
+ ): Promise<{ step: CheckStep; fixes: InspectFix[] }> {
165
+ const startedAt = Date.now();
166
+ const fixes = options.fix
167
+ ? await applyDoctorFixes({ cwd: targetDir, strict: true })
168
+ : [];
169
+ const result = await inspectApp({ cwd: targetDir, strict: true });
170
+ result.fixes = fixes;
171
+ const failed = result.diagnostics.some(
172
+ (diagnostic) =>
173
+ diagnostic.severity === "error" || diagnostic.severity === "warning",
174
+ );
175
+
176
+ return {
177
+ step: {
178
+ name: "beignet doctor --strict",
179
+ command: "beignet doctor --strict",
180
+ status: failed ? "failed" : "passed",
181
+ ...(fixes.length > 0
182
+ ? {
183
+ detail: `${fixes.length} ${fixes.length === 1 ? "fix" : "fixes"} applied`,
184
+ }
185
+ : {}),
186
+ ...(failed
187
+ ? { output: formatDoctor(result, { color: options.color }) }
188
+ : {}),
189
+ durationMs: Date.now() - startedAt,
190
+ },
191
+ fixes,
192
+ };
193
+ }
194
+
195
+ async function scriptStep(
196
+ targetDir: string,
197
+ runner: string,
198
+ script: (typeof scriptStepNames)[number],
199
+ scripts: Record<string, string>,
200
+ ): Promise<CheckStep> {
201
+ const command = `${runner} run ${script}`;
202
+ if (!scripts[script]) {
203
+ return {
204
+ name: script,
205
+ command,
206
+ status: "skipped",
207
+ detail: `no "${script}" script in package.json`,
208
+ durationMs: 0,
209
+ };
210
+ }
211
+
212
+ const startedAt = Date.now();
213
+ const result = await spawnCommand(runner, ["run", script], targetDir, {
214
+ captureOutput: true,
215
+ });
216
+ const failed = result.exitCode !== 0;
217
+
218
+ return {
219
+ name: script,
220
+ command,
221
+ status: failed ? "failed" : "passed",
222
+ detail: command,
223
+ ...(failed
224
+ ? { output: [result.stdout, result.stderr].filter(Boolean).join("\n") }
225
+ : {}),
226
+ durationMs: Date.now() - startedAt,
227
+ };
228
+ }
229
+
230
+ async function readPackageScripts(
231
+ targetDir: string,
232
+ ): Promise<Record<string, string>> {
233
+ let source: string;
234
+ try {
235
+ source = await readFile(path.join(targetDir, "package.json"), "utf8");
236
+ } catch {
237
+ throw new Error(
238
+ "Could not find package.json. Run beignet check from the app root.",
239
+ );
240
+ }
241
+
242
+ const parsed = JSON.parse(source) as {
243
+ scripts?: Record<string, string>;
244
+ };
245
+ return parsed.scripts ?? {};
246
+ }
package/src/db.ts CHANGED
@@ -482,7 +482,7 @@ async function readPackageJson(cwd: string): Promise<PackageJson> {
482
482
  }
483
483
  }
484
484
 
485
- async function detectPackageManager(cwd: string): Promise<string> {
485
+ export async function detectPackageManager(cwd: string): Promise<string> {
486
486
  if (await exists(path.join(cwd, "bun.lock"))) return "bun";
487
487
  if (await exists(path.join(cwd, "bun.lockb"))) return "bun";
488
488
  if (await exists(path.join(cwd, "pnpm-lock.yaml"))) return "pnpm";
@@ -500,7 +500,7 @@ async function exists(filePath: string): Promise<boolean> {
500
500
  }
501
501
  }
502
502
 
503
- function spawnCommand(
503
+ export function spawnCommand(
504
504
  command: string,
505
505
  args: readonly string[],
506
506
  cwd: string,
package/src/index.ts CHANGED
@@ -105,6 +105,12 @@ type LintFlags = {
105
105
  format?: OutputFormat;
106
106
  };
107
107
 
108
+ type CheckFlags = {
109
+ json?: boolean;
110
+ fix?: boolean;
111
+ cwd?: string;
112
+ };
113
+
108
114
  type DbFlags = {
109
115
  json?: boolean;
110
116
  dryRun?: boolean;
@@ -601,6 +607,55 @@ const doctorCommand = buildCommand<DoctorFlags, [], CliContext>({
601
607
  },
602
608
  });
603
609
 
610
+ const checkCommand = buildCommand<CheckFlags, [], CliContext>({
611
+ docs: {
612
+ brief: "Run the full app validation loop as one command.",
613
+ fullDescription:
614
+ "Runs beignet lint, beignet doctor --strict, and the app's lint, typecheck, and test package scripts through the detected package manager. Every step runs even when an earlier one fails, so one run reports everything. Missing package scripts are skipped, not failed.",
615
+ },
616
+ parameters: {
617
+ flags: {
618
+ json: jsonFlag,
619
+ fix: {
620
+ kind: "boolean",
621
+ optional: true,
622
+ withNegated: false,
623
+ brief: "Apply low-risk doctor fixes before checking.",
624
+ },
625
+ cwd: cwdFlag,
626
+ } satisfies FlagParametersForType<CheckFlags, CliContext>,
627
+ },
628
+ loader: async () => {
629
+ const { checkApp, formatCheckStep, formatCheckSummary } = await import(
630
+ "./check.js"
631
+ );
632
+
633
+ return async function runCheck(this: CliContext, flags: CheckFlags) {
634
+ const color = useColor();
635
+ const result = await checkApp({
636
+ cwd: flags.cwd,
637
+ fix: flags.fix,
638
+ color,
639
+ ...(flags.json
640
+ ? {}
641
+ : {
642
+ onStep: (step) =>
643
+ writeOutput(this, formatCheckStep(step, { color })),
644
+ }),
645
+ });
646
+ writeOutput(
647
+ this,
648
+ flags.json
649
+ ? JSON.stringify(result, null, 2)
650
+ : `\n${formatCheckSummary(result, { color })}`,
651
+ );
652
+ if (!result.ok) {
653
+ this.process.exitCode = 1;
654
+ }
655
+ };
656
+ },
657
+ });
658
+
604
659
  const lintCommand = buildCommand<LintFlags, [], CliContext>({
605
660
  docs: {
606
661
  brief: "Check Beignet dependency direction conventions.",
@@ -1493,6 +1548,7 @@ const rootRoutes = buildRouteMap({
1493
1548
  Run npm create beignet@latest (or bun create beignet) to scaffold a new app.`,
1494
1549
  },
1495
1550
  routes: {
1551
+ check: checkCommand,
1496
1552
  completion: completionRoutes,
1497
1553
  create: createCommand,
1498
1554
  db: dbRoutes,
@@ -1506,6 +1562,10 @@ Run npm create beignet@latest (or bun create beignet) to scaffold a new app.`,
1506
1562
  schedule: scheduleRoutes,
1507
1563
  task: taskRoutes,
1508
1564
  },
1565
+ aliases: {
1566
+ // The singular form is a common near-miss for the providers commands.
1567
+ provider: "providers",
1568
+ },
1509
1569
  });
1510
1570
 
1511
1571
  const cli = buildApplication(rootRoutes, {
@@ -1697,19 +1757,13 @@ ${openStep}
1697
1757
 
1698
1758
  Inspect the app:
1699
1759
  ${cli} routes
1700
- ${run} lint
1701
- ${cli} lint
1702
- ${cli} doctor
1760
+ ${cli} check
1703
1761
 
1704
1762
  Generate a feature:
1705
1763
  ${cli} make feature projects
1706
1764
  ${cli} db generate
1707
1765
  ${cli} db migrate
1708
- ${run} test
1709
- ${run} lint
1710
- ${run} typecheck
1711
- ${cli} lint
1712
- ${cli} doctor`;
1766
+ ${cli} check`;
1713
1767
  }
1714
1768
 
1715
1769
  function packageRunnerFromPackageManager(pm: PackageManager): string {
package/src/inspect.ts CHANGED
@@ -19,6 +19,10 @@ import {
19
19
  formatGithubAnnotation,
20
20
  type GithubAnnotationSeverity,
21
21
  } from "./github-annotations.js";
22
+ import {
23
+ providersFilePath,
24
+ wireListenersProviderSource,
25
+ } from "./make/shared.js";
22
26
  import {
23
27
  detectedProviderVariants,
24
28
  diagnosticPackageJsonFile,
@@ -269,6 +273,14 @@ export async function applyDoctorFixes(
269
273
  ...(await fixUnregisteredOutboxEntries(targetDir, files, drift, config)),
270
274
  );
271
275
 
276
+ const listenerFix = await fixUnregisteredListeners(
277
+ targetDir,
278
+ files,
279
+ drift,
280
+ config,
281
+ );
282
+ if (listenerFix) fixes.push(listenerFix);
283
+
272
284
  const openApiFix = await fixDirectOpenApiArrayDrift(
273
285
  targetDir,
274
286
  files,
@@ -5462,6 +5474,94 @@ async function fixUnregisteredTasks(
5462
5474
  });
5463
5475
  }
5464
5476
 
5477
+ /**
5478
+ * Wire fully unregistered feature listener registries into the providers
5479
+ * file with the same `<registry>Provider` block `beignet make listener`
5480
+ * generates, so both write paths stay byte-compatible.
5481
+ *
5482
+ * Bails without writing when the providers file or its exported array is
5483
+ * missing or customized, when the app has no eventBus port to register
5484
+ * against, or when the registry name is already imported from another file.
5485
+ * The diagnostic stays in every bail case.
5486
+ */
5487
+ async function fixUnregisteredListeners(
5488
+ targetDir: string,
5489
+ files: string[],
5490
+ drift: WorkflowRegistrationDrift,
5491
+ config: ResolvedBeignetConfig,
5492
+ ): Promise<InspectFix | undefined> {
5493
+ const candidates = drift.listeners.unregistered.filter(
5494
+ (entry) => entry.fullyUnregistered,
5495
+ );
5496
+ if (candidates.length === 0) return undefined;
5497
+
5498
+ const providersFile = providersFilePath(config);
5499
+ const contextFile = normalizePath(
5500
+ path.join(path.dirname(config.paths.server), "context.ts"),
5501
+ );
5502
+ const wiringDependencies = [
5503
+ providersFile,
5504
+ contextFile,
5505
+ config.paths.appContext,
5506
+ config.paths.ports,
5507
+ ];
5508
+ if (wiringDependencies.some((file) => !files.includes(file))) {
5509
+ return undefined;
5510
+ }
5511
+
5512
+ // The generated wiring registers listeners on ports.eventBus; without the
5513
+ // port the fix would produce code that cannot typecheck.
5514
+ const portsSource = await readFile(
5515
+ path.join(targetDir, config.paths.ports),
5516
+ "utf8",
5517
+ );
5518
+ if (!/\beventBus\s*[:?]/.test(portsSource)) return undefined;
5519
+
5520
+ const providersPath = path.join(targetDir, providersFile);
5521
+ const original = await readFile(providersPath, "utf8");
5522
+ let next = original;
5523
+ const registeredNames: string[] = [];
5524
+
5525
+ for (const { registry } of candidates) {
5526
+ const imported = parseNamedImportSources(next).get(registry.registryName);
5527
+ if (imported) {
5528
+ const importedFile = sourceFileFromImport(
5529
+ imported.sourcePath,
5530
+ providersFile,
5531
+ files,
5532
+ );
5533
+ if (importedFile !== registry.indexFile) return undefined;
5534
+ }
5535
+
5536
+ const result = wireListenersProviderSource(next, {
5537
+ registryName: registry.registryName,
5538
+ featureKebab: listenerFeatureKebab(registry.indexFile),
5539
+ listenerModule: `@/${modulePath(registry.indexFile)}`,
5540
+ config,
5541
+ });
5542
+ if (result.kind === "missing") return undefined;
5543
+ if (result.kind === "updated") {
5544
+ next = result.source;
5545
+ registeredNames.push(registry.registryName);
5546
+ }
5547
+ }
5548
+
5549
+ if (next === original || registeredNames.length === 0) return undefined;
5550
+
5551
+ await writeFile(providersPath, next);
5552
+ return {
5553
+ code: "BEIGNET_LISTENER_UNREGISTERED",
5554
+ file: providersFile,
5555
+ message: `Registered ${registeredNames.join(", ")} with registerListeners(...) in ${providersFile}.`,
5556
+ };
5557
+ }
5558
+
5559
+ function listenerFeatureKebab(indexFile: string): string {
5560
+ return path.posix.basename(
5561
+ path.posix.dirname(path.posix.dirname(normalizePath(indexFile))),
5562
+ );
5563
+ }
5564
+
5465
5565
  async function fixUnregisteredOutboxEntries(
5466
5566
  targetDir: string,
5467
5567
  files: string[],
@@ -7,6 +7,7 @@ import {
7
7
  type ResolvedBeignetConfig,
8
8
  } from "../config.js";
9
9
  import {
10
+ type AppendResult,
10
11
  appendToArrayExpression,
11
12
  appendToNamedArray,
12
13
  arrayInitializerInfo,
@@ -349,6 +350,93 @@ export function providersFilePath(config: ResolvedBeignetConfig): string {
349
350
  return `${directoryPath(path.dirname(config.paths.server))}/providers.ts`;
350
351
  }
351
352
 
353
+ /**
354
+ * Wire a feature listener registry into the generated providers file by
355
+ * adding a `<registry>Provider` lifecycle provider whose setup calls
356
+ * `registerListeners(...)` and registering it in the providers array.
357
+ *
358
+ * Shared by `beignet make listener` and `beignet doctor --fix` so both write
359
+ * paths produce byte-identical wiring. Returns `missing` when the exported
360
+ * providers array anchor cannot be found; callers decide whether that is a
361
+ * hard error (make) or a keep-the-diagnostic bail (doctor).
362
+ */
363
+ export function wireListenersProviderSource(
364
+ source: string,
365
+ options: {
366
+ registryName: string;
367
+ featureKebab: string;
368
+ listenerModule: string;
369
+ config: ResolvedBeignetConfig;
370
+ },
371
+ ): AppendResult {
372
+ const { registryName, featureKebab, listenerModule, config } = options;
373
+ const providerName = `${registryName}Provider`;
374
+ let next = source;
375
+ next = addNamedImport(next, "registerListeners", "@beignet/core/events");
376
+ next = addNamedImport(next, "createServiceActor", "@beignet/core/ports");
377
+ next = addNamedImport(next, "createProvider", "@beignet/core/providers");
378
+ next = addNamedTypeImport(
379
+ next,
380
+ "AppContext",
381
+ aliasModule(config.paths.appContext),
382
+ );
383
+ next = addNamedTypeImport(
384
+ next,
385
+ "AppServiceContextInput",
386
+ aliasModule(path.join(path.dirname(config.paths.server), "context.ts")),
387
+ );
388
+ next = addNamedTypeImport(next, "AppPorts", aliasModule(config.paths.ports));
389
+
390
+ const listenerImport = `import { ${registryName} } from "${listenerModule}";`;
391
+ if (!next.includes(listenerImport)) {
392
+ next = insertAfterImports(next, listenerImport);
393
+ }
394
+
395
+ if (!new RegExp(`\\bconst\\s+${providerName}\\b`).test(next)) {
396
+ const providerDefinition = `const ${providerName} = createProvider<
397
+ \tPick<AppPorts, "eventBus" | "logger">,
398
+ \tAppContext,
399
+ \tAppServiceContextInput
400
+ >()({
401
+ \tname: "${featureKebab}-listeners",
402
+ \tsetup({ ports, createServiceContext }) {
403
+ \t\tconst unregister = registerListeners(ports.eventBus, ${registryName}, {
404
+ \t\t\tctx: () =>
405
+ \t\t\t\tcreateServiceContext({
406
+ \t\t\t\t\tactor: createServiceActor("beignet-listener"),
407
+ \t\t\t\t}),
408
+ \t\t\tonError(error, listener) {
409
+ \t\t\t\tports.logger.error("Event listener failed", {
410
+ \t\t\t\t\terror,
411
+ \t\t\t\t\tlistenerName: listener.name,
412
+ \t\t\t\t});
413
+ \t\t\t},
414
+ \t\t});
415
+
416
+ \t\treturn {
417
+ \t\t\tstop() {
418
+ \t\t\t\tunregister();
419
+ \t\t\t},
420
+ \t\t};
421
+ \t},
422
+ });
423
+ `;
424
+ const withProvider = next.replace(
425
+ /\nexport const providers = \[/,
426
+ `\n${providerDefinition}\nexport const providers = [`,
427
+ );
428
+ if (withProvider === next) return { kind: "missing" };
429
+ next = withProvider;
430
+ }
431
+
432
+ const appended = appendToNamedArray(next, "providers", providerName);
433
+ if (appended.kind === "missing") return { kind: "missing" };
434
+ if (appended.kind === "updated") next = appended.source;
435
+
436
+ if (next === source) return { kind: "unchanged" };
437
+ return { kind: "updated", source: next };
438
+ }
439
+
352
440
  /**
353
441
  * Wire provider-backed app ports a generator depends on.
354
442
  *
package/src/make.ts CHANGED
@@ -131,6 +131,7 @@ import {
131
131
  usesFeatureOwnedPolicies,
132
132
  usesFeatureOwnedTests,
133
133
  type WriteGeneratedFileResult,
134
+ wireListenersProviderSource,
134
135
  wirePortProviders,
135
136
  writeGeneratedFile,
136
137
  writePlannedGeneratedFile,
@@ -1392,79 +1393,20 @@ async function updateListenerProviderWiring(
1392
1393
  }
1393
1394
 
1394
1395
  const listenerIndex = featureArtifactIndexFile("listener", names, config);
1395
- const providerName = `${names.featureSingularCamel}ListenersProvider`;
1396
- let next = original;
1397
- next = addNamedImport(next, "registerListeners", "@beignet/core/events");
1398
- next = addNamedImport(next, "createServiceActor", "@beignet/core/ports");
1399
- next = addNamedImport(next, "createProvider", "@beignet/core/providers");
1400
- next = addNamedTypeImport(
1401
- next,
1402
- "AppContext",
1403
- aliasModule(config.paths.appContext),
1404
- );
1405
- next = addNamedTypeImport(
1406
- next,
1407
- "AppServiceContextInput",
1408
- aliasModule(path.join(path.dirname(config.paths.server), "context.ts")),
1409
- );
1410
- next = addNamedTypeImport(next, "AppPorts", aliasModule(config.paths.ports));
1411
-
1412
- const listenerImport = `import { ${listenerIndex.registryName} } from "${aliasModule(listenerIndex.path)}";`;
1413
- if (!next.includes(listenerImport)) {
1414
- next = insertAfterImports(next, listenerImport);
1415
- }
1416
-
1417
- if (!new RegExp(`\\bconst\\s+${providerName}\\b`).test(next)) {
1418
- const providerDefinition = `const ${providerName} = createProvider<
1419
- \tPick<AppPorts, "eventBus" | "logger">,
1420
- \tAppContext,
1421
- \tAppServiceContextInput
1422
- >()({
1423
- \tname: "${names.feature.kebab}-listeners",
1424
- \tsetup({ ports, createServiceContext }) {
1425
- \t\tconst unregister = registerListeners(ports.eventBus, ${listenerIndex.registryName}, {
1426
- \t\t\tctx: () =>
1427
- \t\t\t\tcreateServiceContext({
1428
- \t\t\t\t\tactor: createServiceActor("beignet-listener"),
1429
- \t\t\t\t}),
1430
- \t\t\tonError(error, listener) {
1431
- \t\t\t\tports.logger.error("Event listener failed", {
1432
- \t\t\t\t\terror,
1433
- \t\t\t\t\tlistenerName: listener.name,
1434
- \t\t\t\t});
1435
- \t\t\t},
1436
- \t\t});
1437
-
1438
- \t\treturn {
1439
- \t\t\tstop() {
1440
- \t\t\t\tunregister();
1441
- \t\t\t},
1442
- \t\t};
1443
- \t},
1444
- });
1445
- `;
1446
- const withProvider = next.replace(
1447
- /\nexport const providers = \[/,
1448
- `\n${providerDefinition}\nexport const providers = [`,
1449
- );
1450
- if (withProvider === next) {
1451
- throw new Error(
1452
- `Could not find the exported providers array in ${providersFile}. Register ${listenerIndex.registryName} manually with registerListeners(...), or restore the generated providers file before running make listener.`,
1453
- );
1454
- }
1455
- next = withProvider;
1456
- }
1457
-
1458
- const appended = appendToNamedArray(next, "providers", providerName);
1459
- if (appended.kind === "missing") {
1396
+ const result = wireListenersProviderSource(original, {
1397
+ registryName: listenerIndex.registryName,
1398
+ featureKebab: names.feature.kebab,
1399
+ listenerModule: aliasModule(listenerIndex.path),
1400
+ config,
1401
+ });
1402
+ if (result.kind === "missing") {
1460
1403
  throw new Error(
1461
1404
  `Could not find the exported providers array in ${providersFile}. Register ${listenerIndex.registryName} manually with registerListeners(...), or restore the generated providers file before running make listener.`,
1462
1405
  );
1463
1406
  }
1464
- if (appended.kind === "updated") next = appended.source;
1465
1407
 
1466
- if (next === original) return "skipped";
1467
- if (!options.dryRun) await writeFile(filePath, next);
1408
+ if (result.kind === "unchanged") return "skipped";
1409
+ if (!options.dryRun) await writeFile(filePath, result.source);
1468
1410
  return "updated";
1469
1411
  }
1470
1412
 
package/src/mcp.ts CHANGED
@@ -380,7 +380,7 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
380
380
  "doctor_fix",
381
381
  {
382
382
  description:
383
- "Apply low-risk doctor fixes, then re-check the app. Repairs route-group, schedule, task, and outbox registration drift; listener registration drift is report-only. Returns applied fixes and remaining diagnostics as JSON.",
383
+ "Apply low-risk doctor fixes, then re-check the app. Repairs route-group, schedule, task, outbox, and listener registration drift. Returns applied fixes and remaining diagnostics as JSON.",
384
384
  inputSchema: {
385
385
  strict: z
386
386
  .boolean()