@beignet/cli 0.0.31 → 0.0.32
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 +58 -0
- package/README.md +49 -6
- package/dist/check.d.ts +2 -0
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +16 -0
- package/dist/check.js.map +1 -1
- package/dist/choices.d.ts +22 -2
- package/dist/choices.d.ts.map +1 -1
- package/dist/choices.js +60 -0
- package/dist/choices.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/create-prompts.d.ts +6 -2
- package/dist/create-prompts.d.ts.map +1 -1
- package/dist/create-prompts.js +1 -1
- package/dist/create-prompts.js.map +1 -1
- package/dist/db.d.ts +5 -5
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +4 -4
- package/dist/db.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +107 -26
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts +5 -0
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +8 -4
- package/dist/inspect.js.map +1 -1
- package/dist/make/shared.d.ts +1 -1
- package/dist/make/shared.d.ts.map +1 -1
- package/dist/make/shared.js +8 -1
- package/dist/make/shared.js.map +1 -1
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +7 -10
- package/dist/make.js.map +1 -1
- package/dist/preflight.d.ts +90 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +423 -0
- package/dist/preflight.js.map +1 -0
- package/dist/provider-add.d.ts +10 -1
- package/dist/provider-add.d.ts.map +1 -1
- package/dist/provider-add.js +209 -16
- package/dist/provider-add.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +2 -8
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/server.js +4 -4
- package/dist/templates/shared.d.ts +2 -0
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +2 -0
- package/dist/templates/shared.js.map +1 -1
- package/package.json +2 -2
- package/skills/app-structure/SKILL.md +1 -1
- package/src/check.ts +23 -0
- package/src/choices.ts +81 -0
- package/src/config.ts +2 -0
- package/src/create-prompts.ts +8 -2
- package/src/db.ts +11 -11
- package/src/index.ts +155 -36
- package/src/inspect.ts +8 -4
- package/src/make/shared.ts +9 -2
- package/src/make.ts +7 -11
- package/src/preflight.ts +596 -0
- package/src/provider-add.ts +223 -16
- package/src/templates/base.ts +2 -10
- package/src/templates/server.ts +4 -4
- package/src/templates/shared.ts +2 -0
package/src/preflight.ts
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseProviderPackageMetadata } from "@beignet/core/providers";
|
|
4
|
+
import { createJiti } from "jiti";
|
|
5
|
+
import {
|
|
6
|
+
type InspectDiagnostic,
|
|
7
|
+
inspectApp,
|
|
8
|
+
productionHardeningDiagnosticCodes,
|
|
9
|
+
} from "./inspect.js";
|
|
10
|
+
import {
|
|
11
|
+
installedPackageNames,
|
|
12
|
+
type ProviderPackageJson,
|
|
13
|
+
providerDoctorRulesForInstalledPackages,
|
|
14
|
+
readProviderPackageMetadataSource,
|
|
15
|
+
} from "./provider-audit.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One preflight finding. Errors fail the gate; warnings and notes do not.
|
|
19
|
+
*/
|
|
20
|
+
export interface PreflightFinding {
|
|
21
|
+
severity: "error" | "warning" | "note";
|
|
22
|
+
code: string;
|
|
23
|
+
message: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PreflightOptions {
|
|
27
|
+
cwd: string;
|
|
28
|
+
/**
|
|
29
|
+
* Environment to validate. Defaults to `process.env` so the command checks
|
|
30
|
+
* the environment the deploy pipeline actually runs with.
|
|
31
|
+
*/
|
|
32
|
+
env?: Record<string, string | undefined>;
|
|
33
|
+
/**
|
|
34
|
+
* Optional dotenv-style file merged under the environment (existing env
|
|
35
|
+
* keys win) for local rehearsal of a deploy environment.
|
|
36
|
+
*/
|
|
37
|
+
envFile?: string;
|
|
38
|
+
/**
|
|
39
|
+
* App env module validated by importing it (default `lib/env.ts`). The
|
|
40
|
+
* module's `createEnv(...)` call performs the validation.
|
|
41
|
+
*/
|
|
42
|
+
envModule?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Fold doctor's production hardening diagnostics into the gate. Defaults
|
|
45
|
+
* to `true`.
|
|
46
|
+
*/
|
|
47
|
+
doctor?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Boot the app server and run every port's `checkHealth()`. Needs network
|
|
50
|
+
* access and real credentials, so it is opt-in.
|
|
51
|
+
*/
|
|
52
|
+
connect?: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Module exporting `getServer` (or `server`), used by `connect`. Defaults
|
|
55
|
+
* to `server/index.ts`.
|
|
56
|
+
*/
|
|
57
|
+
serverModule?: string;
|
|
58
|
+
/**
|
|
59
|
+
* Per-dependency health check timeout in milliseconds. Defaults to 5000.
|
|
60
|
+
*/
|
|
61
|
+
connectTimeoutMs?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PreflightResult {
|
|
65
|
+
findings: PreflightFinding[];
|
|
66
|
+
/**
|
|
67
|
+
* Provider packages whose manifests were checked.
|
|
68
|
+
*/
|
|
69
|
+
checkedProviders: string[];
|
|
70
|
+
/**
|
|
71
|
+
* Whether the app env module was found and imported.
|
|
72
|
+
*/
|
|
73
|
+
envModuleChecked: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Whether doctor's production hardening diagnostics were folded in.
|
|
76
|
+
*/
|
|
77
|
+
doctorChecked: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Ports whose `checkHealth()` ran during a `connect` pass.
|
|
80
|
+
*/
|
|
81
|
+
checkedDependencies: string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Map doctor diagnostics to preflight findings: only production hardening
|
|
86
|
+
* codes are kept, and their severities are promoted for the deploy gate —
|
|
87
|
+
* doctor errors and warnings both fail preflight; hints become warnings.
|
|
88
|
+
*/
|
|
89
|
+
export function productionFindingsFromDiagnostics(
|
|
90
|
+
diagnostics: readonly InspectDiagnostic[],
|
|
91
|
+
): PreflightFinding[] {
|
|
92
|
+
return diagnostics
|
|
93
|
+
.filter((diagnostic) =>
|
|
94
|
+
productionHardeningDiagnosticCodes.has(diagnostic.code),
|
|
95
|
+
)
|
|
96
|
+
.map((diagnostic) => ({
|
|
97
|
+
severity: diagnostic.severity === "hint" ? "warning" : "error",
|
|
98
|
+
code: diagnostic.code,
|
|
99
|
+
message: diagnostic.message,
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const SECRET_KEY_PATTERN = /SECRET|TOKEN|KEY|PASS|PASSWORD|DSN/i;
|
|
104
|
+
|
|
105
|
+
const PLACEHOLDER_VALUE_PATTERN =
|
|
106
|
+
/^(changeme|change-me|todo|placeholder|xxx+)$|replace-with|replace-me|dev-placeholder|your-[a-z]/i;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse a dotenv-style file: KEY=VALUE lines, `#` comments, optional single
|
|
110
|
+
* or double quotes around the value.
|
|
111
|
+
*/
|
|
112
|
+
export function parseEnvFile(source: string): Record<string, string> {
|
|
113
|
+
const values: Record<string, string> = {};
|
|
114
|
+
for (const rawLine of source.split("\n")) {
|
|
115
|
+
const line = rawLine.trim();
|
|
116
|
+
if (!line || line.startsWith("#")) continue;
|
|
117
|
+
const eq = line.indexOf("=");
|
|
118
|
+
if (eq <= 0) continue;
|
|
119
|
+
const key = line.slice(0, eq).trim();
|
|
120
|
+
let value = line.slice(eq + 1).trim();
|
|
121
|
+
if (
|
|
122
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
123
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
124
|
+
) {
|
|
125
|
+
value = value.slice(1, -1);
|
|
126
|
+
}
|
|
127
|
+
values[key] = value;
|
|
128
|
+
}
|
|
129
|
+
return values;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function readFileIfExists(filePath: string): Promise<string | undefined> {
|
|
133
|
+
try {
|
|
134
|
+
return await readFile(filePath, "utf8");
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function readPackageJson(
|
|
141
|
+
cwd: string,
|
|
142
|
+
): Promise<ProviderPackageJson | undefined> {
|
|
143
|
+
const source = await readFileIfExists(path.join(cwd, "package.json"));
|
|
144
|
+
if (!source) return undefined;
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(source) as ProviderPackageJson;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isSecretish(key: string): boolean {
|
|
153
|
+
return SECRET_KEY_PATTERN.test(key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function applyEnvToProcess(
|
|
157
|
+
env: Record<string, string | undefined>,
|
|
158
|
+
): () => void {
|
|
159
|
+
const previousValues = new Map<string, string | undefined>();
|
|
160
|
+
|
|
161
|
+
for (const [key, value] of Object.entries(env)) {
|
|
162
|
+
if (value === undefined) continue;
|
|
163
|
+
previousValues.set(key, process.env[key]);
|
|
164
|
+
process.env[key] = value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return () => {
|
|
168
|
+
for (const [key, value] of previousValues) {
|
|
169
|
+
if (value === undefined) {
|
|
170
|
+
delete process.env[key];
|
|
171
|
+
} else {
|
|
172
|
+
process.env[key] = value;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Validate the deploy environment against installed provider manifests, the
|
|
180
|
+
* app's `.env.example` placeholders, and the app env schema module.
|
|
181
|
+
*
|
|
182
|
+
* This is a runtime gate, unlike `beignet doctor`: it reads the actual
|
|
183
|
+
* environment the process runs with, so run it in the deploy pipeline where
|
|
184
|
+
* production configuration is present.
|
|
185
|
+
*/
|
|
186
|
+
export async function runPreflight(
|
|
187
|
+
options: PreflightOptions,
|
|
188
|
+
): Promise<PreflightResult> {
|
|
189
|
+
const findings: PreflightFinding[] = [];
|
|
190
|
+
const env: Record<string, string | undefined> = {
|
|
191
|
+
...(options.env ?? process.env),
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (options.envFile) {
|
|
195
|
+
const source = await readFileIfExists(
|
|
196
|
+
path.resolve(options.cwd, options.envFile),
|
|
197
|
+
);
|
|
198
|
+
if (source === undefined) {
|
|
199
|
+
findings.push({
|
|
200
|
+
severity: "error",
|
|
201
|
+
code: "BEIGNET_PREFLIGHT_ENV_FILE_MISSING",
|
|
202
|
+
message: `Env file "${options.envFile}" does not exist.`,
|
|
203
|
+
});
|
|
204
|
+
} else {
|
|
205
|
+
for (const [key, value] of Object.entries(parseEnvFile(source))) {
|
|
206
|
+
if (env[key] === undefined) env[key] = value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const packageJson = await readPackageJson(options.cwd);
|
|
212
|
+
if (!packageJson) {
|
|
213
|
+
findings.push({
|
|
214
|
+
severity: "error",
|
|
215
|
+
code: "BEIGNET_PREFLIGHT_PACKAGE_JSON_MISSING",
|
|
216
|
+
message: `No package.json found in ${options.cwd}.`,
|
|
217
|
+
});
|
|
218
|
+
return {
|
|
219
|
+
findings,
|
|
220
|
+
checkedProviders: [],
|
|
221
|
+
envModuleChecked: false,
|
|
222
|
+
doctorChecked: false,
|
|
223
|
+
checkedDependencies: [],
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const rules = await providerDoctorRulesForInstalledPackages(
|
|
228
|
+
options.cwd,
|
|
229
|
+
packageJson,
|
|
230
|
+
);
|
|
231
|
+
const checkedProviders = rules.map((rule) => rule.packageName);
|
|
232
|
+
const requiredEnvKeys = new Set<string>();
|
|
233
|
+
|
|
234
|
+
for (const rule of rules) {
|
|
235
|
+
for (const key of rule.requiredEnv ?? []) {
|
|
236
|
+
requiredEnvKeys.add(key);
|
|
237
|
+
const value = env[key];
|
|
238
|
+
if (value === undefined || value.trim() === "") {
|
|
239
|
+
findings.push({
|
|
240
|
+
severity: "error",
|
|
241
|
+
code: "BEIGNET_PREFLIGHT_ENV_MISSING",
|
|
242
|
+
message: `${key} is required by ${rule.packageName} but is not set.`,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const exampleSource = await readFileIfExists(
|
|
249
|
+
path.join(options.cwd, ".env.example"),
|
|
250
|
+
);
|
|
251
|
+
const exampleValues = exampleSource ? parseEnvFile(exampleSource) : {};
|
|
252
|
+
|
|
253
|
+
for (const [key, value] of Object.entries(env)) {
|
|
254
|
+
if (value === undefined || value === "") continue;
|
|
255
|
+
const guarded = requiredEnvKeys.has(key) || isSecretish(key);
|
|
256
|
+
if (!guarded) continue;
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
Object.hasOwn(exampleValues, key) &&
|
|
260
|
+
exampleValues[key] !== "" &&
|
|
261
|
+
exampleValues[key] === value
|
|
262
|
+
) {
|
|
263
|
+
findings.push({
|
|
264
|
+
severity: "error",
|
|
265
|
+
code: "BEIGNET_PREFLIGHT_ENV_PLACEHOLDER",
|
|
266
|
+
message: `${key} still has its .env.example value. Set a real value in the deploy environment.`,
|
|
267
|
+
});
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (PLACEHOLDER_VALUE_PATTERN.test(value)) {
|
|
272
|
+
findings.push({
|
|
273
|
+
severity: "error",
|
|
274
|
+
code: "BEIGNET_PREFLIGHT_ENV_PLACEHOLDER",
|
|
275
|
+
message: `${key} looks like a placeholder value ("${value}"). Set a real value in the deploy environment.`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
await checkObservabilityWiring(options.cwd, packageJson, env, findings);
|
|
281
|
+
|
|
282
|
+
findings.push({
|
|
283
|
+
severity: "note",
|
|
284
|
+
code: "BEIGNET_PREFLIGHT_MANUAL_REVIEW",
|
|
285
|
+
message:
|
|
286
|
+
"Manual review (no framework primitives yet): CSRF protection, secure headers, and tracing export — see the deployment docs hardening checklist.",
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
let envModuleChecked = false;
|
|
290
|
+
let doctorChecked = false;
|
|
291
|
+
const checkedDependencies: string[] = [];
|
|
292
|
+
const restoreProcessEnv = applyEnvToProcess(env);
|
|
293
|
+
|
|
294
|
+
try {
|
|
295
|
+
const envModule = options.envModule ?? "lib/env.ts";
|
|
296
|
+
const envModulePath = path.resolve(options.cwd, envModule);
|
|
297
|
+
const envModuleSource = await readFileIfExists(envModulePath);
|
|
298
|
+
|
|
299
|
+
if (envModuleSource === undefined) {
|
|
300
|
+
findings.push({
|
|
301
|
+
severity: "note",
|
|
302
|
+
code: "BEIGNET_PREFLIGHT_ENV_MODULE_MISSING",
|
|
303
|
+
message: `No app env module at ${envModule}; skipping app env schema validation.`,
|
|
304
|
+
});
|
|
305
|
+
} else {
|
|
306
|
+
try {
|
|
307
|
+
const jiti = createJiti(envModulePath, {
|
|
308
|
+
interopDefault: true,
|
|
309
|
+
moduleCache: false,
|
|
310
|
+
});
|
|
311
|
+
await jiti.import(envModulePath);
|
|
312
|
+
envModuleChecked = true;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
findings.push({
|
|
315
|
+
severity: "error",
|
|
316
|
+
code: "BEIGNET_PREFLIGHT_APP_ENV_INVALID",
|
|
317
|
+
message: `App env schema validation failed (${envModule}): ${
|
|
318
|
+
error instanceof Error ? error.message : String(error)
|
|
319
|
+
}`,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (options.doctor !== false) {
|
|
325
|
+
const inspected = await inspectApp({ cwd: options.cwd });
|
|
326
|
+
findings.push(
|
|
327
|
+
...productionFindingsFromDiagnostics(inspected.diagnostics),
|
|
328
|
+
);
|
|
329
|
+
doctorChecked = true;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (options.connect) {
|
|
333
|
+
await runDependencyChecks(options, findings, checkedDependencies);
|
|
334
|
+
}
|
|
335
|
+
} finally {
|
|
336
|
+
restoreProcessEnv();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return {
|
|
340
|
+
findings,
|
|
341
|
+
checkedProviders,
|
|
342
|
+
envModuleChecked,
|
|
343
|
+
doctorChecked,
|
|
344
|
+
checkedDependencies,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
interface HealthCheckable {
|
|
349
|
+
checkHealth(): Promise<unknown>;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function isHealthCheckable(value: unknown): value is HealthCheckable {
|
|
353
|
+
return (
|
|
354
|
+
typeof value === "object" &&
|
|
355
|
+
value !== null &&
|
|
356
|
+
typeof (value as HealthCheckable).checkHealth === "function"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function healthResultOk(result: unknown): boolean {
|
|
361
|
+
if (typeof result !== "object" || result === null) return true;
|
|
362
|
+
const ok = (result as { ok?: unknown }).ok;
|
|
363
|
+
return ok === undefined || ok === true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async function withTimeout<T>(
|
|
367
|
+
work: Promise<T>,
|
|
368
|
+
ms: number,
|
|
369
|
+
label: string,
|
|
370
|
+
): Promise<T> {
|
|
371
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
372
|
+
try {
|
|
373
|
+
return await Promise.race([
|
|
374
|
+
work,
|
|
375
|
+
new Promise<never>((_, reject) => {
|
|
376
|
+
timer = setTimeout(
|
|
377
|
+
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
|
378
|
+
ms,
|
|
379
|
+
);
|
|
380
|
+
}),
|
|
381
|
+
]);
|
|
382
|
+
} finally {
|
|
383
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Boot the app server through the same jiti loading the operational commands
|
|
389
|
+
* use, then run `checkHealth()` on every port that exposes one.
|
|
390
|
+
*/
|
|
391
|
+
async function runDependencyChecks(
|
|
392
|
+
options: PreflightOptions,
|
|
393
|
+
findings: PreflightFinding[],
|
|
394
|
+
checkedDependencies: string[],
|
|
395
|
+
): Promise<void> {
|
|
396
|
+
const serverModule = options.serverModule ?? "server/index.ts";
|
|
397
|
+
const serverModulePath = path.resolve(options.cwd, serverModule);
|
|
398
|
+
const timeoutMs = options.connectTimeoutMs ?? 5000;
|
|
399
|
+
|
|
400
|
+
if ((await readFileIfExists(serverModulePath)) === undefined) {
|
|
401
|
+
findings.push({
|
|
402
|
+
severity: "error",
|
|
403
|
+
code: "BEIGNET_PREFLIGHT_SERVER_MODULE_MISSING",
|
|
404
|
+
message: `--connect needs a server module at ${serverModule} (override with --server-module).`,
|
|
405
|
+
});
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
let server:
|
|
410
|
+
| { ports: Record<string, unknown>; stop?: () => Promise<void> }
|
|
411
|
+
| undefined;
|
|
412
|
+
try {
|
|
413
|
+
const jiti = createJiti(serverModulePath, {
|
|
414
|
+
interopDefault: true,
|
|
415
|
+
moduleCache: false,
|
|
416
|
+
});
|
|
417
|
+
const loaded = (await jiti.import(serverModulePath)) as Record<
|
|
418
|
+
string,
|
|
419
|
+
unknown
|
|
420
|
+
>;
|
|
421
|
+
const candidate = loaded.getServer ?? loaded.server;
|
|
422
|
+
const resolved =
|
|
423
|
+
typeof candidate === "function" ? await candidate() : await candidate;
|
|
424
|
+
if (
|
|
425
|
+
typeof resolved !== "object" ||
|
|
426
|
+
resolved === null ||
|
|
427
|
+
typeof (resolved as { ports?: unknown }).ports !== "object"
|
|
428
|
+
) {
|
|
429
|
+
findings.push({
|
|
430
|
+
severity: "error",
|
|
431
|
+
code: "BEIGNET_PREFLIGHT_SERVER_EXPORT_MISSING",
|
|
432
|
+
message: `${serverModule} must export getServer (or server) resolving to a server with ports.`,
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
server = resolved as {
|
|
437
|
+
ports: Record<string, unknown>;
|
|
438
|
+
stop?: () => Promise<void>;
|
|
439
|
+
};
|
|
440
|
+
} catch (error) {
|
|
441
|
+
findings.push({
|
|
442
|
+
severity: "error",
|
|
443
|
+
code: "BEIGNET_PREFLIGHT_SERVER_BOOT_FAILED",
|
|
444
|
+
message: `Booting the app server failed: ${
|
|
445
|
+
error instanceof Error ? error.message : String(error)
|
|
446
|
+
}`,
|
|
447
|
+
});
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
try {
|
|
452
|
+
for (const [name, port] of Object.entries(server.ports)) {
|
|
453
|
+
if (!isHealthCheckable(port)) continue;
|
|
454
|
+
checkedDependencies.push(name);
|
|
455
|
+
try {
|
|
456
|
+
const result = await withTimeout(
|
|
457
|
+
port.checkHealth(),
|
|
458
|
+
timeoutMs,
|
|
459
|
+
`${name}.checkHealth()`,
|
|
460
|
+
);
|
|
461
|
+
if (!healthResultOk(result)) {
|
|
462
|
+
const message = (result as { message?: unknown }).message;
|
|
463
|
+
findings.push({
|
|
464
|
+
severity: "error",
|
|
465
|
+
code: "BEIGNET_PREFLIGHT_DEPENDENCY_UNHEALTHY",
|
|
466
|
+
message: `${name} reported unhealthy${
|
|
467
|
+
typeof message === "string" ? `: ${message}` : "."
|
|
468
|
+
}`,
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
} catch (error) {
|
|
472
|
+
findings.push({
|
|
473
|
+
severity: "error",
|
|
474
|
+
code: "BEIGNET_PREFLIGHT_DEPENDENCY_UNHEALTHY",
|
|
475
|
+
message: `${name} health check failed: ${
|
|
476
|
+
error instanceof Error ? error.message : String(error)
|
|
477
|
+
}`,
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
} finally {
|
|
482
|
+
try {
|
|
483
|
+
await server.stop?.();
|
|
484
|
+
} catch {
|
|
485
|
+
// Shutdown failures do not change readiness results.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* Format a preflight result for terminal output.
|
|
492
|
+
*/
|
|
493
|
+
export function formatPreflight(result: PreflightResult, cwd: string): string {
|
|
494
|
+
const lines: string[] = [];
|
|
495
|
+
const errors = result.findings.filter((f) => f.severity === "error");
|
|
496
|
+
const warnings = result.findings.filter((f) => f.severity === "warning");
|
|
497
|
+
const notes = result.findings.filter((f) => f.severity === "note");
|
|
498
|
+
|
|
499
|
+
for (const finding of [...errors, ...warnings, ...notes]) {
|
|
500
|
+
const tag =
|
|
501
|
+
finding.severity === "error"
|
|
502
|
+
? "error"
|
|
503
|
+
: finding.severity === "warning"
|
|
504
|
+
? "warning"
|
|
505
|
+
: "note";
|
|
506
|
+
lines.push(`${tag} ${finding.code}: ${finding.message}`);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (lines.length > 0) lines.push("");
|
|
510
|
+
|
|
511
|
+
const providerSummary = result.checkedProviders.length
|
|
512
|
+
? `${result.checkedProviders.length} provider manifest(s)`
|
|
513
|
+
: "no provider manifests";
|
|
514
|
+
const envSummary = result.envModuleChecked
|
|
515
|
+
? "app env schema validated"
|
|
516
|
+
: "app env schema not validated";
|
|
517
|
+
const doctorSummary = result.doctorChecked
|
|
518
|
+
? "production hardening checks folded in"
|
|
519
|
+
: "production hardening checks skipped";
|
|
520
|
+
const dependencySummary = result.checkedDependencies.length
|
|
521
|
+
? `, ${result.checkedDependencies.length} dependency health check(s) run`
|
|
522
|
+
: "";
|
|
523
|
+
|
|
524
|
+
if (errors.length === 0) {
|
|
525
|
+
lines.push(
|
|
526
|
+
`Preflight passed in ${cwd}: ${providerSummary} checked, ${envSummary}, ${doctorSummary}${dependencySummary}.`,
|
|
527
|
+
);
|
|
528
|
+
} else {
|
|
529
|
+
lines.push(
|
|
530
|
+
`Preflight failed in ${cwd}: ${errors.length} error(s) across ${providerSummary}, ${envSummary}, ${doctorSummary}${dependencySummary}.`,
|
|
531
|
+
);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
return `${lines.join("\n")}\n`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const DSN_KEY_PATTERN = /DSN/i;
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Warn when logging or error reporting is absent or inert: no installed
|
|
541
|
+
* provider contributes a `logger` or `errorReporter` port, or an error
|
|
542
|
+
* reporting provider is installed but its DSN-style env var is unset.
|
|
543
|
+
*/
|
|
544
|
+
async function checkObservabilityWiring(
|
|
545
|
+
cwd: string,
|
|
546
|
+
packageJson: ProviderPackageJson,
|
|
547
|
+
env: Record<string, string | undefined>,
|
|
548
|
+
findings: PreflightFinding[],
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
let hasLogger = false;
|
|
551
|
+
let hasErrorReporter = false;
|
|
552
|
+
|
|
553
|
+
for (const packageName of installedPackageNames(packageJson)) {
|
|
554
|
+
const source = await readProviderPackageMetadataSource(cwd, packageName);
|
|
555
|
+
if (!source) continue;
|
|
556
|
+
const parsed = parseProviderPackageMetadata(source.metadata);
|
|
557
|
+
if (!parsed.success) continue;
|
|
558
|
+
const ports = parsed.metadata.ports ?? [];
|
|
559
|
+
|
|
560
|
+
if (ports.includes("logger")) hasLogger = true;
|
|
561
|
+
if (ports.includes("errorReporter")) {
|
|
562
|
+
hasErrorReporter = true;
|
|
563
|
+
const dsnKeys = (parsed.metadata.env ?? []).filter((key) =>
|
|
564
|
+
DSN_KEY_PATTERN.test(key),
|
|
565
|
+
);
|
|
566
|
+
for (const key of dsnKeys) {
|
|
567
|
+
const value = env[key];
|
|
568
|
+
if (value === undefined || value.trim() === "") {
|
|
569
|
+
findings.push({
|
|
570
|
+
severity: "warning",
|
|
571
|
+
code: "BEIGNET_PREFLIGHT_ERROR_REPORTING_INERT",
|
|
572
|
+
message: `${packageName} is installed but ${key} is not set, so error reporting will be inert.`,
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!hasLogger) {
|
|
580
|
+
findings.push({
|
|
581
|
+
severity: "warning",
|
|
582
|
+
code: "BEIGNET_PREFLIGHT_LOGGING_NOT_CONFIGURED",
|
|
583
|
+
message:
|
|
584
|
+
"No installed provider contributes a logger port; production logs need a real logger provider such as @beignet/provider-logger-pino.",
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (!hasErrorReporter) {
|
|
589
|
+
findings.push({
|
|
590
|
+
severity: "warning",
|
|
591
|
+
code: "BEIGNET_PREFLIGHT_ERROR_REPORTING_NOT_CONFIGURED",
|
|
592
|
+
message:
|
|
593
|
+
"No installed provider contributes an errorReporter port; production error capture needs a provider such as @beignet/provider-error-reporting-sentry.",
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
}
|