@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.
- package/CHANGELOG.md +10 -0
- package/README.md +77 -21
- package/dist/choices.d.ts +18 -0
- package/dist/choices.d.ts.map +1 -1
- package/dist/choices.js +35 -0
- package/dist/choices.js.map +1 -1
- package/dist/db.d.ts +18 -7
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +20 -7
- package/dist/db.js.map +1 -1
- package/dist/doctor-fixes.d.ts +64 -0
- package/dist/doctor-fixes.d.ts.map +1 -0
- package/dist/doctor-fixes.js +142 -0
- package/dist/doctor-fixes.js.map +1 -0
- package/dist/explain.d.ts +3 -1
- package/dist/explain.d.ts.map +1 -1
- package/dist/explain.js +136 -42
- package/dist/explain.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +92 -25
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +33 -9
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +353 -116
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +6 -2
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +3 -2
- package/dist/lib.js.map +1 -1
- package/dist/make/shared.js +3 -3
- package/dist/make/shared.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +121 -13
- package/dist/mcp.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +26 -10
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +3 -3
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/shared.d.ts +2 -1
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +7 -4
- package/dist/templates/shared.js.map +1 -1
- package/package.json +3 -2
- package/skills/app-structure/SKILL.md +34 -10
- package/src/choices.ts +57 -0
- package/src/db.ts +45 -15
- package/src/doctor-fixes.ts +252 -0
- package/src/explain.ts +151 -43
- package/src/index.ts +130 -35
- package/src/inspect.ts +497 -145
- package/src/lib.ts +28 -1
- package/src/make/shared.ts +3 -3
- package/src/mcp.ts +187 -13
- package/src/templates/agents.ts +26 -10
- package/src/templates/base.ts +3 -2
- 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
|
-
|
|
7
|
-
*/
|
|
8
|
-
export
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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
|
+
}
|