@beignet/cli 0.0.41 → 0.0.42

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 (58) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +77 -21
  3. package/dist/choices.d.ts +18 -0
  4. package/dist/choices.d.ts.map +1 -1
  5. package/dist/choices.js +35 -0
  6. package/dist/choices.js.map +1 -1
  7. package/dist/db.d.ts +18 -7
  8. package/dist/db.d.ts.map +1 -1
  9. package/dist/db.js +20 -7
  10. package/dist/db.js.map +1 -1
  11. package/dist/doctor-fixes.d.ts +64 -0
  12. package/dist/doctor-fixes.d.ts.map +1 -0
  13. package/dist/doctor-fixes.js +142 -0
  14. package/dist/doctor-fixes.js.map +1 -0
  15. package/dist/explain.d.ts +3 -1
  16. package/dist/explain.d.ts.map +1 -1
  17. package/dist/explain.js +136 -42
  18. package/dist/explain.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +92 -25
  21. package/dist/index.js.map +1 -1
  22. package/dist/inspect.d.ts +33 -9
  23. package/dist/inspect.d.ts.map +1 -1
  24. package/dist/inspect.js +353 -116
  25. package/dist/inspect.js.map +1 -1
  26. package/dist/lib.d.ts +6 -2
  27. package/dist/lib.d.ts.map +1 -1
  28. package/dist/lib.js +3 -2
  29. package/dist/lib.js.map +1 -1
  30. package/dist/make/shared.js +3 -3
  31. package/dist/make/shared.js.map +1 -1
  32. package/dist/mcp.d.ts.map +1 -1
  33. package/dist/mcp.js +121 -13
  34. package/dist/mcp.js.map +1 -1
  35. package/dist/templates/agents.d.ts.map +1 -1
  36. package/dist/templates/agents.js +26 -10
  37. package/dist/templates/agents.js.map +1 -1
  38. package/dist/templates/base.d.ts.map +1 -1
  39. package/dist/templates/base.js +3 -3
  40. package/dist/templates/base.js.map +1 -1
  41. package/dist/templates/shared.d.ts +2 -1
  42. package/dist/templates/shared.d.ts.map +1 -1
  43. package/dist/templates/shared.js +7 -4
  44. package/dist/templates/shared.js.map +1 -1
  45. package/package.json +3 -2
  46. package/skills/app-structure/SKILL.md +34 -10
  47. package/src/choices.ts +57 -0
  48. package/src/db.ts +45 -15
  49. package/src/doctor-fixes.ts +252 -0
  50. package/src/explain.ts +151 -43
  51. package/src/index.ts +130 -35
  52. package/src/inspect.ts +497 -145
  53. package/src/lib.ts +28 -1
  54. package/src/make/shared.ts +3 -3
  55. package/src/mcp.ts +187 -13
  56. package/src/templates/agents.ts +26 -10
  57. package/src/templates/base.ts +3 -2
  58. package/src/templates/shared.ts +14 -6
package/src/choices.ts CHANGED
@@ -5,6 +5,12 @@
5
5
  * templates or generators at startup.
6
6
  */
7
7
 
8
+ function defineExhaustiveChoices<Choice extends string>() {
9
+ return <const Choices extends readonly Choice[]>(
10
+ choices: Exclude<Choice, Choices[number]> extends never ? Choices : never,
11
+ ): Choices => choices;
12
+ }
13
+
8
14
  /**
9
15
  * Package managers supported by the app template generator.
10
16
  */
@@ -13,6 +19,23 @@ export type PackageManager = "bun" | "npm" | "pnpm" | "yarn";
13
19
  * Application templates supported by the app template generator.
14
20
  */
15
21
  export type TemplateName = "next";
