@warlock.js/core 5.0.1 → 5.1.0
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 +47 -0
- package/esm/cli/cli-commands.manager.mjs +36 -1
- package/esm/cli/cli-commands.manager.mjs.map +1 -1
- package/esm/cli/commands/dev-server.command.mjs +1 -1
- package/esm/cli/commands/doctor/boot-for-diagnostics.mjs +207 -0
- package/esm/cli/commands/doctor/boot-for-diagnostics.mjs.map +1 -0
- package/esm/cli/commands/doctor/checks/config.check.mjs +5 -2
- package/esm/cli/commands/doctor/checks/config.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/connectors.check.mjs +38 -12
- package/esm/cli/commands/doctor/checks/connectors.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/handler-signature.check.mjs +8 -4
- package/esm/cli/commands/doctor/checks/handler-signature.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/health.check.mjs +77 -11
- package/esm/cli/commands/doctor/checks/health.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/index.mjs +5 -0
- package/esm/cli/commands/doctor/checks/index.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/optional-peers.check.mjs +116 -53
- package/esm/cli/commands/doctor/checks/optional-peers.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/release-hygiene.check.mjs +27 -9
- package/esm/cli/commands/doctor/checks/release-hygiene.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/checks/routes.check.mjs +51 -12
- package/esm/cli/commands/doctor/checks/routes.check.mjs.map +1 -1
- package/esm/cli/commands/doctor/doctor-command.action.mjs +16 -6
- package/esm/cli/commands/doctor/doctor-command.action.mjs.map +1 -1
- package/esm/cli/commands/doctor/run-checks.mjs +15 -6
- package/esm/cli/commands/doctor/run-checks.mjs.map +1 -1
- package/esm/cli/commands/doctor.command.mjs +17 -9
- package/esm/cli/commands/doctor.command.mjs.map +1 -1
- package/esm/connectors/describe-server-address.mjs +89 -6
- package/esm/connectors/describe-server-address.mjs.map +1 -1
- package/esm/connectors/http-connector.d.mts +18 -0
- package/esm/connectors/http-connector.d.mts.map +1 -1
- package/esm/connectors/http-connector.mjs +36 -3
- package/esm/connectors/http-connector.mjs.map +1 -1
- package/esm/dev-server/dev-logger.mjs +1 -4
- package/esm/dev-server/dev-logger.mjs.map +1 -1
- package/esm/dev-server/development-server.mjs +3 -7
- package/esm/dev-server/development-server.mjs.map +1 -1
- package/esm/dev-server/module-loader.mjs +1 -1
- package/esm/dev-server/ready-block.mjs +62 -0
- package/esm/dev-server/ready-block.mjs.map +1 -0
- package/esm/generations/add-command.action.mjs +2 -575
- package/esm/generations/add-command.action.mjs.map +1 -1
- package/esm/generations/features/access.feature.mjs +76 -0
- package/esm/generations/features/access.feature.mjs.map +1 -0
- package/esm/generations/features/ai-anthropic.feature.mjs +10 -0
- package/esm/generations/features/ai-anthropic.feature.mjs.map +1 -0
- package/esm/generations/features/ai-bedrock.feature.mjs +10 -0
- package/esm/generations/features/ai-bedrock.feature.mjs.map +1 -0
- package/esm/generations/features/ai-google.feature.mjs +10 -0
- package/esm/generations/features/ai-google.feature.mjs.map +1 -0
- package/esm/generations/features/ai-ollama.feature.mjs +10 -0
- package/esm/generations/features/ai-ollama.feature.mjs.map +1 -0
- package/esm/generations/features/ai-openai.feature.mjs +10 -0
- package/esm/generations/features/ai-openai.feature.mjs.map +1 -0
- package/esm/generations/features/ai-panoptic.feature.mjs +13 -0
- package/esm/generations/features/ai-panoptic.feature.mjs.map +1 -0
- package/esm/generations/features/ai-tools.feature.mjs +13 -0
- package/esm/generations/features/ai-tools.feature.mjs.map +1 -0
- package/esm/generations/features/ai-workspace.feature.mjs +13 -0
- package/esm/generations/features/ai-workspace.feature.mjs.map +1 -0
- package/esm/generations/features/ai.feature.mjs +15 -0
- package/esm/generations/features/ai.feature.mjs.map +1 -0
- package/esm/generations/features/herald.feature.mjs +19 -0
- package/esm/generations/features/herald.feature.mjs.map +1 -0
- package/esm/generations/features/image.feature.mjs +9 -0
- package/esm/generations/features/image.feature.mjs.map +1 -0
- package/esm/generations/features/index.mjs +72 -0
- package/esm/generations/features/index.mjs.map +1 -0
- package/esm/generations/features/mail.feature.mjs +10 -0
- package/esm/generations/features/mail.feature.mjs.map +1 -0
- package/esm/generations/features/mongodb.feature.mjs +9 -0
- package/esm/generations/features/mongodb.feature.mjs.map +1 -0
- package/esm/generations/features/mysql.feature.mjs +9 -0
- package/esm/generations/features/mysql.feature.mjs.map +1 -0
- package/esm/generations/features/notifications.feature.mjs +43 -0
- package/esm/generations/features/notifications.feature.mjs.map +1 -0
- package/esm/generations/features/postgres.feature.mjs +9 -0
- package/esm/generations/features/postgres.feature.mjs.map +1 -0
- package/esm/generations/features/react-email.feature.mjs +70 -0
- package/esm/generations/features/react-email.feature.mjs.map +1 -0
- package/esm/generations/features/react.feature.mjs +16 -0
- package/esm/generations/features/react.feature.mjs.map +1 -0
- package/esm/generations/features/redis.feature.mjs +9 -0
- package/esm/generations/features/redis.feature.mjs.map +1 -0
- package/esm/generations/features/s3.feature.mjs +13 -0
- package/esm/generations/features/s3.feature.mjs.map +1 -0
- package/esm/generations/features/scheduler.feature.mjs +9 -0
- package/esm/generations/features/scheduler.feature.mjs.map +1 -0
- package/esm/generations/features/ses.feature.mjs +9 -0
- package/esm/generations/features/ses.feature.mjs.map +1 -0
- package/esm/generations/features/shadcn.feature.mjs +397 -0
- package/esm/generations/features/shadcn.feature.mjs.map +1 -0
- package/esm/generations/features/shared/link-ai-package-import.mjs +37 -0
- package/esm/generations/features/shared/link-ai-package-import.mjs.map +1 -0
- package/esm/generations/features/shared/migration-timestamp.mjs +17 -0
- package/esm/generations/features/shared/migration-timestamp.mjs.map +1 -0
- package/esm/generations/features/socket.feature.mjs +15 -0
- package/esm/generations/features/socket.feature.mjs.map +1 -0
- package/esm/generations/features/tailwind.feature.mjs +174 -0
- package/esm/generations/features/tailwind.feature.mjs.map +1 -0
- package/esm/generations/features/test.feature.mjs +96 -0
- package/esm/generations/features/test.feature.mjs.map +1 -0
- package/esm/generations/features/web.feature.mjs +179 -0
- package/esm/generations/features/web.feature.mjs.map +1 -0
- package/esm/generations/stubs.mjs +95 -8
- package/esm/generations/stubs.mjs.map +1 -1
- package/esm/http/index.d.mts +1 -0
- package/esm/http/index.mjs +1 -0
- package/esm/http/ready-report.d.mts +56 -0
- package/esm/http/ready-report.d.mts.map +1 -0
- package/esm/http/ready-report.mjs +24 -0
- package/esm/http/ready-report.mjs.map +1 -0
- package/esm/index.d.mts +2 -1
- package/esm/index.mjs +2 -1
- package/llms-full.txt +322 -1
- package/llms.txt +2 -0
- package/package.json +12 -12
- package/skills/use-request-locals/SKILL.md +24 -1
- package/skills/warlock-conventions/SKILL.md +5 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"connectors.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/connectors.check.ts"],"sourcesContent":["import
|
|
1
|
+
{"version":3,"file":"connectors.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/connectors.check.ts"],"sourcesContent":["import type { DoctorCheck } from \"../check.types\";\n\n/**\n * Reports whether the app's connectors survive boot.\n *\n * NEEDS A BOOTED APP: the boot pass runs every connector's `boot()` and no\n * `start()`, and this check reads the outcome.\n *\n * WHAT THIS REPLACED, AND WHY: the previous version printed\n * `11 registered, none active`. Both halves were tautologies. Eleven is the\n * number of built-in connectors the manager constructs when its module is\n * imported — it is the same on every project that has ever existed and says\n * nothing about the app. And \"none active\" was guaranteed, because `active` is\n * set by `start()` and doctor deliberately never starts anything: the warning\n * could not be absent from a healthy app, so it could not mean anything on a\n * broken one.\n *\n * What is reported instead is a fact that varies with the project and can be\n * wrong: the connectors the app itself configured, and whether each of them\n * boots. A connector that throws on boot is a boot-time crash the user has not\n * hit yet.\n *\n * Verdicts:\n * - `warlock.config.ts > connectors` refused (duplicate or reserved name) →\n * `fail`. The next real boot would throw the same error;\n * - a connector's `boot()` threw → `fail`, named, with the message;\n * - otherwise → `ok`, listing what booted.\n */\nexport const connectorsCheck: DoctorCheck = {\n name: \"connectors\",\n run: (context) => {\n const { connectors } = context;\n\n if (connectors.registrationError) {\n return {\n name: \"connectors\",\n status: \"fail\",\n detail: `warlock.config.ts > connectors was refused: ${connectors.registrationError}`,\n };\n }\n\n if (connectors.failures.length > 0) {\n return {\n name: \"connectors\",\n status: \"fail\",\n detail:\n `${connectors.failures.length} connector(s) failed to boot:\\n` +\n connectors.failures\n .map((failure) => ` - ${failure.name}: ${failure.message}`)\n .join(\"\\n\"),\n };\n }\n\n if (!context.booted) {\n return undefined;\n }\n\n const skipped = connectors.skipped\n .map((entry) => `; skipped ${entry.name} (${entry.reason})`)\n .join(\"\");\n\n const configured =\n connectors.configured.length > 0\n ? `${connectors.configured.join(\", \")} from warlock.config.ts + ` +\n `${connectors.registered.length - connectors.configured.length} built-in`\n : `${connectors.registered.length} built-in, none configured in warlock.config.ts`;\n\n return {\n name: \"connectors\",\n status: \"ok\",\n detail: `all booted without error (${configured})${skipped}`,\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,MAAa,kBAA+B;CAC1C,MAAM;CACN,MAAM,YAAY;EAChB,MAAM,EAAE,eAAe;EAEvB,IAAI,WAAW,mBACb,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,+CAA+C,WAAW;EACpE;EAGF,IAAI,WAAW,SAAS,SAAS,GAC/B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,GAAG,WAAW,SAAS,OAAO,mCAC9B,WAAW,SACR,KAAK,YAAY,OAAO,QAAQ,KAAK,IAAI,QAAQ,SAAS,CAAC,CAC3D,KAAK,IAAI;EAChB;EAGF,IAAI,CAAC,QAAQ,QACX;EAGF,MAAM,UAAU,WAAW,QACxB,KAAK,UAAU,aAAa,MAAM,KAAK,IAAI,MAAM,OAAO,EAAE,CAAC,CAC3D,KAAK,EAAE;EAQV,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,6BARR,WAAW,WAAW,SAAS,IAC3B,GAAG,WAAW,WAAW,KAAK,IAAI,EAAE,4BACjC,WAAW,WAAW,SAAS,WAAW,WAAW,OAAO,aAC/D,GAAG,WAAW,WAAW,OAAO,iDAKY,GAAG;EACrD;CACF;AACF"}
|
|
@@ -14,13 +14,17 @@ import { describePositionalHandlerSuspect, listPositionalHandlerSuspects } from
|
|
|
14
14
|
* `warn`, never `fail`: the detection is a heuristic on the handler's declared
|
|
15
15
|
* parameters, so a false positive is possible and must not break a build.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* NEEDS A BOOTED APP: the router collects suspects as routes register, so this
|
|
18
|
+
* reads a list the boot pass filled. Before that pass existed the list was
|
|
19
|
+
* always empty and this check always passed — it was green because it had
|
|
20
|
+
* nothing to look at, which is indistinguishable from green because the app is
|
|
21
|
+
* fine. It stays silent when there are no routes for the same reason: a check
|
|
22
|
+
* with no input reports nothing rather than a clean bill of health.
|
|
20
23
|
*/
|
|
21
24
|
const handlerSignatureCheck = {
|
|
22
25
|
name: "handler-signature",
|
|
23
|
-
run: () => {
|
|
26
|
+
run: (context) => {
|
|
27
|
+
if (context.totalRoutes === 0) return;
|
|
24
28
|
const suspects = listPositionalHandlerSuspects();
|
|
25
29
|
if (suspects.length === 0) return {
|
|
26
30
|
name: "handler-signature",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler-signature.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/handler-signature.check.ts"],"sourcesContent":["import {\n describePositionalHandlerSuspect,\n listPositionalHandlerSuspects,\n} from \"../../../../router/positional-handler-diagnostics\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Surfaces route handlers still written against the v4 positional signature,\n * `(request, response)`. v5 calls a handler with a single context object, so\n * the second parameter is always `undefined` and the route fails on its first\n * request with a `TypeError` that names nothing.\n *\n * Verdicts:\n * - one or more suspects → `warn`;\n * - otherwise → `ok`.\n *\n * `warn`, never `fail`: the detection is a heuristic on the handler's declared\n * parameters, so a false positive is possible and must not break a build.\n *\n *
|
|
1
|
+
{"version":3,"file":"handler-signature.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/handler-signature.check.ts"],"sourcesContent":["import {\n describePositionalHandlerSuspect,\n listPositionalHandlerSuspects,\n} from \"../../../../router/positional-handler-diagnostics\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Surfaces route handlers still written against the v4 positional signature,\n * `(request, response)`. v5 calls a handler with a single context object, so\n * the second parameter is always `undefined` and the route fails on its first\n * request with a `TypeError` that names nothing.\n *\n * Verdicts:\n * - one or more suspects → `warn`;\n * - otherwise → `ok`.\n *\n * `warn`, never `fail`: the detection is a heuristic on the handler's declared\n * parameters, so a false positive is possible and must not break a build.\n *\n * NEEDS A BOOTED APP: the router collects suspects as routes register, so this\n * reads a list the boot pass filled. Before that pass existed the list was\n * always empty and this check always passed — it was green because it had\n * nothing to look at, which is indistinguishable from green because the app is\n * fine. It stays silent when there are no routes for the same reason: a check\n * with no input reports nothing rather than a clean bill of health.\n */\nexport const handlerSignatureCheck: DoctorCheck = {\n name: \"handler-signature\",\n run: (context) => {\n if (context.totalRoutes === 0) {\n return undefined;\n }\n\n const suspects = listPositionalHandlerSuspects();\n\n if (suspects.length === 0) {\n return {\n name: \"handler-signature\",\n status: \"ok\",\n detail: \"no handlers look like the v4 positional signature\",\n };\n }\n\n const headline =\n suspects.length === 1\n ? \"1 handler looks like the v4 positional signature\"\n : `${suspects.length} handlers look like the v4 positional signature`;\n\n return {\n name: \"handler-signature\",\n status: \"warn\",\n detail:\n `${headline}:\\n` +\n suspects.map((suspect) => ` - ${describePositionalHandlerSuspect(suspect)}`).join(\"\\n\"),\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;AA0BA,MAAa,wBAAqC;CAChD,MAAM;CACN,MAAM,YAAY;EAChB,IAAI,QAAQ,gBAAgB,GAC1B;EAGF,MAAM,WAAW,8BAA8B;EAE/C,IAAI,SAAS,WAAW,GACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAQF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,GARF,SAAS,WAAW,IAChB,qDACA,GAAG,SAAS,OAAO,iDAMT,OACZ,SAAS,KAAK,YAAY,OAAO,iCAAiC,OAAO,GAAG,CAAC,CAAC,KAAK,IAAI;EAC3F;CACF;AACF"}
|
|
@@ -1,31 +1,97 @@
|
|
|
1
1
|
import { config } from "../../../../config/config-getter.mjs";
|
|
2
|
+
import { container } from "../../../../container/index.mjs";
|
|
3
|
+
import { router } from "../../../../router/router.mjs";
|
|
2
4
|
|
|
3
5
|
//#region ../core/src/cli/commands/doctor/checks/health.check.ts
|
|
4
6
|
/**
|
|
5
|
-
*
|
|
6
|
-
* endpoints will be exposed. The HTTP connector registers them automatically
|
|
7
|
-
* unless `http.health.enabled` is explicitly `false`, so this check reads that
|
|
8
|
-
* config flag and the (overridable) paths.
|
|
7
|
+
* Ask the booted Fastify instance whether a GET route exists at this path.
|
|
9
8
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* `hasRoute` is the instance's own answer, not a re-derivation of the config
|
|
10
|
+
* that was supposed to produce it — which is the entire point of asking it.
|
|
11
|
+
* Guarded because it is the one part of this check that depends on a Fastify
|
|
12
|
+
* API: if the instance cannot answer, the check says so instead of inventing a
|
|
13
|
+
* verdict from the config flag it is meant to be verifying.
|
|
14
|
+
*/
|
|
15
|
+
function fastifyHasGet(path) {
|
|
16
|
+
const http = container.has("http.server") ? container.get("http.server") : void 0;
|
|
17
|
+
if (!http || typeof http.hasRoute !== "function") return void 0;
|
|
18
|
+
try {
|
|
19
|
+
return Boolean(http.hasRoute({
|
|
20
|
+
method: "GET",
|
|
21
|
+
url: path
|
|
22
|
+
}));
|
|
23
|
+
} catch {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether an app route already claims this path. The HTTP connector puts the
|
|
29
|
+
* probes on the Fastify instance directly and the router scans app routes onto
|
|
30
|
+
* the same instance at `start()`, so a collision is a duplicate registration:
|
|
31
|
+
* Fastify refuses it and the server does not start.
|
|
32
|
+
*/
|
|
33
|
+
function appRouteClaims(path) {
|
|
34
|
+
return router.list().some((route) => {
|
|
35
|
+
const method = String(route.method).toUpperCase();
|
|
36
|
+
return route.path === path && (method === "GET" || method === "ALL");
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Confirms the liveness (`/health`) and readiness (`/ready`) endpoints are
|
|
41
|
+
* really there.
|
|
42
|
+
*
|
|
43
|
+
* NEEDS A BOOTED APP: the HTTP connector registers the probes during `boot()`,
|
|
44
|
+
* which the boot pass runs. Previously this check read `http.health.enabled`
|
|
45
|
+
* and reported the endpoints as "registered" on that basis alone — in the same
|
|
46
|
+
* report where `routes` said zero, because nothing had been booted at all. It
|
|
47
|
+
* was reporting an intention as a fact, and the two lines contradicted each
|
|
48
|
+
* other in the owner's own run.
|
|
13
49
|
*
|
|
14
|
-
*
|
|
50
|
+
* Verdicts:
|
|
51
|
+
* - no `http` config → not applicable, no line;
|
|
52
|
+
* - `http.health.enabled = false` → `ok`. Turning the probes off is a supported
|
|
53
|
+
* choice, and a warning that fires on a deliberate setting every single run
|
|
54
|
+
* is not read by the time it matters;
|
|
55
|
+
* - an app route already claims a probe's path → `fail`: Fastify will refuse
|
|
56
|
+
* the duplicate and the server will not start;
|
|
57
|
+
* - the instance says the route is absent → `fail`;
|
|
58
|
+
* - otherwise → `ok`, with the paths that are actually registered.
|
|
15
59
|
*/
|
|
16
60
|
const healthCheck = {
|
|
17
61
|
name: "health",
|
|
18
|
-
run: () => {
|
|
62
|
+
run: (context) => {
|
|
63
|
+
if (!config.get("http")) return;
|
|
19
64
|
const healthConfig = config.get("http.health");
|
|
20
65
|
if (healthConfig?.enabled === false) return {
|
|
66
|
+
name: "health",
|
|
67
|
+
status: "ok",
|
|
68
|
+
detail: "probes disabled by config (http.health.enabled = false)"
|
|
69
|
+
};
|
|
70
|
+
const livenessPath = healthConfig?.path ?? "/health";
|
|
71
|
+
const readinessPath = healthConfig?.readinessPath ?? "/ready";
|
|
72
|
+
const collisions = [livenessPath, readinessPath].filter(appRouteClaims);
|
|
73
|
+
if (collisions.length > 0) return {
|
|
74
|
+
name: "health",
|
|
75
|
+
status: "fail",
|
|
76
|
+
detail: `an app route already claims ${collisions.join(" and ")} — Fastify refuses the duplicate registration and the server will not start. Move the app route, or rename the probe via http.health.path / http.health.readinessPath`
|
|
77
|
+
};
|
|
78
|
+
if (!context.connectors.booted.includes("http")) return;
|
|
79
|
+
const registered = [livenessPath, readinessPath].map(fastifyHasGet);
|
|
80
|
+
if (registered.some((answer) => answer === void 0)) return {
|
|
21
81
|
name: "health",
|
|
22
82
|
status: "warn",
|
|
23
|
-
detail:
|
|
83
|
+
detail: `could not read the http server's route table — ${livenessPath} + ${readinessPath} unverified`
|
|
84
|
+
};
|
|
85
|
+
const missing = [livenessPath, readinessPath].filter((_path, index) => !registered[index]);
|
|
86
|
+
if (missing.length > 0) return {
|
|
87
|
+
name: "health",
|
|
88
|
+
status: "fail",
|
|
89
|
+
detail: `enabled in config but not registered on the http server: ${missing.join(", ")}`
|
|
24
90
|
};
|
|
25
91
|
return {
|
|
26
92
|
name: "health",
|
|
27
93
|
status: "ok",
|
|
28
|
-
detail: `liveness ${
|
|
94
|
+
detail: `liveness ${livenessPath} + readiness ${readinessPath} registered`
|
|
29
95
|
};
|
|
30
96
|
}
|
|
31
97
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"health.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/health.check.ts"],"sourcesContent":["import { config } from \"../../../../config/config-getter\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"health.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/health.check.ts"],"sourcesContent":["import { config } from \"../../../../config/config-getter\";\nimport { container } from \"../../../../container\";\nimport { router } from \"../../../../router/router\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * Ask the booted Fastify instance whether a GET route exists at this path.\n *\n * `hasRoute` is the instance's own answer, not a re-derivation of the config\n * that was supposed to produce it — which is the entire point of asking it.\n * Guarded because it is the one part of this check that depends on a Fastify\n * API: if the instance cannot answer, the check says so instead of inventing a\n * verdict from the config flag it is meant to be verifying.\n */\nfunction fastifyHasGet(path: string): boolean | undefined {\n const http = container.has(\"http.server\") ? container.get<any>(\"http.server\") : undefined;\n\n if (!http || typeof http.hasRoute !== \"function\") return undefined;\n\n try {\n return Boolean(http.hasRoute({ method: \"GET\", url: path }));\n } catch {\n return undefined;\n }\n}\n\n/**\n * Whether an app route already claims this path. The HTTP connector puts the\n * probes on the Fastify instance directly and the router scans app routes onto\n * the same instance at `start()`, so a collision is a duplicate registration:\n * Fastify refuses it and the server does not start.\n */\nfunction appRouteClaims(path: string): boolean {\n return router.list().some((route) => {\n const method = String(route.method).toUpperCase();\n\n return route.path === path && (method === \"GET\" || method === \"ALL\");\n });\n}\n\n/**\n * Confirms the liveness (`/health`) and readiness (`/ready`) endpoints are\n * really there.\n *\n * NEEDS A BOOTED APP: the HTTP connector registers the probes during `boot()`,\n * which the boot pass runs. Previously this check read `http.health.enabled`\n * and reported the endpoints as \"registered\" on that basis alone — in the same\n * report where `routes` said zero, because nothing had been booted at all. It\n * was reporting an intention as a fact, and the two lines contradicted each\n * other in the owner's own run.\n *\n * Verdicts:\n * - no `http` config → not applicable, no line;\n * - `http.health.enabled = false` → `ok`. Turning the probes off is a supported\n * choice, and a warning that fires on a deliberate setting every single run\n * is not read by the time it matters;\n * - an app route already claims a probe's path → `fail`: Fastify will refuse\n * the duplicate and the server will not start;\n * - the instance says the route is absent → `fail`;\n * - otherwise → `ok`, with the paths that are actually registered.\n */\nexport const healthCheck: DoctorCheck = {\n name: \"health\",\n run: (context) => {\n if (!config.get(\"http\")) {\n return undefined;\n }\n\n const healthConfig = config.get(\"http.health\");\n\n if (healthConfig?.enabled === false) {\n return {\n name: \"health\",\n status: \"ok\",\n detail: \"probes disabled by config (http.health.enabled = false)\",\n };\n }\n\n const livenessPath = healthConfig?.path ?? \"/health\";\n const readinessPath = healthConfig?.readinessPath ?? \"/ready\";\n\n const collisions = [livenessPath, readinessPath].filter(appRouteClaims);\n\n if (collisions.length > 0) {\n return {\n name: \"health\",\n status: \"fail\",\n detail:\n `an app route already claims ${collisions.join(\" and \")} — Fastify refuses the ` +\n \"duplicate registration and the server will not start. Move the app route, or \" +\n \"rename the probe via http.health.path / http.health.readinessPath\",\n };\n }\n\n // The HTTP connector is what registers the probes. If it did not boot, the\n // connectors check already says so with the reason — reporting the same\n // defect a second time here as a health failure would inflate one problem\n // into two.\n if (!context.connectors.booted.includes(\"http\")) {\n return undefined;\n }\n\n const registered = [livenessPath, readinessPath].map(fastifyHasGet);\n\n if (registered.some((answer) => answer === undefined)) {\n return {\n name: \"health\",\n status: \"warn\",\n detail:\n \"could not read the http server's route table — \" +\n `${livenessPath} + ${readinessPath} unverified`,\n };\n }\n\n const missing = [livenessPath, readinessPath].filter((_path, index) => !registered[index]);\n\n if (missing.length > 0) {\n return {\n name: \"health\",\n status: \"fail\",\n detail: `enabled in config but not registered on the http server: ${missing.join(\", \")}`,\n };\n }\n\n return {\n name: \"health\",\n status: \"ok\",\n detail: `liveness ${livenessPath} + readiness ${readinessPath} registered`,\n };\n },\n};\n"],"mappings":";;;;;;;;;;;;;;AAcA,SAAS,cAAc,MAAmC;CACxD,MAAM,OAAO,UAAU,IAAI,aAAa,IAAI,UAAU,IAAS,aAAa,IAAI;CAEhF,IAAI,CAAC,QAAQ,OAAO,KAAK,aAAa,YAAY,OAAO;CAEzD,IAAI;EACF,OAAO,QAAQ,KAAK,SAAS;GAAE,QAAQ;GAAO,KAAK;EAAK,CAAC,CAAC;CAC5D,QAAQ;EACN;CACF;AACF;;;;;;;AAQA,SAAS,eAAe,MAAuB;CAC7C,OAAO,OAAO,KAAK,CAAC,CAAC,MAAM,UAAU;EACnC,MAAM,SAAS,OAAO,MAAM,MAAM,CAAC,CAAC,YAAY;EAEhD,OAAO,MAAM,SAAS,SAAS,WAAW,SAAS,WAAW;CAChE,CAAC;AACH;;;;;;;;;;;;;;;;;;;;;;AAuBA,MAAa,cAA2B;CACtC,MAAM;CACN,MAAM,YAAY;EAChB,IAAI,CAAC,OAAO,IAAI,MAAM,GACpB;EAGF,MAAM,eAAe,OAAO,IAAI,aAAa;EAE7C,IAAI,cAAc,YAAY,OAC5B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,MAAM,eAAe,cAAc,QAAQ;EAC3C,MAAM,gBAAgB,cAAc,iBAAiB;EAErD,MAAM,aAAa,CAAC,cAAc,aAAa,CAAC,CAAC,OAAO,cAAc;EAEtE,IAAI,WAAW,SAAS,GACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,+BAA+B,WAAW,KAAK,OAAO,EAAE;EAG5D;EAOF,IAAI,CAAC,QAAQ,WAAW,OAAO,SAAS,MAAM,GAC5C;EAGF,MAAM,aAAa,CAAC,cAAc,aAAa,CAAC,CAAC,IAAI,aAAa;EAElE,IAAI,WAAW,MAAM,WAAW,WAAW,MAAS,GAClD,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QACE,kDACG,aAAa,KAAK,cAAc;EACvC;EAGF,MAAM,UAAU,CAAC,cAAc,aAAa,CAAC,CAAC,QAAQ,OAAO,UAAU,CAAC,WAAW,MAAM;EAEzF,IAAI,QAAQ,SAAS,GACnB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,4DAA4D,QAAQ,KAAK,IAAI;EACvF;EAGF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,YAAY,aAAa,eAAe,cAAc;EAChE;CACF;AACF"}
|
|
@@ -11,6 +11,11 @@ import { routesCheck } from "./routes.check.mjs";
|
|
|
11
11
|
* The default, ordered set of checks `warlock doctor` runs. Ordering controls
|
|
12
12
|
* the report layout: runtime-surface checks first (routes, handler signatures,
|
|
13
13
|
* config, connectors, peers, health), release hygiene last.
|
|
14
|
+
*
|
|
15
|
+
* Every check is handed the same boot context, and any of them may return
|
|
16
|
+
* `undefined` to opt out of a project it does not apply to — so this list is
|
|
17
|
+
* the set of checks that COULD run, not the set of lines a given project will
|
|
18
|
+
* print.
|
|
14
19
|
*/
|
|
15
20
|
const defaultDoctorChecks = [
|
|
16
21
|
routesCheck,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/index.ts"],"sourcesContent":["import type { DoctorCheck } from \"../check.types\";\nimport { configCheck } from \"./config.check\";\nimport { connectorsCheck } from \"./connectors.check\";\nimport { handlerSignatureCheck } from \"./handler-signature.check\";\nimport { healthCheck } from \"./health.check\";\nimport { optionalPeersCheck } from \"./optional-peers.check\";\nimport { releaseHygieneCheck } from \"./release-hygiene.check\";\nimport { routesCheck } from \"./routes.check\";\n\n/**\n * The default, ordered set of checks `warlock doctor` runs. Ordering controls\n * the report layout: runtime-surface checks first (routes, handler signatures,\n * config, connectors, peers, health), release hygiene last.\n */\nexport const defaultDoctorChecks: DoctorCheck[] = [\n routesCheck,\n handlerSignatureCheck,\n configCheck,\n connectorsCheck,\n optionalPeersCheck,\n healthCheck,\n releaseHygieneCheck,\n];\n"],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/index.ts"],"sourcesContent":["import type { DoctorCheck } from \"../check.types\";\nimport { configCheck } from \"./config.check\";\nimport { connectorsCheck } from \"./connectors.check\";\nimport { handlerSignatureCheck } from \"./handler-signature.check\";\nimport { healthCheck } from \"./health.check\";\nimport { optionalPeersCheck } from \"./optional-peers.check\";\nimport { releaseHygieneCheck } from \"./release-hygiene.check\";\nimport { routesCheck } from \"./routes.check\";\n\n/**\n * The default, ordered set of checks `warlock doctor` runs. Ordering controls\n * the report layout: runtime-surface checks first (routes, handler signatures,\n * config, connectors, peers, health), release hygiene last.\n *\n * Every check is handed the same boot context, and any of them may return\n * `undefined` to opt out of a project it does not apply to — so this list is\n * the set of checks that COULD run, not the set of lines a given project will\n * print.\n */\nexport const defaultDoctorChecks: DoctorCheck[] = [\n routesCheck,\n handlerSignatureCheck,\n configCheck,\n connectorsCheck,\n optionalPeersCheck,\n healthCheck,\n releaseHygieneCheck,\n];\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmBA,MAAa,sBAAqC;CAChD;CACA;CACA;CACA;CACA;CACA;CACA;AACF"}
|
|
@@ -1,50 +1,8 @@
|
|
|
1
|
+
import { config } from "../../../../config/config-getter.mjs";
|
|
1
2
|
import { createRequire } from "node:module";
|
|
2
3
|
|
|
3
4
|
//#region ../core/src/cli/commands/doctor/checks/optional-peers.check.ts
|
|
4
5
|
/**
|
|
5
|
-
* The optional peers core knows how to use. Kept in sync with the install
|
|
6
|
-
* targets in the `warlock add` feature registry (sharp, nodemailer, socket.io,
|
|
7
|
-
* the AWS SDK family, redis, the Cascade DB drivers, …).
|
|
8
|
-
*/
|
|
9
|
-
const OPTIONAL_PEERS = [
|
|
10
|
-
{
|
|
11
|
-
package: "sharp",
|
|
12
|
-
feature: "image processing"
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
package: "nodemailer",
|
|
16
|
-
feature: "mail (SMTP) channel"
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
package: "socket.io",
|
|
20
|
-
feature: "realtime socket server"
|
|
21
|
-
},
|
|
22
|
-
{
|
|
23
|
-
package: "@aws-sdk/client-s3",
|
|
24
|
-
feature: "S3 cloud storage"
|
|
25
|
-
},
|
|
26
|
-
{
|
|
27
|
-
package: "@aws-sdk/client-sesv2",
|
|
28
|
-
feature: "Amazon SES mail channel"
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
package: "redis",
|
|
32
|
-
feature: "Redis cache driver"
|
|
33
|
-
},
|
|
34
|
-
{
|
|
35
|
-
package: "mongodb",
|
|
36
|
-
feature: "MongoDB database driver"
|
|
37
|
-
},
|
|
38
|
-
{
|
|
39
|
-
package: "pg",
|
|
40
|
-
feature: "Postgres database driver"
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
package: "mysql2",
|
|
44
|
-
feature: "MySQL database driver"
|
|
45
|
-
}
|
|
46
|
-
];
|
|
47
|
-
/**
|
|
48
6
|
* Resolve from the project's working directory, not from core's own
|
|
49
7
|
* `node_modules`, so the probe reflects what the *consuming app* has installed.
|
|
50
8
|
*/
|
|
@@ -62,25 +20,130 @@ function isInstalled(packageName) {
|
|
|
62
20
|
}
|
|
63
21
|
}
|
|
64
22
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
23
|
+
* A non-empty string, or `undefined`. Config values arrive from `env(...)` and
|
|
24
|
+
* an unset variable yields `undefined` — but a `.env` line with nothing after
|
|
25
|
+
* the `=` yields `""`, which is "not configured" just as much.
|
|
26
|
+
*/
|
|
27
|
+
function text(value) {
|
|
28
|
+
return typeof value === "string" && value.trim().length > 0 ? value.trim() : void 0;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The database driver's package. `driver` is a closed union in cascade
|
|
32
|
+
* (`mongodb | postgres | mysql`), so an unrecognised value means the config is
|
|
33
|
+
* wrong in a way this check is not the right one to report.
|
|
34
|
+
*/
|
|
35
|
+
function databasePeer() {
|
|
36
|
+
const driver = text(config.get("database.driver"));
|
|
37
|
+
const packageName = driver === "mongodb" ? "mongodb" : driver === "postgres" ? "pg" : driver === "mysql" ? "mysql2" : void 0;
|
|
38
|
+
return packageName ? {
|
|
39
|
+
package: packageName,
|
|
40
|
+
because: `database.driver = "${driver}"`
|
|
41
|
+
} : void 0;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* The selected cache driver. Only the DEFAULT matters: `cache.drivers` is a
|
|
45
|
+
* menu the scaffold fills in for every driver the framework ships, and warning
|
|
46
|
+
* about a menu entry nobody selected is the false positive this whole check was
|
|
47
|
+
* rewritten to remove.
|
|
48
|
+
*/
|
|
49
|
+
function cachePeer() {
|
|
50
|
+
const selected = text(config.get("cache.default"));
|
|
51
|
+
if (!selected || !/redis/i.test(selected)) return void 0;
|
|
52
|
+
return {
|
|
53
|
+
package: "redis",
|
|
54
|
+
because: `cache.default = "${selected}"`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The selected storage driver, read through its canonical `driver` field
|
|
59
|
+
* (`local` / `s3` / `r2` / `spaces`) rather than the key the app happened to
|
|
60
|
+
* name it, so a driver called "cdn" is still recognised as S3.
|
|
61
|
+
*/
|
|
62
|
+
function storagePeer() {
|
|
63
|
+
const selected = text(config.get("storage.default"));
|
|
64
|
+
if (!selected) return void 0;
|
|
65
|
+
const driver = text(config.get(`storage.drivers.${selected}.driver`));
|
|
66
|
+
if (!driver || driver === "local") return void 0;
|
|
67
|
+
return {
|
|
68
|
+
package: "@aws-sdk/client-s3",
|
|
69
|
+
because: `storage.default = "${selected}" (driver "${driver}")`
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The mail transport. A `mail` config with no host and no driver is the
|
|
74
|
+
* scaffold's placeholder — every field is an unset env var — so it asks for
|
|
75
|
+
* nothing and this stays quiet.
|
|
76
|
+
*/
|
|
77
|
+
function mailPeer() {
|
|
78
|
+
if (text(config.get("mail.driver")) === "ses") return {
|
|
79
|
+
package: "@aws-sdk/client-sesv2",
|
|
80
|
+
because: `mail.driver = "ses"`
|
|
81
|
+
};
|
|
82
|
+
const host = text(config.get("mail.host"));
|
|
83
|
+
if (!host) return void 0;
|
|
84
|
+
return {
|
|
85
|
+
package: "nodemailer",
|
|
86
|
+
because: `mail.host = "${host}"`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Realtime sockets. The connector no-ops without a `socket` config, so the
|
|
91
|
+
* presence of that config is exactly the request for the peer.
|
|
92
|
+
*/
|
|
93
|
+
function socketPeer() {
|
|
94
|
+
if (!config.get("socket")) return void 0;
|
|
95
|
+
return {
|
|
96
|
+
package: "socket.io",
|
|
97
|
+
because: "a socket config is present"
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Every peer THIS project's configuration asks for.
|
|
102
|
+
*/
|
|
103
|
+
function requiredPeers() {
|
|
104
|
+
return [
|
|
105
|
+
databasePeer(),
|
|
106
|
+
cachePeer(),
|
|
107
|
+
storagePeer(),
|
|
108
|
+
mailPeer(),
|
|
109
|
+
socketPeer()
|
|
110
|
+
].filter((peer) => Boolean(peer));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Checks that every optional peer the project's own configuration selects is
|
|
114
|
+
* actually installed.
|
|
115
|
+
*
|
|
116
|
+
* NEEDS NO BOOTED APP — it reads config and resolves module paths. It runs after
|
|
117
|
+
* the boot pass only because config is loaded by then.
|
|
118
|
+
*
|
|
119
|
+
* WHAT THIS REPLACED, AND WHY: the previous version probed nine packages and
|
|
120
|
+
* warned about every one that was absent. Every one of them is an OPTIONAL
|
|
121
|
+
* peer — a fresh app installs none of them by design — so the check warned, at
|
|
122
|
+
* length, about the framework's own default state. "No MySQL driver installed"
|
|
123
|
+
* is not a diagnosis for someone who never wanted MySQL; it is noise that
|
|
124
|
+
* teaches the reader to skim past the line where a real finding will one day
|
|
125
|
+
* appear.
|
|
68
126
|
*
|
|
69
|
-
*
|
|
127
|
+
* Inverted, it becomes a genuine one: a project whose config selects a driver
|
|
128
|
+
* whose package is missing is broken — it will throw the first time that
|
|
129
|
+
* feature is used — so a missing peer here is a `fail`, not a warning.
|
|
70
130
|
*/
|
|
71
131
|
const optionalPeersCheck = {
|
|
72
132
|
name: "optional-peers",
|
|
73
133
|
run: () => {
|
|
74
|
-
const
|
|
75
|
-
if (
|
|
134
|
+
const required = requiredPeers();
|
|
135
|
+
if (required.length === 0) return;
|
|
136
|
+
const missing = required.filter((peer) => !isInstalled(peer.package));
|
|
137
|
+
const status = missing.length > 0 ? "fail" : "ok";
|
|
138
|
+
if (missing.length > 0) return {
|
|
76
139
|
name: "optional-peers",
|
|
77
|
-
status
|
|
78
|
-
detail: `
|
|
140
|
+
status,
|
|
141
|
+
detail: `${missing.length} configured feature(s) have no driver installed:\n` + missing.map((peer) => ` - ${peer.package} — required by ${peer.because}`).join("\n")
|
|
79
142
|
};
|
|
80
143
|
return {
|
|
81
144
|
name: "optional-peers",
|
|
82
|
-
status
|
|
83
|
-
detail: `
|
|
145
|
+
status,
|
|
146
|
+
detail: `every configured driver is installed (${required.map((peer) => peer.package).join(", ")})`
|
|
84
147
|
};
|
|
85
148
|
}
|
|
86
149
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"optional-peers.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/optional-peers.check.ts"],"sourcesContent":["import { createRequire } from \"node:module\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * A
|
|
1
|
+
{"version":3,"file":"optional-peers.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/optional-peers.check.ts"],"sourcesContent":["import { createRequire } from \"node:module\";\nimport { config } from \"../../../../config/config-getter\";\nimport type { CheckStatus, DoctorCheck } from \"../check.types\";\n\n/**\n * A package this project's own configuration requires, and what asked for it.\n */\ntype RequiredPeer = {\n /** The npm package name to probe via `require.resolve`. */\n package: string;\n\n /** The config that asked for it, quoted back to the user verbatim. */\n because: string;\n};\n\n/**\n * Resolve from the project's working directory, not from core's own\n * `node_modules`, so the probe reflects what the *consuming app* has installed.\n */\nconst projectRequire = createRequire(`${process.cwd()}/package.json`);\n\n/**\n * Whether a package can be resolved from the project. A failed resolve (the\n * normal \"not installed\" case) is swallowed and reported as missing.\n */\nfunction isInstalled(packageName: string): boolean {\n try {\n projectRequire.resolve(packageName);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * A non-empty string, or `undefined`. Config values arrive from `env(...)` and\n * an unset variable yields `undefined` — but a `.env` line with nothing after\n * the `=` yields `\"\"`, which is \"not configured\" just as much.\n */\nfunction text(value: unknown): string | undefined {\n return typeof value === \"string\" && value.trim().length > 0 ? value.trim() : undefined;\n}\n\n/**\n * The database driver's package. `driver` is a closed union in cascade\n * (`mongodb | postgres | mysql`), so an unrecognised value means the config is\n * wrong in a way this check is not the right one to report.\n */\nfunction databasePeer(): RequiredPeer | undefined {\n const driver = text(config.get(\"database.driver\"));\n\n const packageName =\n driver === \"mongodb\"\n ? \"mongodb\"\n : driver === \"postgres\"\n ? \"pg\"\n : driver === \"mysql\"\n ? \"mysql2\"\n : undefined;\n\n return packageName ? { package: packageName, because: `database.driver = \"${driver}\"` } : undefined;\n}\n\n/**\n * The selected cache driver. Only the DEFAULT matters: `cache.drivers` is a\n * menu the scaffold fills in for every driver the framework ships, and warning\n * about a menu entry nobody selected is the false positive this whole check was\n * rewritten to remove.\n */\nfunction cachePeer(): RequiredPeer | undefined {\n const selected = text(config.get(\"cache.default\"));\n\n if (!selected || !/redis/i.test(selected)) return undefined;\n\n return { package: \"redis\", because: `cache.default = \"${selected}\"` };\n}\n\n/**\n * The selected storage driver, read through its canonical `driver` field\n * (`local` / `s3` / `r2` / `spaces`) rather than the key the app happened to\n * name it, so a driver called \"cdn\" is still recognised as S3.\n */\nfunction storagePeer(): RequiredPeer | undefined {\n const selected = text(config.get(\"storage.default\"));\n\n if (!selected) return undefined;\n\n const driver = text(config.get(`storage.drivers.${selected}.driver`));\n\n if (!driver || driver === \"local\") return undefined;\n\n return {\n package: \"@aws-sdk/client-s3\",\n because: `storage.default = \"${selected}\" (driver \"${driver}\")`,\n };\n}\n\n/**\n * The mail transport. A `mail` config with no host and no driver is the\n * scaffold's placeholder — every field is an unset env var — so it asks for\n * nothing and this stays quiet.\n */\nfunction mailPeer(): RequiredPeer | undefined {\n const driver = text(config.get(\"mail.driver\"));\n\n if (driver === \"ses\") {\n return { package: \"@aws-sdk/client-sesv2\", because: `mail.driver = \"ses\"` };\n }\n\n const host = text(config.get(\"mail.host\"));\n\n if (!host) return undefined;\n\n return { package: \"nodemailer\", because: `mail.host = \"${host}\"` };\n}\n\n/**\n * Realtime sockets. The connector no-ops without a `socket` config, so the\n * presence of that config is exactly the request for the peer.\n */\nfunction socketPeer(): RequiredPeer | undefined {\n if (!config.get(\"socket\")) return undefined;\n\n return { package: \"socket.io\", because: \"a socket config is present\" };\n}\n\n/**\n * Every peer THIS project's configuration asks for.\n */\nfunction requiredPeers(): RequiredPeer[] {\n return [databasePeer(), cachePeer(), storagePeer(), mailPeer(), socketPeer()].filter(\n (peer): peer is RequiredPeer => Boolean(peer),\n );\n}\n\n/**\n * Checks that every optional peer the project's own configuration selects is\n * actually installed.\n *\n * NEEDS NO BOOTED APP — it reads config and resolves module paths. It runs after\n * the boot pass only because config is loaded by then.\n *\n * WHAT THIS REPLACED, AND WHY: the previous version probed nine packages and\n * warned about every one that was absent. Every one of them is an OPTIONAL\n * peer — a fresh app installs none of them by design — so the check warned, at\n * length, about the framework's own default state. \"No MySQL driver installed\"\n * is not a diagnosis for someone who never wanted MySQL; it is noise that\n * teaches the reader to skim past the line where a real finding will one day\n * appear.\n *\n * Inverted, it becomes a genuine one: a project whose config selects a driver\n * whose package is missing is broken — it will throw the first time that\n * feature is used — so a missing peer here is a `fail`, not a warning.\n */\nexport const optionalPeersCheck: DoctorCheck = {\n name: \"optional-peers\",\n run: () => {\n const required = requiredPeers();\n\n if (required.length === 0) {\n return undefined;\n }\n\n const missing = required.filter((peer) => !isInstalled(peer.package));\n\n const status: CheckStatus = missing.length > 0 ? \"fail\" : \"ok\";\n\n if (missing.length > 0) {\n return {\n name: \"optional-peers\",\n status,\n detail:\n `${missing.length} configured feature(s) have no driver installed:\\n` +\n missing.map((peer) => ` - ${peer.package} — required by ${peer.because}`).join(\"\\n\"),\n };\n }\n\n return {\n name: \"optional-peers\",\n status,\n detail: `every configured driver is installed (${required\n .map((peer) => peer.package)\n .join(\", \")})`,\n };\n },\n};\n"],"mappings":";;;;;;;;AAmBA,MAAM,iBAAiB,cAAc,GAAG,QAAQ,IAAI,EAAE,cAAc;;;;;AAMpE,SAAS,YAAY,aAA8B;CACjD,IAAI;EACF,eAAe,QAAQ,WAAW;EAClC,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;;;AAOA,SAAS,KAAK,OAAoC;CAChD,OAAO,OAAO,UAAU,YAAY,MAAM,KAAK,CAAC,CAAC,SAAS,IAAI,MAAM,KAAK,IAAI;AAC/E;;;;;;AAOA,SAAS,eAAyC;CAChD,MAAM,SAAS,KAAK,OAAO,IAAI,iBAAiB,CAAC;CAEjD,MAAM,cACJ,WAAW,YACP,YACA,WAAW,aACT,OACA,WAAW,UACT,WACA;CAEV,OAAO,cAAc;EAAE,SAAS;EAAa,SAAS,sBAAsB,OAAO;CAAG,IAAI;AAC5F;;;;;;;AAQA,SAAS,YAAsC;CAC7C,MAAM,WAAW,KAAK,OAAO,IAAI,eAAe,CAAC;CAEjD,IAAI,CAAC,YAAY,CAAC,SAAS,KAAK,QAAQ,GAAG,OAAO;CAElD,OAAO;EAAE,SAAS;EAAS,SAAS,oBAAoB,SAAS;CAAG;AACtE;;;;;;AAOA,SAAS,cAAwC;CAC/C,MAAM,WAAW,KAAK,OAAO,IAAI,iBAAiB,CAAC;CAEnD,IAAI,CAAC,UAAU,OAAO;CAEtB,MAAM,SAAS,KAAK,OAAO,IAAI,mBAAmB,SAAS,QAAQ,CAAC;CAEpE,IAAI,CAAC,UAAU,WAAW,SAAS,OAAO;CAE1C,OAAO;EACL,SAAS;EACT,SAAS,sBAAsB,SAAS,aAAa,OAAO;CAC9D;AACF;;;;;;AAOA,SAAS,WAAqC;CAG5C,IAFe,KAAK,OAAO,IAAI,aAAa,CAEnC,MAAM,OACb,OAAO;EAAE,SAAS;EAAyB,SAAS;CAAsB;CAG5E,MAAM,OAAO,KAAK,OAAO,IAAI,WAAW,CAAC;CAEzC,IAAI,CAAC,MAAM,OAAO;CAElB,OAAO;EAAE,SAAS;EAAc,SAAS,gBAAgB,KAAK;CAAG;AACnE;;;;;AAMA,SAAS,aAAuC;CAC9C,IAAI,CAAC,OAAO,IAAI,QAAQ,GAAG,OAAO;CAElC,OAAO;EAAE,SAAS;EAAa,SAAS;CAA6B;AACvE;;;;AAKA,SAAS,gBAAgC;CACvC,OAAO;EAAC,aAAa;EAAG,UAAU;EAAG,YAAY;EAAG,SAAS;EAAG,WAAW;CAAC,CAAC,CAAC,QAC3E,SAA+B,QAAQ,IAAI,CAC9C;AACF;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,qBAAkC;CAC7C,MAAM;CACN,WAAW;EACT,MAAM,WAAW,cAAc;EAE/B,IAAI,SAAS,WAAW,GACtB;EAGF,MAAM,UAAU,SAAS,QAAQ,SAAS,CAAC,YAAY,KAAK,OAAO,CAAC;EAEpE,MAAM,SAAsB,QAAQ,SAAS,IAAI,SAAS;EAE1D,IAAI,QAAQ,SAAS,GACnB,OAAO;GACL,MAAM;GACN;GACA,QACE,GAAG,QAAQ,OAAO,sDAClB,QAAQ,KAAK,SAAS,OAAO,KAAK,QAAQ,iBAAiB,KAAK,SAAS,CAAC,CAAC,KAAK,IAAI;EACxF;EAGF,OAAO;GACL,MAAM;GACN;GACA,QAAQ,yCAAyC,SAC9C,KAAK,SAAS,KAAK,OAAO,CAAC,CAC3B,KAAK,IAAI,EAAE;EAChB;CACF;AACF"}
|
|
@@ -16,29 +16,47 @@ function parseTopHeading(changelog) {
|
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* the
|
|
22
|
-
*
|
|
19
|
+
* Whether this project is something anyone could `npm publish`.
|
|
20
|
+
*
|
|
21
|
+
* THE GATE, and the reason it exists: a changelog is a promise to the people
|
|
22
|
+
* who install your package. An application has no such people. Running this
|
|
23
|
+
* check on every project projected the framework monorepo's own release
|
|
24
|
+
* practice onto users building an app, who were told — on every single run,
|
|
25
|
+
* forever — that their private application was missing a CHANGELOG.md it had no
|
|
26
|
+
* reason to have. `"private": true` is exactly the field npm itself reads to
|
|
27
|
+
* refuse a publish, so it is the honest test of whether the concern applies.
|
|
28
|
+
*/
|
|
29
|
+
function isPublishable(manifest) {
|
|
30
|
+
return manifest.private !== true && typeof manifest.name === "string";
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Checks that a PUBLISHABLE package's `package.json` version matches the
|
|
34
|
+
* top-most `## x.y.z` heading in `CHANGELOG.md` — the same version↔changelog
|
|
35
|
+
* invariant the release-hygiene unit guard enforces, surfaced as a pre-release
|
|
36
|
+
* doctor check.
|
|
37
|
+
*
|
|
38
|
+
* NEEDS NO BOOTED APP — it reads two files from the project root.
|
|
23
39
|
*
|
|
24
40
|
* Verdicts:
|
|
25
|
-
* - no
|
|
41
|
+
* - `"private": true`, or no package name → not applicable, no line at all;
|
|
42
|
+
* - no `CHANGELOG.md` → `warn` (recommended for a published package, not
|
|
43
|
+
* mandatory);
|
|
26
44
|
* - no parseable heading → `warn`;
|
|
27
45
|
* - heading version ≠ package.json version → `fail`;
|
|
28
46
|
* - otherwise → `ok`.
|
|
29
|
-
*
|
|
30
|
-
* Read-only: reads `package.json` and `CHANGELOG.md` from the project root.
|
|
31
47
|
*/
|
|
32
48
|
const releaseHygieneCheck = {
|
|
33
49
|
name: "release-hygiene",
|
|
34
50
|
run: () => {
|
|
35
51
|
const packageJsonPath = rootPath("package.json");
|
|
36
52
|
const changelogPath = rootPath("CHANGELOG.md");
|
|
37
|
-
const
|
|
53
|
+
const manifest = JSON.parse(readFileSync(packageJsonPath, "utf8"));
|
|
54
|
+
if (!isPublishable(manifest)) return;
|
|
55
|
+
const pkgVersion = manifest.version;
|
|
38
56
|
if (typeof pkgVersion !== "string") return {
|
|
39
57
|
name: "release-hygiene",
|
|
40
58
|
status: "fail",
|
|
41
|
-
detail: "package.json has no string version field"
|
|
59
|
+
detail: "package.json is publishable but has no string version field"
|
|
42
60
|
};
|
|
43
61
|
if (!existsSync(changelogPath)) return {
|
|
44
62
|
name: "release-hygiene",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"release-hygiene.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/release-hygiene.check.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { rootPath } from \"../../../../utils/paths\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * A `## x.y.z` changelog heading with an optional ` - YYYY-MM-DD` suffix.\n * Mirrors the shape parsed by the tier-0 release-hygiene unit guard; inlined\n * here because that guard lives in the test tree and is not importable from\n * `src`.\n */\ntype ChangelogHeading = {\n version: string;\n date?: string;\n};\n\n/**\n * Parse the top-most `## x.y.z[ - YYYY-MM-DD]` heading out of a CHANGELOG body.\n * Returns `undefined` when no version heading is present.\n */\nfunction parseTopHeading(changelog: string): ChangelogHeading | undefined {\n const match = changelog.match(\n /^##\\s+(\\d+)\\.(\\d+)\\.(\\d+)(?:\\s*[-–]\\s*(\\d{4}-\\d{2}-\\d{2}))?\\s*$/m,\n );\n\n if (!match) {\n return undefined;\n }\n\n const [, major, minor, patch, date] = match;\n\n return {\n version: `${major}.${minor}.${patch}`,\n date,\n };\n}\n\n/**\n *
|
|
1
|
+
{"version":3,"file":"release-hygiene.check.mjs","names":[],"sources":["../../../../../../../../../../core/src/cli/commands/doctor/checks/release-hygiene.check.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\nimport { rootPath } from \"../../../../utils/paths\";\nimport type { DoctorCheck } from \"../check.types\";\n\n/**\n * A `## x.y.z` changelog heading with an optional ` - YYYY-MM-DD` suffix.\n * Mirrors the shape parsed by the tier-0 release-hygiene unit guard; inlined\n * here because that guard lives in the test tree and is not importable from\n * `src`.\n */\ntype ChangelogHeading = {\n version: string;\n date?: string;\n};\n\n/**\n * The parts of `package.json` this check reads.\n */\ntype ProjectManifest = {\n version?: unknown;\n private?: unknown;\n name?: unknown;\n};\n\n/**\n * Parse the top-most `## x.y.z[ - YYYY-MM-DD]` heading out of a CHANGELOG body.\n * Returns `undefined` when no version heading is present.\n */\nfunction parseTopHeading(changelog: string): ChangelogHeading | undefined {\n const match = changelog.match(\n /^##\\s+(\\d+)\\.(\\d+)\\.(\\d+)(?:\\s*[-–]\\s*(\\d{4}-\\d{2}-\\d{2}))?\\s*$/m,\n );\n\n if (!match) {\n return undefined;\n }\n\n const [, major, minor, patch, date] = match;\n\n return {\n version: `${major}.${minor}.${patch}`,\n date,\n };\n}\n\n/**\n * Whether this project is something anyone could `npm publish`.\n *\n * THE GATE, and the reason it exists: a changelog is a promise to the people\n * who install your package. An application has no such people. Running this\n * check on every project projected the framework monorepo's own release\n * practice onto users building an app, who were told — on every single run,\n * forever — that their private application was missing a CHANGELOG.md it had no\n * reason to have. `\"private\": true` is exactly the field npm itself reads to\n * refuse a publish, so it is the honest test of whether the concern applies.\n */\nfunction isPublishable(manifest: ProjectManifest): boolean {\n return manifest.private !== true && typeof manifest.name === \"string\";\n}\n\n/**\n * Checks that a PUBLISHABLE package's `package.json` version matches the\n * top-most `## x.y.z` heading in `CHANGELOG.md` — the same version↔changelog\n * invariant the release-hygiene unit guard enforces, surfaced as a pre-release\n * doctor check.\n *\n * NEEDS NO BOOTED APP — it reads two files from the project root.\n *\n * Verdicts:\n * - `\"private\": true`, or no package name → not applicable, no line at all;\n * - no `CHANGELOG.md` → `warn` (recommended for a published package, not\n * mandatory);\n * - no parseable heading → `warn`;\n * - heading version ≠ package.json version → `fail`;\n * - otherwise → `ok`.\n */\nexport const releaseHygieneCheck: DoctorCheck = {\n name: \"release-hygiene\",\n run: () => {\n const packageJsonPath = rootPath(\"package.json\");\n const changelogPath = rootPath(\"CHANGELOG.md\");\n\n const manifest: ProjectManifest = JSON.parse(readFileSync(packageJsonPath, \"utf8\"));\n\n if (!isPublishable(manifest)) {\n return undefined;\n }\n\n const pkgVersion = manifest.version;\n\n if (typeof pkgVersion !== \"string\") {\n return {\n name: \"release-hygiene\",\n status: \"fail\",\n detail: \"package.json is publishable but has no string version field\",\n };\n }\n\n if (!existsSync(changelogPath)) {\n return {\n name: \"release-hygiene\",\n status: \"warn\",\n detail: `no CHANGELOG.md at project root (package.json is ${pkgVersion})`,\n };\n }\n\n const changelog = readFileSync(changelogPath, \"utf8\");\n const heading = parseTopHeading(changelog);\n\n if (!heading) {\n return {\n name: \"release-hygiene\",\n status: \"warn\",\n detail: \"CHANGELOG.md has no parseable `## x.y.z` heading\",\n };\n }\n\n if (heading.version !== pkgVersion) {\n return {\n name: \"release-hygiene\",\n status: \"fail\",\n detail: `package.json ${pkgVersion} ≠ top CHANGELOG heading ${heading.version}`,\n };\n }\n\n return {\n name: \"release-hygiene\",\n status: \"ok\",\n detail: `package.json and CHANGELOG agree on ${pkgVersion}`,\n };\n },\n};\n"],"mappings":";;;;;;;;AA4BA,SAAS,gBAAgB,WAAiD;CACxE,MAAM,QAAQ,UAAU,MACtB,kEACF;CAEA,IAAI,CAAC,OACH;CAGF,MAAM,GAAG,OAAO,OAAO,OAAO,QAAQ;CAEtC,OAAO;EACL,SAAS,GAAG,MAAM,GAAG,MAAM,GAAG;EAC9B;CACF;AACF;;;;;;;;;;;;AAaA,SAAS,cAAc,UAAoC;CACzD,OAAO,SAAS,YAAY,QAAQ,OAAO,SAAS,SAAS;AAC/D;;;;;;;;;;;;;;;;;AAkBA,MAAa,sBAAmC;CAC9C,MAAM;CACN,WAAW;EACT,MAAM,kBAAkB,SAAS,cAAc;EAC/C,MAAM,gBAAgB,SAAS,cAAc;EAE7C,MAAM,WAA4B,KAAK,MAAM,aAAa,iBAAiB,MAAM,CAAC;EAElF,IAAI,CAAC,cAAc,QAAQ,GACzB;EAGF,MAAM,aAAa,SAAS;EAE5B,IAAI,OAAO,eAAe,UACxB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,IAAI,CAAC,WAAW,aAAa,GAC3B,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,oDAAoD,WAAW;EACzE;EAIF,MAAM,UAAU,gBADE,aAAa,eAAe,MACN,CAAC;EAEzC,IAAI,CAAC,SACH,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ;EACV;EAGF,IAAI,QAAQ,YAAY,YACtB,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,gBAAgB,WAAW,2BAA2B,QAAQ;EACxE;EAGF,OAAO;GACL,MAAM;GACN,QAAQ;GACR,QAAQ,uCAAuC;EACjD;CACF;AACF"}
|
|
@@ -1,26 +1,65 @@
|
|
|
1
|
-
import { router } from "../../../../router/router.mjs";
|
|
2
|
-
|
|
3
1
|
//#region ../core/src/cli/commands/doctor/checks/routes.check.ts
|
|
4
2
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
3
|
+
* Describe the failed modules as an indented block, one per line, cause first.
|
|
4
|
+
* The file that failed is the only thing that makes this actionable, so it is
|
|
5
|
+
* never summarised away.
|
|
6
|
+
*/
|
|
7
|
+
function describeFailures(context) {
|
|
8
|
+
return context.moduleFailures.map((failure) => ` - ${failure.file}: ${failure.message}`).join("\n");
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reports the route surface the app will actually serve.
|
|
8
12
|
*
|
|
9
|
-
*
|
|
13
|
+
* NEEDS A BOOTED APP — and now gets one. Routes exist only as the side effect of
|
|
14
|
+
* importing the module that declares them, so a doctor that did not import app
|
|
15
|
+
* code reported `0 routes` for an app the dev server served fourteen routes
|
|
16
|
+
* for. The boot pass imports the same modules, in the same order, and boots
|
|
17
|
+
* (never starts) the same connectors, so the number printed here is the number
|
|
18
|
+
* `warlock dev` prints for the same project — the two are read off the same
|
|
19
|
+
* router after the same registrations.
|
|
20
|
+
*
|
|
21
|
+
* Verdicts:
|
|
22
|
+
* - a module failed to import → `fail`, naming every file and its cause. This
|
|
23
|
+
* is the finding doctor exists for: a route file that throws on import is
|
|
24
|
+
* invisible at runtime, it just 404s;
|
|
25
|
+
* - route modules on disk but nothing registered → `fail` (they loaded and
|
|
26
|
+
* registered nothing, which is not a state a working app is in);
|
|
27
|
+
* - no route modules at all → `ok`, stated plainly. A worker or CLI-only app is
|
|
28
|
+
* a legitimate shape and must not be nagged;
|
|
29
|
+
* - otherwise → `ok` with the count, split into what app code registered and
|
|
30
|
+
* what connectors added.
|
|
10
31
|
*/
|
|
11
32
|
const routesCheck = {
|
|
12
33
|
name: "routes",
|
|
13
|
-
run: () => {
|
|
14
|
-
|
|
15
|
-
|
|
34
|
+
run: (context) => {
|
|
35
|
+
if (context.moduleFailures.length > 0) return {
|
|
36
|
+
name: "routes",
|
|
37
|
+
status: "fail",
|
|
38
|
+
detail: `${context.moduleFailures.length} app module(s) failed to load — the routes they declare will 404:\n` + describeFailures(context)
|
|
39
|
+
};
|
|
40
|
+
if (!context.booted) return {
|
|
16
41
|
name: "routes",
|
|
17
|
-
status: "
|
|
18
|
-
detail: "
|
|
42
|
+
status: "fail",
|
|
43
|
+
detail: "app modules could not be discovered — nothing to report on"
|
|
19
44
|
};
|
|
45
|
+
if (context.totalRoutes === 0) {
|
|
46
|
+
if (context.routeModules === 0) return {
|
|
47
|
+
name: "routes",
|
|
48
|
+
status: "ok",
|
|
49
|
+
detail: "no route modules found (src/app/*/routes.ts) — this app serves no HTTP routes"
|
|
50
|
+
};
|
|
51
|
+
return {
|
|
52
|
+
name: "routes",
|
|
53
|
+
status: "fail",
|
|
54
|
+
detail: `${context.routeModules} route module(s) loaded without error but registered 0 routes — every request will 404`
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const fromConnectors = context.totalRoutes - context.appRoutes;
|
|
58
|
+
const breakdown = fromConnectors > 0 ? `${context.appRoutes} from ${context.routeModules} route module(s), ${fromConnectors} from connectors` : `from ${context.routeModules} route module(s)`;
|
|
20
59
|
return {
|
|
21
60
|
name: "routes",
|
|
22
61
|
status: "ok",
|
|
23
|
-
detail: `${
|
|
62
|
+
detail: `${context.totalRoutes} registered (${breakdown})`
|
|
24
63
|
};
|
|
25
64
|
}
|
|
26
65
|
};
|