@beignet/cli 0.0.40 → 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 +19 -0
- package/README.md +139 -17
- package/dist/analysis/source-index.d.ts +38 -0
- package/dist/analysis/source-index.d.ts.map +1 -0
- package/dist/analysis/source-index.js +271 -0
- package/dist/analysis/source-index.js.map +1 -0
- package/dist/analysis/workspace.d.ts +16 -0
- package/dist/analysis/workspace.d.ts.map +1 -0
- package/dist/analysis/workspace.js +134 -0
- package/dist/analysis/workspace.js.map +1 -0
- package/dist/app-map-schema.d.ts +5 -0
- package/dist/app-map-schema.d.ts.map +1 -0
- package/dist/app-map-schema.js +26 -0
- package/dist/app-map-schema.js.map +1 -0
- package/dist/app-map.d.ts +96 -0
- package/dist/app-map.d.ts.map +1 -0
- package/dist/app-map.js +1141 -0
- package/dist/app-map.js.map +1 -0
- package/dist/check.d.ts +7 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +20 -5
- package/dist/check.js.map +1 -1
- 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 +49 -13
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +202 -21
- 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 +100 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +606 -0
- package/dist/explain.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +165 -21
- 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 +406 -119
- package/dist/inspect.js.map +1 -1
- package/dist/lib.d.ts +10 -2
- package/dist/lib.d.ts.map +1 -1
- package/dist/lib.js +5 -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/make.d.ts.map +1 -1
- package/dist/make.js +63 -2
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +188 -12
- package/dist/mcp.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +10 -0
- package/dist/preflight.js.map +1 -1
- package/dist/provider-add.d.ts.map +1 -1
- package/dist/provider-add.js +167 -13
- package/dist/provider-add.js.map +1 -1
- package/dist/provider-audit.d.ts +6 -0
- package/dist/provider-audit.d.ts.map +1 -1
- package/dist/provider-audit.js +63 -15
- package/dist/provider-audit.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +35 -6
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +13 -7
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/index.d.ts +1 -1
- package/dist/templates/index.d.ts.map +1 -1
- package/dist/templates/index.js +8 -1
- package/dist/templates/index.js.map +1 -1
- package/dist/templates/server.d.ts +3 -0
- package/dist/templates/server.d.ts.map +1 -1
- package/dist/templates/server.js +55 -1
- package/dist/templates/server.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 +8 -5
- package/dist/templates/shared.js.map +1 -1
- package/package.json +3 -2
- package/skills/app-structure/SKILL.md +48 -6
- package/src/analysis/source-index.ts +395 -0
- package/src/analysis/workspace.ts +180 -0
- package/src/app-map-schema.ts +28 -0
- package/src/app-map.ts +1705 -0
- package/src/check.ts +27 -4
- package/src/choices.ts +57 -0
- package/src/db.ts +276 -28
- package/src/doctor-fixes.ts +252 -0
- package/src/explain.ts +894 -0
- package/src/index.ts +237 -31
- package/src/inspect.ts +567 -147
- package/src/lib.ts +64 -1
- package/src/make/shared.ts +3 -3
- package/src/make.ts +90 -2
- package/src/mcp.ts +292 -12
- package/src/preflight.ts +14 -0
- package/src/provider-add.ts +211 -12
- package/src/provider-audit.ts +127 -22
- package/src/templates/agents.ts +35 -6
- package/src/templates/base.ts +25 -6
- package/src/templates/index.ts +18 -1
- package/src/templates/server.ts +58 -1
- package/src/templates/shared.ts +15 -7
package/src/check.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type CheckStep = {
|
|
|
22
22
|
status: "passed" | "failed" | "skipped";
|
|
23
23
|
detail?: string;
|
|
24
24
|
output?: string;
|
|
25
|
+
outputTruncated?: boolean;
|
|
25
26
|
durationMs: number;
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -40,11 +41,18 @@ export type CheckAppOptions = {
|
|
|
40
41
|
color?: boolean;
|
|
41
42
|
/** Append a `beignet preflight` step after the doctor step. */
|
|
42
43
|
preflight?: boolean;
|
|
44
|
+
/** Keep only the last N bytes from each package-script output stream. */
|
|
45
|
+
maxOutputBytes?: number;
|
|
46
|
+
/** Cancel the active package script with the owning request. */
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
/** Fail each package script after this many milliseconds. */
|
|
49
|
+
scriptTimeoutMs?: number;
|
|
43
50
|
/** Called after each step completes, for incremental progress output. */
|
|
44
51
|
onStep?: (step: CheckStep) => void;
|
|
45
52
|
};
|
|
46
53
|
|
|
47
54
|
const scriptStepNames = ["lint", "typecheck", "test"] as const;
|
|
55
|
+
const defaultMaxOutputBytes = 64 * 1024;
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
58
|
* Run the whole app validation loop as one command: `beignet lint`,
|
|
@@ -67,17 +75,21 @@ export async function checkApp(
|
|
|
67
75
|
options.onStep?.(step);
|
|
68
76
|
};
|
|
69
77
|
|
|
78
|
+
options.signal?.throwIfAborted();
|
|
70
79
|
record(await lintStep(targetDir, options));
|
|
80
|
+
options.signal?.throwIfAborted();
|
|
71
81
|
const { step: doctorStep, fixes } = await runDoctorStep(targetDir, options);
|
|
72
82
|
record(doctorStep);
|
|
73
83
|
|
|
74
84
|
if (options.preflight) {
|
|
85
|
+
options.signal?.throwIfAborted();
|
|
75
86
|
record(await preflightStep(targetDir));
|
|
76
87
|
}
|
|
77
88
|
|
|
78
89
|
const scripts = await readPackageScripts(targetDir);
|
|
79
90
|
for (const script of scriptStepNames) {
|
|
80
|
-
|
|
91
|
+
options.signal?.throwIfAborted();
|
|
92
|
+
record(await scriptStep(targetDir, runner, script, scripts, options));
|
|
81
93
|
}
|
|
82
94
|
|
|
83
95
|
return {
|
|
@@ -114,7 +126,10 @@ export function formatCheckStep(
|
|
|
114
126
|
.split("\n")
|
|
115
127
|
.map((outputLine) => ` ${outputLine}`)
|
|
116
128
|
.join("\n");
|
|
117
|
-
|
|
129
|
+
const truncation = step.outputTruncated
|
|
130
|
+
? "\n … output truncated; showing the retained tail"
|
|
131
|
+
: "";
|
|
132
|
+
return `${line}\n${indented}${truncation}`;
|
|
118
133
|
}
|
|
119
134
|
|
|
120
135
|
/**
|
|
@@ -203,6 +218,7 @@ async function scriptStep(
|
|
|
203
218
|
runner: string,
|
|
204
219
|
script: (typeof scriptStepNames)[number],
|
|
205
220
|
scripts: Record<string, string>,
|
|
221
|
+
options: CheckAppOptions,
|
|
206
222
|
): Promise<CheckStep> {
|
|
207
223
|
const command = `${runner} run ${script}`;
|
|
208
224
|
if (!scripts[script]) {
|
|
@@ -218,17 +234,24 @@ async function scriptStep(
|
|
|
218
234
|
const startedAt = Date.now();
|
|
219
235
|
const result = await spawnCommand(runner, ["run", script], targetDir, {
|
|
220
236
|
captureOutput: true,
|
|
237
|
+
maxOutputBytes: options.maxOutputBytes ?? defaultMaxOutputBytes,
|
|
238
|
+
signal: options.signal,
|
|
239
|
+
timeoutMs: options.scriptTimeoutMs,
|
|
221
240
|
});
|
|
222
|
-
const failed = result.exitCode !== 0;
|
|
241
|
+
const failed = result.timedOut === true || result.exitCode !== 0;
|
|
242
|
+
const detail = result.timedOut
|
|
243
|
+
? `${command}; timed out after ${options.scriptTimeoutMs}ms`
|
|
244
|
+
: command;
|
|
223
245
|
|
|
224
246
|
return {
|
|
225
247
|
name: script,
|
|
226
248
|
command,
|
|
227
249
|
status: failed ? "failed" : "passed",
|
|
228
|
-
detail
|
|
250
|
+
detail,
|
|
229
251
|
...(failed
|
|
230
252
|
? { output: [result.stdout, result.stderr].filter(Boolean).join("\n") }
|
|
231
253
|
: {}),
|
|
254
|
+
...(failed && result.outputTruncated ? { outputTruncated: true } : {}),
|
|
232
255
|
durationMs: Date.now() - startedAt,
|
|
233
256
|
};
|
|
234
257
|
}
|
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
|
-
import { spawn } from "node:child_process";
|
|
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,21 +47,47 @@ 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
|
};
|
|
35
55
|
|
|
36
|
-
export type
|
|
37
|
-
|
|
56
|
+
export type SpawnCommandOptions = {
|
|
57
|
+
captureOutput: boolean;
|
|
58
|
+
/** Keep only the last N bytes from each output stream. Unbounded when omitted. */
|
|
59
|
+
maxOutputBytes?: number;
|
|
60
|
+
/** Cancel the child process when the owning request is aborted. */
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
/** Stop the child process after this many milliseconds. Unbounded when omitted. */
|
|
63
|
+
timeoutMs?: number;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export type SpawnCommandResult = {
|
|
67
|
+
exitCode: number;
|
|
68
|
+
stdout?: string;
|
|
69
|
+
stderr?: string;
|
|
70
|
+
outputTruncated?: boolean;
|
|
71
|
+
timedOut?: boolean;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
type ProcessTerminationPlan =
|
|
75
|
+
| { kind: "child"; signal: NodeJS.Signals }
|
|
76
|
+
| { kind: "process-group"; pid: number; signal: NodeJS.Signals }
|
|
77
|
+
| { kind: "windows-process-tree"; command: string; args: string[] };
|
|
38
78
|
|
|
79
|
+
/** Options for syncing app-owned Beignet provider-table schema re-exports. */
|
|
39
80
|
export type SyncDatabaseSchemaOptions = {
|
|
40
81
|
cwd?: string;
|
|
41
82
|
dialect?: DatabaseSchemaDialect;
|
|
42
83
|
tables?: readonly DatabaseSchemaTable[];
|
|
43
84
|
output?: string;
|
|
44
85
|
dryRun?: boolean;
|
|
86
|
+
/** Cancel before applying pending schema-file writes. */
|
|
87
|
+
signal?: AbortSignal;
|
|
45
88
|
};
|
|
46
89
|
|
|
90
|
+
/** Versioned report returned by `syncDatabaseSchema`. */
|
|
47
91
|
export type SyncDatabaseSchemaResult = {
|
|
48
92
|
schemaVersion: 1;
|
|
49
93
|
command: "schema:sync";
|
|
@@ -69,12 +113,6 @@ const databaseScripts: Record<DatabaseCommand, string> = {
|
|
|
69
113
|
reset: "db:reset",
|
|
70
114
|
};
|
|
71
115
|
|
|
72
|
-
const allDatabaseSchemaTables = [
|
|
73
|
-
"audit",
|
|
74
|
-
"idempotency",
|
|
75
|
-
"outbox",
|
|
76
|
-
] as const satisfies readonly DatabaseSchemaTable[];
|
|
77
|
-
|
|
78
116
|
/**
|
|
79
117
|
* Run an app-owned database lifecycle script.
|
|
80
118
|
*
|
|
@@ -85,6 +123,7 @@ const allDatabaseSchemaTables = [
|
|
|
85
123
|
export async function runDatabaseCommand(
|
|
86
124
|
options: RunDatabaseCommandOptions,
|
|
87
125
|
): Promise<RunDatabaseCommandResult> {
|
|
126
|
+
options.signal?.throwIfAborted();
|
|
88
127
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
89
128
|
const script = databaseScripts[options.command];
|
|
90
129
|
const packageJson = await readPackageJson(cwd);
|
|
@@ -95,6 +134,7 @@ export async function runDatabaseCommand(
|
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
await assertDatabaseCommandPreflight(cwd, options.command, scriptCommand);
|
|
137
|
+
options.signal?.throwIfAborted();
|
|
98
138
|
|
|
99
139
|
const runner = await detectPackageManager(cwd);
|
|
100
140
|
const args = ["run", script];
|
|
@@ -114,6 +154,9 @@ export async function runDatabaseCommand(
|
|
|
114
154
|
|
|
115
155
|
const commandResult = await spawnCommand(runner, args, cwd, {
|
|
116
156
|
captureOutput: Boolean(options.captureOutput),
|
|
157
|
+
maxOutputBytes: options.maxOutputBytes,
|
|
158
|
+
signal: options.signal,
|
|
159
|
+
timeoutMs: options.timeoutMs,
|
|
117
160
|
});
|
|
118
161
|
|
|
119
162
|
return {
|
|
@@ -128,6 +171,8 @@ export async function runDatabaseCommand(
|
|
|
128
171
|
...(options.captureOutput
|
|
129
172
|
? { stdout: commandResult.stdout, stderr: commandResult.stderr }
|
|
130
173
|
: {}),
|
|
174
|
+
...(commandResult.outputTruncated ? { outputTruncated: true } : {}),
|
|
175
|
+
...(commandResult.timedOut ? { timedOut: true } : {}),
|
|
131
176
|
};
|
|
132
177
|
}
|
|
133
178
|
|
|
@@ -141,9 +186,12 @@ export async function runDatabaseCommand(
|
|
|
141
186
|
export async function syncDatabaseSchema(
|
|
142
187
|
options: SyncDatabaseSchemaOptions = {},
|
|
143
188
|
): Promise<SyncDatabaseSchemaResult> {
|
|
189
|
+
options.signal?.throwIfAborted();
|
|
144
190
|
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
145
191
|
const dialect = await resolveDatabaseSchemaDialect(cwd, options.dialect);
|
|
146
|
-
const tables = uniqueSchemaTables(
|
|
192
|
+
const tables = uniqueSchemaTables(
|
|
193
|
+
options.tables ?? databaseSchemaTableChoices,
|
|
194
|
+
);
|
|
147
195
|
const output = normalizeAppRelativePath(
|
|
148
196
|
cwd,
|
|
149
197
|
options.output ?? "infra/db/schema/beignet.ts",
|
|
@@ -173,6 +221,7 @@ export async function syncDatabaseSchema(
|
|
|
173
221
|
dryRun: Boolean(options.dryRun),
|
|
174
222
|
};
|
|
175
223
|
|
|
224
|
+
options.signal?.throwIfAborted();
|
|
176
225
|
const schemaStatus = await writeProjectFile(
|
|
177
226
|
cwd,
|
|
178
227
|
output,
|
|
@@ -370,7 +419,7 @@ const schemaTableDefinitions: Record<
|
|
|
370
419
|
function uniqueSchemaTables(
|
|
371
420
|
tables: readonly DatabaseSchemaTable[],
|
|
372
421
|
): DatabaseSchemaTable[] {
|
|
373
|
-
const unique =
|
|
422
|
+
const unique = databaseSchemaTableChoices.filter((table) =>
|
|
374
423
|
tables.includes(table),
|
|
375
424
|
);
|
|
376
425
|
if (unique.length === 0) {
|
|
@@ -504,34 +553,233 @@ export function spawnCommand(
|
|
|
504
553
|
command: string,
|
|
505
554
|
args: readonly string[],
|
|
506
555
|
cwd: string,
|
|
507
|
-
options:
|
|
508
|
-
): Promise<
|
|
556
|
+
options: SpawnCommandOptions,
|
|
557
|
+
): Promise<SpawnCommandResult> {
|
|
558
|
+
if (options.signal?.aborted) {
|
|
559
|
+
return Promise.reject(commandAbortError(options.signal));
|
|
560
|
+
}
|
|
561
|
+
const timeoutMs =
|
|
562
|
+
options.timeoutMs === undefined
|
|
563
|
+
? undefined
|
|
564
|
+
: normalizePositiveInteger(options.timeoutMs, "Command timeout");
|
|
565
|
+
const maxOutputBytes =
|
|
566
|
+
options.maxOutputBytes === undefined
|
|
567
|
+
? undefined
|
|
568
|
+
: normalizeNonNegativeInteger(
|
|
569
|
+
options.maxOutputBytes,
|
|
570
|
+
"Maximum output bytes",
|
|
571
|
+
);
|
|
572
|
+
|
|
509
573
|
return new Promise((resolve, reject) => {
|
|
574
|
+
let settled = false;
|
|
575
|
+
let timedOut = false;
|
|
576
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
577
|
+
let forceKillTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
578
|
+
const ownsProcessGroup = commandOwnsProcessGroup(process.platform, {
|
|
579
|
+
signal: options.signal,
|
|
580
|
+
timeoutMs,
|
|
581
|
+
});
|
|
510
582
|
const child = spawn(command, args, {
|
|
511
583
|
cwd,
|
|
584
|
+
detached: ownsProcessGroup,
|
|
512
585
|
env: process.env,
|
|
513
586
|
stdio: options.captureOutput ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
514
587
|
});
|
|
515
588
|
|
|
516
|
-
const stdout
|
|
517
|
-
const stderr
|
|
589
|
+
const stdout = createOutputCapture(maxOutputBytes);
|
|
590
|
+
const stderr = createOutputCapture(maxOutputBytes);
|
|
591
|
+
|
|
592
|
+
const cleanup = () => {
|
|
593
|
+
if (timeout) clearTimeout(timeout);
|
|
594
|
+
if (forceKillTimeout) clearTimeout(forceKillTimeout);
|
|
595
|
+
options.signal?.removeEventListener("abort", abort);
|
|
596
|
+
};
|
|
597
|
+
const abort = () => {
|
|
598
|
+
if (settled) return;
|
|
599
|
+
settled = true;
|
|
600
|
+
terminateChild(child, "SIGKILL", ownsProcessGroup);
|
|
601
|
+
cleanup();
|
|
602
|
+
reject(commandAbortError(options.signal));
|
|
603
|
+
};
|
|
518
604
|
|
|
519
605
|
if (options.captureOutput) {
|
|
520
|
-
child.stdout?.on("data", (chunk: Buffer) => stdout.
|
|
521
|
-
child.stderr?.on("data", (chunk: Buffer) => stderr.
|
|
606
|
+
child.stdout?.on("data", (chunk: Buffer) => stdout.append(chunk));
|
|
607
|
+
child.stderr?.on("data", (chunk: Buffer) => stderr.append(chunk));
|
|
522
608
|
}
|
|
523
609
|
|
|
524
|
-
child.on("error",
|
|
525
|
-
|
|
610
|
+
child.on("error", (error) => {
|
|
611
|
+
if (settled) return;
|
|
612
|
+
settled = true;
|
|
613
|
+
cleanup();
|
|
614
|
+
reject(error);
|
|
615
|
+
});
|
|
616
|
+
child.on("close", (code) => {
|
|
617
|
+
if (settled) return;
|
|
618
|
+
if (timedOut && ownsProcessGroup && forceKillTimeout) {
|
|
619
|
+
terminateChild(child, "SIGKILL", ownsProcessGroup);
|
|
620
|
+
}
|
|
621
|
+
settled = true;
|
|
622
|
+
cleanup();
|
|
526
623
|
resolve({
|
|
527
|
-
exitCode: code ?? 1,
|
|
624
|
+
exitCode: timedOut ? 1 : (code ?? 1),
|
|
528
625
|
...(options.captureOutput
|
|
529
626
|
? {
|
|
530
|
-
stdout:
|
|
531
|
-
stderr:
|
|
627
|
+
stdout: stdout.text(),
|
|
628
|
+
stderr: stderr.text(),
|
|
629
|
+
...(stdout.truncated() || stderr.truncated()
|
|
630
|
+
? { outputTruncated: true }
|
|
631
|
+
: {}),
|
|
532
632
|
}
|
|
533
633
|
: {}),
|
|
534
|
-
|
|
535
|
-
|
|
634
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
635
|
+
});
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
639
|
+
if (options.signal?.aborted) {
|
|
640
|
+
abort();
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
if (timeoutMs !== undefined) {
|
|
644
|
+
timeout = setTimeout(() => {
|
|
645
|
+
if (settled) return;
|
|
646
|
+
timedOut = true;
|
|
647
|
+
terminateChild(child, "SIGTERM", ownsProcessGroup);
|
|
648
|
+
forceKillTimeout = setTimeout(() => {
|
|
649
|
+
forceKillTimeout = undefined;
|
|
650
|
+
if (!settled) terminateChild(child, "SIGKILL", ownsProcessGroup);
|
|
651
|
+
}, 1_000);
|
|
652
|
+
forceKillTimeout.unref?.();
|
|
653
|
+
}, timeoutMs);
|
|
654
|
+
timeout.unref?.();
|
|
655
|
+
}
|
|
536
656
|
});
|
|
537
657
|
}
|
|
658
|
+
|
|
659
|
+
export function commandOwnsProcessGroup(
|
|
660
|
+
platform: NodeJS.Platform,
|
|
661
|
+
options: Pick<SpawnCommandOptions, "signal" | "timeoutMs">,
|
|
662
|
+
): boolean {
|
|
663
|
+
return (
|
|
664
|
+
platform !== "win32" &&
|
|
665
|
+
(options.signal !== undefined || options.timeoutMs !== undefined)
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function commandTerminationPlan(
|
|
670
|
+
platform: NodeJS.Platform,
|
|
671
|
+
pid: number | undefined,
|
|
672
|
+
signal: NodeJS.Signals,
|
|
673
|
+
ownsProcessGroup: boolean,
|
|
674
|
+
): ProcessTerminationPlan {
|
|
675
|
+
if (platform === "win32" && pid !== undefined) {
|
|
676
|
+
return {
|
|
677
|
+
kind: "windows-process-tree",
|
|
678
|
+
command: "taskkill",
|
|
679
|
+
args: ["/PID", String(pid), "/T", "/F"],
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
if (ownsProcessGroup && pid !== undefined) {
|
|
683
|
+
return { kind: "process-group", pid: -pid, signal };
|
|
684
|
+
}
|
|
685
|
+
return { kind: "child", signal };
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function terminateChild(
|
|
689
|
+
child: ChildProcess,
|
|
690
|
+
signal: NodeJS.Signals,
|
|
691
|
+
ownsProcessGroup: boolean,
|
|
692
|
+
): void {
|
|
693
|
+
const plan = commandTerminationPlan(
|
|
694
|
+
process.platform,
|
|
695
|
+
child.pid,
|
|
696
|
+
signal,
|
|
697
|
+
ownsProcessGroup,
|
|
698
|
+
);
|
|
699
|
+
if (plan.kind === "windows-process-tree") {
|
|
700
|
+
const killer = spawn(plan.command, plan.args, {
|
|
701
|
+
stdio: "ignore",
|
|
702
|
+
windowsHide: true,
|
|
703
|
+
});
|
|
704
|
+
const fallback = () => {
|
|
705
|
+
if (child.exitCode === null) child.kill(signal);
|
|
706
|
+
};
|
|
707
|
+
killer.once("error", fallback);
|
|
708
|
+
killer.once("close", (code) => {
|
|
709
|
+
if (code !== 0) fallback();
|
|
710
|
+
});
|
|
711
|
+
killer.unref();
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (plan.kind === "process-group") {
|
|
715
|
+
try {
|
|
716
|
+
process.kill(plan.pid, plan.signal);
|
|
717
|
+
return;
|
|
718
|
+
} catch (error) {
|
|
719
|
+
if ((error as NodeJS.ErrnoException).code === "ESRCH") return;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
child.kill(plan.signal);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function createOutputCapture(maxOutputBytes?: number): {
|
|
726
|
+
append(chunk: Buffer): void;
|
|
727
|
+
text(): string;
|
|
728
|
+
truncated(): boolean;
|
|
729
|
+
} {
|
|
730
|
+
const limit = maxOutputBytes;
|
|
731
|
+
const unboundedChunks: Buffer[] = [];
|
|
732
|
+
let output: Buffer = Buffer.alloc(0);
|
|
733
|
+
let wasTruncated = false;
|
|
734
|
+
|
|
735
|
+
return {
|
|
736
|
+
append(chunk) {
|
|
737
|
+
if (limit === undefined) {
|
|
738
|
+
unboundedChunks.push(chunk);
|
|
739
|
+
return;
|
|
740
|
+
}
|
|
741
|
+
if (limit === 0) {
|
|
742
|
+
if (chunk.length > 0) wasTruncated = true;
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (chunk.length >= limit) {
|
|
746
|
+
wasTruncated ||= output.length > 0 || chunk.length > limit;
|
|
747
|
+
output = Buffer.from(chunk.subarray(chunk.length - limit));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const overflow = output.length + chunk.length - limit;
|
|
751
|
+
if (overflow > 0) {
|
|
752
|
+
wasTruncated = true;
|
|
753
|
+
output = Buffer.concat([output.subarray(overflow), chunk]);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
output = Buffer.concat([output, chunk]);
|
|
757
|
+
},
|
|
758
|
+
text: () =>
|
|
759
|
+
(limit === undefined ? Buffer.concat(unboundedChunks) : output).toString(
|
|
760
|
+
"utf8",
|
|
761
|
+
),
|
|
762
|
+
truncated: () => wasTruncated,
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
function commandAbortError(signal?: AbortSignal): Error {
|
|
767
|
+
if (signal?.reason instanceof Error) return signal.reason;
|
|
768
|
+
const error = new Error("Command cancelled.");
|
|
769
|
+
error.name = "AbortError";
|
|
770
|
+
return error;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function normalizePositiveInteger(value: number, label: string): number {
|
|
774
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
775
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
776
|
+
}
|
|
777
|
+
return value;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function normalizeNonNegativeInteger(value: number, label: string): number {
|
|
781
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
782
|
+
throw new Error(`${label} must be a non-negative integer.`);
|
|
783
|
+
}
|
|
784
|
+
return value;
|
|
785
|
+
}
|