22
+
23
+ /**
24
+ * Stable repair operations supported by doctor fix planning.
25
+ */
26
+ export const doctorFixOperationIds = [
27
+ "package.repair-generated-support",
28
+ "routes.register-missing",
29
+ "schedules.register-missing",
30
+ "tasks.register-missing",
31
+ "workflows.register-missing",
32
+ "outbox.register-missing",
33
+ "listeners.register-missing",
34
+ "openapi.register-missing",
35
+ ] as const;
36
+
37
+ /** Stable identifier for one selectable doctor repair operation. */
38
+ export type DoctorFixOperationId = (typeof doctorFixOperationIds)[number];
16
39
  /**
17
40
  * Optional providers rendered directly by the starter template.
18
41
  *
@@ -27,6 +50,40 @@ export type StarterProviderName =
27
50
  * Databases supported by the starter's Drizzle persistence layer.
28
51
  */
29
52
  export type DatabaseName = "sqlite" | "postgres" | "mysql";
53
+
54
+ /** Database lifecycle command supported by Beignet. */
55
+ export type DatabaseCommand = "generate" | "migrate" | "seed" | "reset";
56
+
57
+ /** Database lifecycle commands shared by the CLI, library, and MCP tool. */
58
+ export const databaseCommandChoices =
59
+ defineExhaustiveChoices<DatabaseCommand>()([
60
+ "generate",
61
+ "migrate",
62
+ "seed",
63
+ "reset",
64
+ ]);
65
+
66
+ /** Drizzle dialect supported by provider-table schema sync. */
67
+ export type DatabaseSchemaDialect = "sqlite" | "postgres" | "mysql";
68
+
69
+ /** Drizzle dialects supported by provider-table schema sync. */
70
+ export const databaseSchemaDialectChoices =
71
+ defineExhaustiveChoices<DatabaseSchemaDialect>()([
72
+ "sqlite",
73
+ "postgres",
74
+ "mysql",
75
+ ]);
76
+
77
+ /** Beignet provider table supported by database schema sync. */
78
+ export type DatabaseSchemaTable = "audit" | "idempotency" | "outbox";
79
+
80
+ /** Beignet provider tables supported by database schema sync. */
81
+ export const databaseSchemaTableChoices =
82
+ defineExhaustiveChoices<DatabaseSchemaTable>()([
83
+ "audit",
84
+ "idempotency",
85
+ "outbox",
86
+ ]);
30
87
  /**
31
88
  * Provider setup presets supported by `beignet provider add`.
32
89
  */
package/src/db.ts CHANGED
@@ -1,11 +1,23 @@
1
1
  import { type ChildProcess, spawn } from "node:child_process";
2
2
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import {
5
+ type DatabaseCommand,
6
+ type DatabaseSchemaDialect,
7
+ type DatabaseSchemaTable,
8
+ databaseSchemaTableChoices,
9
+ } from "./choices.js";
10
+
11
+ export type {
12
+ DatabaseCommand,
13
+ DatabaseSchemaDialect,
14
+ DatabaseSchemaTable,
15
+ } from "./choices.js";
4
16
 
5
- /**
6
- * Database lifecycle operations supported by the Beignet CLI.
7
- */
8
- export type DatabaseCommand = "generate" | "migrate" | "seed" | "reset";
17
+ /** Default output retained per stream by structured database command callers. */
18
+ export const defaultDatabaseCommandMaxOutputBytes = 64 * 1024;
19
+ /** Default timeout used by request-controlled database command callers. */
20
+ export const defaultDatabaseCommandTimeoutMs = 10 * 60 * 1_000;
9
21
 
10
22
  /**
11
23
  * Options for running a database lifecycle command.
@@ -14,6 +26,12 @@ export type RunDatabaseCommandOptions = {
14
26
  command: DatabaseCommand;
15
27
  cwd?: string;
16
28
  captureOutput?: boolean;
29
+ /** Keep only the last N bytes from each output stream. */
30
+ maxOutputBytes?: number;
31
+ /** Cancel the app-owned database script when the owning request aborts. */
32
+ signal?: AbortSignal;
33
+ /** Stop the app-owned database script after this many milliseconds. */
34
+ timeoutMs?: number;
17
35
  dryRun?: boolean;
18
36
  };
19
37
 
@@ -29,6 +47,8 @@ export type RunDatabaseCommandResult = {
29
47
  args: string[];
30
48
  stdout?: string;
31
49
  stderr?: string;
50
+ outputTruncated?: boolean;
51
+ timedOut?: boolean;
32
52
  dryRun: boolean;
33
53
  exitCode: number;
34
54
  };
@@ -56,17 +76,18 @@ type ProcessTerminationPlan =
56
76
  | { kind: "process-group"; pid: number; signal: NodeJS.Signals }
57
77
  | { kind: "windows-process-tree"; command: string; args: string[] };
58
78
 
59
- export type DatabaseSchemaDialect = "sqlite" | "postgres" | "mysql";
60
- export type DatabaseSchemaTable = "audit" | "idempotency" | "outbox";
61
-
79
+ /** Options for syncing app-owned Beignet provider-table schema re-exports. */
62
80
  export type SyncDatabaseSchemaOptions = {
63
81
  cwd?: string;
64
82
  dialect?: DatabaseSchemaDialect;
65
83
  tables?: readonly DatabaseSchemaTable[];
66
84
  output?: string;
67
85
  dryRun?: boolean;
86
+ /** Cancel before applying pending schema-file writes. */
87
+ signal?: AbortSignal;
68
88
  };
69
89
 
90
+ /** Versioned report returned by `syncDatabaseSchema`. */
70
91
  export type SyncDatabaseSchemaResult = {
71
92
  schemaVersion: 1;
72
93
  command: "schema:sync";
@@ -92,12 +113,6 @@ const databaseScripts: Record<DatabaseCommand, string> = {
92
113
  reset: "db:reset",
93
114
  };
94
115
 
95
- const allDatabaseSchemaTables = [
96
- "audit",
97
- "idempotency",
98
- "outbox",
99
- ] as const satisfies readonly DatabaseSchemaTable[];
100
-
101
116
  /**
102
117
  * Run an app-owned database lifecycle script.
103
118
  *
@@ -108,6 +123,7 @@ const allDatabaseSchemaTables = [
108
123
  export async function runDatabaseCommand(
109
124
  options: RunDatabaseCommandOptions,
110
125
  ): Promise<RunDatabaseCommandResult> {
126
+ options.signal?.throwIfAborted();
111
127
  const cwd = path.resolve(options.cwd ?? process.cwd());
112
128
  const script = databaseScripts[options.command];
113
129
  const packageJson = await readPackageJson(cwd);
@@ -118,6 +134,7 @@ export async function runDatabaseCommand(
118
134
  }
119
135
 
120
136
  await assertDatabaseCommandPreflight(cwd, options.command, scriptCommand);
137
+ options.signal?.throwIfAborted();
121
138
 
122
139
  const runner = await detectPackageManager(cwd);
123
140
  const args = ["run", script];
@@ -137,6 +154,9 @@ export async function runDatabaseCommand(
137
154
 
138
155
  const commandResult = await spawnCommand(runner, args, cwd, {
139
156
  captureOutput: Boolean(options.captureOutput),
157
+ maxOutputBytes: options.maxOutputBytes,
158
+ signal: options.signal,
159
+ timeoutMs: options.timeoutMs,
140
160
  });
141
161
 
142
162
  return {
@@ -151,6 +171,8 @@ export async function runDatabaseCommand(
151
171
  ...(options.captureOutput
152
172
  ? { stdout: commandResult.stdout, stderr: commandResult.stderr }
153
173
  : {}),
174
+ ...(commandResult.outputTruncated ? { outputTruncated: true } : {}),
175
+ ...(commandResult.timedOut ? { timedOut: true } : {}),
154
176
  };
155
177
  }
156
178
 
@@ -164,9 +186,12 @@ export async function runDatabaseCommand(
164
186
  export async function syncDatabaseSchema(
165
187
  options: SyncDatabaseSchemaOptions = {},
166
188
  ): Promise<SyncDatabaseSchemaResult> {
189
+ options.signal?.throwIfAborted();
167
190
  const cwd = path.resolve(options.cwd ?? process.cwd());
168
191
  const dialect = await resolveDatabaseSchemaDialect(cwd, options.dialect);
169
- const tables = uniqueSchemaTables(options.tables ?? allDatabaseSchemaTables);
192
+ const tables = uniqueSchemaTables(
193
+ options.tables ?? databaseSchemaTableChoices,
194
+ );
170
195
  const output = normalizeAppRelativePath(
171
196
  cwd,
172
197
  options.output ?? "infra/db/schema/beignet.ts",
@@ -196,6 +221,7 @@ export async function syncDatabaseSchema(
196
221
  dryRun: Boolean(options.dryRun),
197
222
  };
198
223
 
224
+ options.signal?.throwIfAborted();
199
225
  const schemaStatus = await writeProjectFile(
200
226
  cwd,
201
227
  output,
@@ -393,7 +419,7 @@ const schemaTableDefinitions: Record<
393
419
  function uniqueSchemaTables(
394
420
  tables: readonly DatabaseSchemaTable[],
395
421
  ): DatabaseSchemaTable[] {
396
- const unique = allDatabaseSchemaTables.filter((table) =>
422
+ const unique = databaseSchemaTableChoices.filter((table) =>
397
423
  tables.includes(table),
398
424
  );
399
425
  if (unique.length === 0) {
@@ -589,6 +615,9 @@ export function spawnCommand(
589
615
  });
590
616
  child.on("close", (code) => {
591
617
  if (settled) return;
618
+ if (timedOut && ownsProcessGroup && forceKillTimeout) {
619
+ terminateChild(child, "SIGKILL", ownsProcessGroup);
620
+ }
592
621
  settled = true;
593
622
  cleanup();
594
623
  resolve({
@@ -617,6 +646,7 @@ export function spawnCommand(
617
646
  timedOut = true;
618
647
  terminateChild(child, "SIGTERM", ownsProcessGroup);
619
648
  forceKillTimeout = setTimeout(() => {
649
+ forceKillTimeout = undefined;
620
650
  if (!settled) terminateChild(child, "SIGKILL", ownsProcessGroup);
621
651
  }, 1_000);
622
652
  forceKillTimeout.unref?.();
@@ -0,0 +1,252 @@
1
+ import { createHash } from "node:crypto";
2
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { createTwoFilesPatch } from "diff";
5
+ import { type DoctorFixOperationId, doctorFixOperationIds } from "./choices.js";
6
+
7
+ export type { DoctorFixOperationId } from "./choices.js";
8
+ export { doctorFixOperationIds } from "./choices.js";
9
+
10
+ /** Automatic doctor fix that was applied. */
11
+ export type InspectFix = {
12
+ code: string;
13
+ message: string;
14
+ file: string;
15
+ };
16
+
17
+ /** Exact file mutation proposed by a doctor fix operation. */
18
+ export type DoctorFixFileChange = {
19
+ file: string;
20
+ kind: "create" | "update";
21
+ beforeHash: string | null;
22
+ afterHash: string;
23
+ patch: string;
24
+ };
25
+
26
+ /** Selectable low-risk repair and every file change it requires. */
27
+ export type DoctorFixOperation = {
28
+ id: DoctorFixOperationId;
29
+ fixes: InspectFix[];
30
+ changes: DoctorFixFileChange[];
31
+ };
32
+
33
+ /** Deterministic, read-only repair plan for the current app state. */
34
+ export type DoctorFixPlan = {
35
+ schemaVersion: 1;
36
+ targetDir: string;
37
+ strict: boolean;
38
+ planId: string;
39
+ operations: DoctorFixOperation[];
40
+ };
41
+
42
+ /** Result of applying all or part of a guarded doctor fix plan. */
43
+ export type DoctorFixApplyResult = {
44
+ schemaVersion: 1;
45
+ targetDir: string;
46
+ planId: string;
47
+ operationIds: DoctorFixOperationId[];
48
+ fixes: InspectFix[];
49
+ };
50
+
51
+ export type PlannedDoctorFixFileChange = DoctorFixFileChange & {
52
+ before: string | undefined;
53
+ after: string;
54
+ };
55
+
56
+ export type PlannedDoctorFixOperation = Omit<DoctorFixOperation, "changes"> & {
57
+ changes: PlannedDoctorFixFileChange[];
58
+ };
59
+
60
+ export type PlannedDoctorFixPlan = Omit<DoctorFixPlan, "operations"> & {
61
+ operations: PlannedDoctorFixOperation[];
62
+ };
63
+
64
+ export function planDoctorFixFileChange(options: {
65
+ file: string;
66
+ before?: string;
67
+ after: string;
68
+ }): PlannedDoctorFixFileChange {
69
+ const oldName =
70
+ options.before === undefined ? "/dev/null" : `a/${options.file}`;
71
+ const newName = `b/${options.file}`;
72
+ return {
73
+ file: options.file,
74
+ kind: options.before === undefined ? "create" : "update",
75
+ beforeHash:
76
+ options.before === undefined ? null : contentHash(options.before),
77
+ afterHash: contentHash(options.after),
78
+ patch: createTwoFilesPatch(
79
+ oldName,
80
+ newName,
81
+ options.before ?? "",
82
+ options.after,
83
+ "",
84
+ "",
85
+ { context: 3 },
86
+ ),
87
+ before: options.before,
88
+ after: options.after,
89
+ };
90
+ }
91
+
92
+ export function createDoctorFixPlan(options: {
93
+ targetDir: string;
94
+ strict: boolean;
95
+ operations: PlannedDoctorFixOperation[];
96
+ }): PlannedDoctorFixPlan {
97
+ const operationOrder = new Map(
98
+ doctorFixOperationIds.map((id, index) => [id, index]),
99
+ );
100
+ const operations = [...options.operations].sort(
101
+ (left, right) =>
102
+ (operationOrder.get(left.id) ?? Number.MAX_SAFE_INTEGER) -
103
+ (operationOrder.get(right.id) ?? Number.MAX_SAFE_INTEGER),
104
+ );
105
+ const fingerprint = {
106
+ targetDir: path.resolve(options.targetDir),
107
+ operations: operations.map((operation) => ({
108
+ id: operation.id,
109
+ changes: operation.changes.map((change) => ({
110
+ file: change.file,
111
+ beforeHash: change.beforeHash,
112
+ afterHash: change.afterHash,
113
+ })),
114
+ })),
115
+ };
116
+
117
+ return {
118
+ schemaVersion: 1,
119
+ targetDir: path.resolve(options.targetDir),
120
+ strict: options.strict,
121
+ planId: contentHash(JSON.stringify(fingerprint)),
122
+ operations,
123
+ };
124
+ }
125
+
126
+ export function publicDoctorFixPlan(plan: PlannedDoctorFixPlan): DoctorFixPlan {
127
+ return {
128
+ ...plan,
129
+ operations: plan.operations.map((operation) => ({
130
+ id: operation.id,
131
+ fixes: operation.fixes,
132
+ changes: operation.changes.map(
133
+ ({ before: _before, after: _after, ...change }) => change,
134
+ ),
135
+ })),
136
+ };
137
+ }
138
+
139
+ export async function applyPlannedDoctorFixes(
140
+ plan: PlannedDoctorFixPlan,
141
+ fixIds?: readonly DoctorFixOperationId[],
142
+ hooks: {
143
+ beforeWrite?: (
144
+ change: PlannedDoctorFixFileChange,
145
+ index: number,
146
+ ) => void | Promise<void>;
147
+ } = {},
148
+ ): Promise<DoctorFixApplyResult> {
149
+ const requested = fixIds ? new Set(fixIds) : undefined;
150
+ const operations = requested
151
+ ? plan.operations.filter((operation) => requested.has(operation.id))
152
+ : plan.operations;
153
+
154
+ if (requested) {
155
+ const available = new Set(plan.operations.map((operation) => operation.id));
156
+ const missing = [...requested].filter((id) => !available.has(id));
157
+ if (missing.length > 0) {
158
+ throw new Error(
159
+ `Doctor fix operations are not available in this plan: ${missing.join(", ")}. Run beignet doctor --fix --dry-run again.`,
160
+ );
161
+ }
162
+ }
163
+
164
+ const changes = operations.flatMap((operation) => operation.changes);
165
+ const duplicateFiles = duplicateValues(changes.map((change) => change.file));
166
+ if (duplicateFiles.length > 0) {
167
+ throw new Error(
168
+ `Doctor fix operations overlap on ${duplicateFiles.join(", ")}; no files were changed.`,
169
+ );
170
+ }
171
+
172
+ for (const change of changes) {
173
+ const current = await readOptionalFile(
174
+ path.join(plan.targetDir, change.file),
175
+ );
176
+ const currentHash = current === undefined ? null : contentHash(current);
177
+ if (currentHash !== change.beforeHash) {
178
+ throw new Error(
179
+ `Doctor fix plan is stale because ${change.file} changed. No files were changed; run beignet doctor --fix --dry-run again.`,
180
+ );
181
+ }
182
+ }
183
+
184
+ const attempted: PlannedDoctorFixFileChange[] = [];
185
+ try {
186
+ for (const [index, change] of changes.entries()) {
187
+ attempted.push(change);
188
+ await hooks.beforeWrite?.(change, index);
189
+ const destination = path.join(plan.targetDir, change.file);
190
+ await mkdir(path.dirname(destination), { recursive: true });
191
+ await writeFile(destination, change.after);
192
+ }
193
+ } catch (error) {
194
+ const rollbackErrors: string[] = [];
195
+ for (const change of attempted.reverse()) {
196
+ const destination = path.join(plan.targetDir, change.file);
197
+ try {
198
+ if (change.before === undefined) {
199
+ await rm(destination, { force: true });
200
+ } else {
201
+ await mkdir(path.dirname(destination), { recursive: true });
202
+ await writeFile(destination, change.before);
203
+ }
204
+ } catch (rollbackError) {
205
+ rollbackErrors.push(
206
+ rollbackError instanceof Error
207
+ ? rollbackError.message
208
+ : String(rollbackError),
209
+ );
210
+ }
211
+ }
212
+
213
+ const message = error instanceof Error ? error.message : String(error);
214
+ throw new Error(
215
+ rollbackErrors.length === 0
216
+ ? `Doctor fix application failed and was rolled back: ${message}`
217
+ : `Doctor fix application failed: ${message}. Rollback also failed: ${rollbackErrors.join("; ")}`,
218
+ { cause: error },
219
+ );
220
+ }
221
+
222
+ return {
223
+ schemaVersion: 1,
224
+ targetDir: plan.targetDir,
225
+ planId: plan.planId,
226
+ operationIds: operations.map((operation) => operation.id),
227
+ fixes: operations.flatMap((operation) => operation.fixes),
228
+ };
229
+ }
230
+
231
+ function contentHash(value: string): string {
232
+ return createHash("sha256").update(value).digest("hex");
233
+ }
234
+
235
+ async function readOptionalFile(file: string): Promise<string | undefined> {
236
+ try {
237
+ return await readFile(file, "utf8");
238
+ } catch (error) {
239
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
240
+ throw error;
241
+ }
242
+ }
243
+
244
+ function duplicateValues(values: readonly string[]): string[] {
245
+ const seen = new Set<string>();
246
+ const duplicates = new Set<string>();
247
+ for (const value of values) {
248
+ if (seen.has(value)) duplicates.add(value);
249
+ seen.add(value);
250
+ }
251
+ return [...duplicates].sort();
252
+ }