@warlock.js/core 5.3.2 → 5.5.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 +24 -0
- package/esm/cli/cli-commands.utils.mjs +27 -9
- package/esm/cli/cli-commands.utils.mjs.map +1 -1
- package/esm/dev-server/shortcuts.mjs +27 -6
- package/esm/dev-server/shortcuts.mjs.map +1 -1
- package/esm/generations/features/react-email.feature.mjs +3 -9
- package/esm/generations/features/react-email.feature.mjs.map +1 -1
- package/esm/generations/features/shared/patch-tsconfig-include.mjs +85 -0
- package/esm/generations/features/shared/patch-tsconfig-include.mjs.map +1 -0
- package/esm/generations/features/shared/relocate-conflicting-home-route.mjs +153 -0
- package/esm/generations/features/shared/relocate-conflicting-home-route.mjs.map +1 -0
- package/esm/generations/features/shared/resolve-contact-scaffold.mjs +45 -0
- package/esm/generations/features/shared/resolve-contact-scaffold.mjs.map +1 -0
- package/esm/generations/features/web.feature.mjs +18 -93
- package/esm/generations/features/web.feature.mjs.map +1 -1
- package/esm/generations/stubs.mjs +59 -19
- package/esm/generations/stubs.mjs.map +1 -1
- package/llms-full.txt +60 -60
- package/llms.txt +3 -3
- package/package.json +12 -12
- package/skills/create-controller/SKILL.md +1 -1
- package/skills/create-module/SKILL.md +11 -11
- package/skills/hash-password/SKILL.md +4 -4
- package/skills/run-app/SKILL.md +16 -16
- package/skills/send-mail/SKILL.md +2 -2
- package/skills/use-model-transformers/SKILL.md +2 -2
- package/skills/use-repository/SKILL.md +1 -1
- package/skills/validate-input/SKILL.md +1 -1
- package/skills/warlock-doctor/SKILL.md +3 -3
- package/skills/warlock-routes/SKILL.md +10 -10
- package/skills/write-cli-command/SKILL.md +1 -1
- package/skills/write-seeder/SKILL.md +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
> ⚠ **Versioning: `@warlock.js/*` does not follow SemVer strictly — breaking changes may ship in a minor.** This is a deliberate decision, not an oversight: the framework is pre-adoption and the cost of a major per behaviour fix currently outweighs the benefit. **Pin an exact version or a tilde range (`~4.13.0`) if you need to opt into changes rather than receive them.** Every breaking change is marked **BREAKING** in its entry and summarised in an *Upgrading* section at the top of the release. **This policy will change once the framework has consumers beyond its author.**
|
|
8
8
|
|
|
9
|
+
## 5.5.0 - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `warlock add web` silently overwrote an existing `src/app/contact` module. It guarded `src/web/root.tsx` against clobbering a human's work but wrote the contact route and controller unconditionally, destroying them without a word. Each file is now guarded on its own existence, and a skip reports the consequence — that the contact form's `POST /api/contact` endpoint is missing and the form will 404 until you wire it.
|
|
14
|
+
- Documentation shipped in `skills/` told users to run `pnpm`-specific commands — including `pnpm warlock routes --json`, which cannot work under npm at all, since `pnpm <binary>` has no npm equivalent. Commands are now package-manager neutral.
|
|
15
|
+
|
|
16
|
+
## 5.4.0 - 2026-09-07
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `warlock routes --json` could not be parsed. The command's success banner shared stdout with the JSON payload, so the documented machine seam — "emit the routes as JSON for piping into scripts/CI" — produced output no consumer could read, and always had.
|
|
21
|
+
- `warlock add react-email` failed on every freshly scaffolded app. It read the project `tsconfig.json` with `JSON.parse`, and the scaffold's own tsconfig carries `//` comments, so the command aborted pointing at the developer's file.
|
|
22
|
+
- `warlock add web` produced an app whose homepage returned HTTP 500. `GET /` was registered twice — by the scaffold's own home route and by the generated page — and Fastify refused the duplicate. Affected both `warlock dev` and `warlock start`.
|
|
23
|
+
- `warlock add web` generated code that failed the scaffold's own lint gate: twelve `prettier/prettier` errors in files the developer had not written.
|
|
24
|
+
- `warlock add notifications` generated a controller that did not compile — seven `TS2345` errors from passing `request.user` where a `Notifiable | Id` was required.
|
|
25
|
+
- Every `warlock` command opened a stdin handle at import time, through a module-level singleton whose constructor defaulted to `process.stdin`. Only `warlock dev` has any use for stdin.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **Command success and failure banners now write to stderr, not stdout.** stdout carries a command's output; status chrome carries no data. A script that captured only stdout to grep for `✔ … completed successfully` must now read stderr. Nothing could have depended on the previous behaviour for `--json`, whose payload was unparseable precisely because of it.
|
|
30
|
+
- Feature generators that patch `tsconfig.json` now edit its text instead of parsing and rewriting it, so the file's comments survive.
|
|
31
|
+
- `warlock add web` locates an existing `GET /` by scanning `src/app/**/routes.ts` rather than assuming one hardcoded path.
|
|
32
|
+
|
|
9
33
|
## 5.3.2 - 2026-09-05
|
|
10
34
|
|
|
11
35
|
### Fixed
|
|
@@ -125,22 +125,40 @@ function displayMissingCommand() {
|
|
|
125
125
|
console.log();
|
|
126
126
|
}
|
|
127
127
|
/**
|
|
128
|
-
* Display command success message
|
|
128
|
+
* Display command success message.
|
|
129
|
+
*
|
|
130
|
+
* Written to STDERR, not stdout. This banner is printed around EVERY command,
|
|
131
|
+
* including ones whose stdout is a machine payload a script is meant to parse —
|
|
132
|
+
* `warlock routes --json` is the clearest case. On stdout it produced this,
|
|
133
|
+
* which is not JSON and cannot be piped anywhere:
|
|
134
|
+
*
|
|
135
|
+
* ```
|
|
136
|
+
* []
|
|
137
|
+
*
|
|
138
|
+
* ✔ routes completed successfully (696ms)
|
|
139
|
+
* ```
|
|
140
|
+
*
|
|
141
|
+
* stdout belongs to the command's output; status chrome belongs to stderr,
|
|
142
|
+
* where it stays visible to a person in a terminal and out of a pipe.
|
|
129
143
|
*/
|
|
130
144
|
function displayCommandSuccess(commandName, durationMs) {
|
|
131
145
|
const duration = durationMs ? colors.dim(` (${durationMs}ms)`) : "";
|
|
132
|
-
console.
|
|
133
|
-
console.
|
|
134
|
-
console.
|
|
146
|
+
console.error();
|
|
147
|
+
console.error(` ${colors.green("✔")} ${colors.bold(commandName)} completed successfully${duration}`);
|
|
148
|
+
console.error();
|
|
135
149
|
}
|
|
136
150
|
/**
|
|
137
|
-
* Display command error message
|
|
151
|
+
* Display command error message.
|
|
152
|
+
*
|
|
153
|
+
* On stderr for the same reason as {@link displayCommandSuccess}, and one
|
|
154
|
+
* stronger: a failure interleaved into a machine payload corrupts the payload
|
|
155
|
+
* AND hides the failure from anyone reading stderr for it.
|
|
138
156
|
*/
|
|
139
157
|
function displayCommandError(commandName, error) {
|
|
140
|
-
console.
|
|
141
|
-
console.
|
|
142
|
-
console.
|
|
143
|
-
console.
|
|
158
|
+
console.error();
|
|
159
|
+
console.error(` ${colors.red("✖")} ${colors.bold(commandName)} failed`);
|
|
160
|
+
console.error(` ${colors.dim(error.message)}`);
|
|
161
|
+
console.error();
|
|
144
162
|
}
|
|
145
163
|
/**
|
|
146
164
|
* Display a FATAL boot/preload failure and exit information.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli-commands.utils.mjs","names":[],"sources":["../../../../../../../core/src/cli/cli-commands.utils.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport type { Environment } from \"../utils\";\nimport { getWarlockVersion } from \"../utils/framework-vesion\";\n\nexport function isMatchingCommandName(commandName: string, targetingCommandName: string) {\n return commandName.split(\" \")[0] === targetingCommandName;\n}\n\n/**\n * Display the Warlock.js version banner\n */\nexport async function displayWarlockVersionInTerminal() {\n const version = await getWarlockVersion();\n console.log(`⚡ ${colors.bold(\"Warlock.js\")} ${colors.greenBright(`v${version}`)}`);\n}\n\ntype StartBannerOptions = {\n environment: Environment;\n};\n\nfunction getTextColorMethod(environment: Environment) {\n switch (environment) {\n case \"development\":\n return colors.yellowBright;\n case \"production\":\n return colors.greenBright;\n case \"test\":\n return colors.blueBright;\n default:\n return colors.whiteBright;\n }\n}\n\n/**\n * Display CLI startup banner\n */\nexport async function displayStartupBanner({ environment }: StartBannerOptions) {\n const version = await getWarlockVersion();\n const textColorMethod = getTextColorMethod(environment);\n console.log(` ⚡ ${colors.bold(textColorMethod(\"Warlock.js\"))} ${colors.dim(`v${version}`)}`);\n console.log();\n}\n\ntype ProductionReadyBannerOptions = {\n bootDurationMs?: number;\n};\n\n/**\n * Announce that the production server is up — printed on **stdout**, and only\n * after the child process reported a completed boot.\n *\n * Stdout is the channel a supervisor or CI gate greps to decide the service is\n * healthy, so nothing optimistic may be written to it: this function is the\n * single place allowed to say \"started\", and it is called from exactly one\n * place, the `warlock:ready` handler in the `start` command.\n */\nexport async function displayProductionReadyBanner({\n bootDurationMs,\n}: ProductionReadyBannerOptions = {}) {\n const version = await getWarlockVersion();\n const duration = bootDurationMs ? colors.dim(` in ${bootDurationMs}ms`) : \"\";\n\n console.log(\n ` ⚡ ${colors.bold(colors.greenBright(\"Warlock.js\"))} ${colors.dim(`v${version}`)} ${colors.green(\"✔\")} production server started${duration}`,\n );\n console.log();\n}\n\n/**\n * Report that the production server died before it ever served anything.\n *\n * Written to **stderr** only. `warlock start` inherits both child streams, and\n * production launchers commonly merge them (`2>&1`), so mirroring this block to\n * stdout prints every line twice. The command's non-zero exit code is the\n * machine-readable failure signal; stdout remains reserved for the ready\n * banner.\n *\n * `causeWasCaptured` gates the \"the cause is printed above\" line. The\n * supervisor cannot always guarantee the child wrote anything before it\n * died — an import that throws before the logger configures its channels,\n * a bundle with no console channel at all — and claiming a cause is above\n * when nothing was ever captured sends the developer looking for output\n * that doesn't exist, which is worse than admitting the gap.\n */\nexport function displayProductionStartFailure(\n exitCode: number,\n causeWasCaptured: boolean,\n) {\n const causeLine = causeWasCaptured\n ? ` ${colors.dim(\"the cause is printed above, in the application's own output\")}`\n : ` ${colors.dim(\"no output was captured from the application process — its cause did not reach this terminal\")}`;\n\n const lines = [\n \"\",\n ` ${colors.red(\"✖\")} ${colors.bold(\"warlock start\")} failed — the server never finished booting`,\n ` ${colors.dim(`the application process exited with code ${exitCode}`)}`,\n causeLine,\n \"\",\n ];\n\n for (const line of lines) {\n console.error(line);\n }\n}\n\n/**\n * Note that a running child has not reported readiness yet.\n *\n * Emitted on **stderr** only. The process may be perfectly healthy — an older\n * bundle has no readiness signal at all — so saying anything on stdout would be\n * the very false-green this channel exists to prevent.\n *\n * The wording offers the likely causes rather than asserting one. It cannot\n * distinguish an old bundle from a slow boot from a boot that is about to fail,\n * and an earlier version claimed \"the bundle predates readiness reporting\" —\n * which was simply wrong when a current bundle was mid-crash, and sent the\n * reader after the wrong thing.\n */\nexport function displayMissingReadinessNotice(waitedMs: number) {\n console.error();\n console.error(\n ` ${colors.yellow(\"!\")} still running after ${Math.round(waitedMs / 1000)}s with no readiness signal`,\n );\n console.error(` ${colors.dim(\"either the boot is still in progress, or this bundle was built\")}`);\n console.error(\n ` ${colors.dim(\"before readiness reporting — re-run\")} ${colors.cyan(\"warlock build\")} ${colors.dim(\"if the banner never appears\")}`,\n );\n console.error();\n}\n\n/**\n * Display command execution header\n */\nexport function displayExecutingCommand(commandName: string) {\n console.log(` ${colors.cyan(\"›\")} Running ${colors.bold(colors.white(commandName))}...`);\n console.log();\n}\n\n/**\n * Display command not found error with optional suggestions\n */\nexport function displayCommandNotFound(commandName: string, suggestions?: string[]) {\n console.log();\n console.log(` ${colors.red(\"✖\")} Command ${colors.magenta(commandName)} not found`);\n\n if (suggestions && suggestions.length > 0) {\n console.log();\n console.log(` ${colors.yellow(\"Did you mean?\")}`);\n suggestions.forEach((suggestion) => {\n console.log(` ${colors.cyan(\"→\")} ${colors.white(suggestion)}`);\n });\n }\n\n console.log();\n console.log(\n ` ${colors.dim(\"Run\")} ${colors.cyan(\"warlock --help\")} ${colors.dim(\"to see available commands\")}`,\n );\n console.log();\n}\n\n/**\n * Display missing command error\n */\nexport function displayMissingCommand() {\n console.log();\n console.log(` ${colors.red(\"✖\")} No command specified`);\n console.log(\n ` ${colors.dim(\"Run\")} ${colors.cyan(\"warlock --help\")} ${colors.dim(\"to see available commands\")}`,\n );\n console.log();\n}\n\n/**\n * Display command success message\n */\nexport function displayCommandSuccess(commandName: string, durationMs?: number) {\n const duration = durationMs ? colors.dim(` (${durationMs}ms)`) : \"\";\n console.log();\n console.log(\n ` ${colors.green(\"✔\")} ${colors.bold(commandName)} completed successfully${duration}`,\n );\n console.log();\n}\n\n/**\n * Display command error message\n */\nexport function displayCommandError(commandName: string, error: Error) {\n console.log();\n console.log(` ${colors.red(\"✖\")} ${colors.bold(commandName)} failed`);\n console.log(` ${colors.dim(error.message)}`);\n console.log();\n}\n\n/**\n * Display a FATAL boot/preload failure and exit information.\n *\n * Preload failures — a bad import in a config file, a connector that throws\n * on startup, a missing module export — happen BEFORE the command's run loop\n * exists, so there is nothing to recover into: they are always fatal. Unlike\n * `displayCommandError`, this prints the full stack (not just `error.message`)\n * because the stack names the exact file + line of the offending import, which\n * is the single most useful clue when a config file pulls in a broken module.\n *\n * Surfacing this loudly is the difference between a clear error and a silent\n * hang: an unhandled preload rejection used to escape while the already-started\n * loader worker thread kept the process alive, leaving `warlock dev` frozen\n * just after the banner with no message at all.\n *\n * @example\n * // SyntaxError: The requested module '@warlock.js/cascade' does not\n * // provide an export named 'belongsTo'\n * // at src/app/.../permission.model.ts:2\n */\nexport function displayBootError(commandName: string, error: Error) {\n console.log();\n console.log(` ${colors.red(\"✖\")} ${colors.bold(commandName)} failed to start`);\n console.log(` ${colors.red(error.message)}`);\n if (error.stack) {\n console.log();\n console.log(colors.dim(error.stack));\n }\n console.log();\n}\n\n/**\n * Display missing required options error\n */\nexport function displayMissingOptions(options: { name: string; text: string }[]) {\n console.log();\n console.log(` ${colors.red(\"✖\")} Missing required options:`);\n options.forEach((opt) => {\n console.log(` ${colors.yellow(opt.text)} ${colors.dim(`(--${opt.name})`)}`);\n });\n console.log();\n}\n\n/**\n * Command info for help display\n */\nexport type HelpCommandInfo = {\n name: string;\n alias?: string;\n description?: string;\n source: \"framework\" | \"plugin\" | \"project\";\n};\n\n/**\n * Display global help with all commands grouped by source\n */\nexport async function displayHelp(commands: HelpCommandInfo[]) {\n const version = await getWarlockVersion();\n\n console.log();\n console.log(\n ` ⚡ ${colors.bold(colors.yellowBright(\"Warlock.js\"))} CLI ${colors.dim(`v${version}`)}`,\n );\n console.log();\n console.log(\n ` ${colors.bold(\"Usage:\")} ${colors.cyan(\"warlock\")} ${colors.dim(\"<command>\")} ${colors.dim(\"[options]\")}`,\n );\n console.log();\n\n // Group by source\n const grouped: Record<string, HelpCommandInfo[]> = {\n framework: [],\n plugin: [],\n project: [],\n };\n\n commands.forEach((cmd) => {\n grouped[cmd.source]?.push(cmd);\n });\n\n // Display each group\n const groupLabels: Record<string, string> = {\n framework: \"Framework Commands\",\n plugin: \"Plugin Commands\",\n project: \"Project Commands\",\n };\n\n for (const [source, cmds] of Object.entries(grouped)) {\n if (cmds.length === 0) continue;\n\n console.log(` ${colors.bold(colors.white(groupLabels[source]))}`);\n console.log();\n\n // Find max name length for alignment\n const maxLen = Math.max(...cmds.map((c) => c.name.length + (c.alias ? c.alias.length + 4 : 0)));\n\n cmds.forEach((cmd) => {\n const aliasStr = cmd.alias ? colors.dim(` (${cmd.alias})`) : \"\";\n const nameWithAlias = cmd.name + (cmd.alias ? ` (${cmd.alias})` : \"\");\n const padding = \" \".repeat(maxLen - nameWithAlias.length + 2);\n // const desc = cmd.description || colors.dim(\"No description\");\n const desc = cmd.description || \"\";\n console.log(` ${colors.cyan(cmd.name)}${aliasStr}${padding}${desc}`);\n });\n console.log();\n }\n\n // Display global flags\n console.log(` ${colors.bold(colors.white(\"Global Flags\"))}`);\n console.log();\n\n const globalFlags = [\n { flag: \"--help, -h\", description: \"Show help for a command\" },\n { flag: \"--version, -v\", description: \"Show Warlock version\" },\n { flag: \"--no-cache\", description: \"Force reload without cache\" },\n { flag: \"--warm-cache\", description: \"Pre-cache all project commands\" },\n ];\n\n const maxFlagLen = Math.max(...globalFlags.map((f) => f.flag.length));\n\n globalFlags.forEach(({ flag, description }) => {\n const padding = \" \".repeat(maxFlagLen - flag.length + 2);\n console.log(` ${colors.yellow(flag)}${padding}${description}`);\n });\n console.log();\n\n console.log(\n ` ${colors.dim(\"Run\")} ${colors.cyan(\"warlock <command> --help\")} ${colors.dim(\"for command-specific help\")}`,\n );\n console.log();\n}\n\n/**\n * Display help for a specific command\n */\nexport function displayCommandHelp(command: {\n name: string;\n alias?: string;\n description?: string;\n options?: { name: string; text: string; description?: string; required?: boolean }[];\n}) {\n console.log();\n console.log(\n ` ${colors.bold(colors.cyan(command.name))}${command.alias ? colors.dim(` (${command.alias})`) : \"\"}`,\n );\n\n if (command.description) {\n console.log(` ${command.description}`);\n }\n console.log();\n\n if (command.options && command.options.length > 0) {\n console.log(` ${colors.bold(\"Options:\")}`);\n console.log();\n\n const maxLen = Math.max(...command.options.map((o) => o.text.length));\n\n command.options.forEach((opt) => {\n const padding = \" \".repeat(maxLen - opt.text.length + 2);\n const required = opt.required ? colors.red(\" (required)\") : \"\";\n const desc = opt.description || \"\";\n console.log(` ${colors.green(opt.text)}${padding}${desc}${required}`);\n });\n console.log();\n } else {\n console.log(` ${colors.dim(\"No options available\")}`);\n console.log();\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"cli-commands.utils.mjs","names":[],"sources":["../../../../../../../core/src/cli/cli-commands.utils.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport type { Environment } from \"../utils\";\nimport { getWarlockVersion } from \"../utils/framework-vesion\";\n\nexport function isMatchingCommandName(commandName: string, targetingCommandName: string) {\n return commandName.split(\" \")[0] === targetingCommandName;\n}\n\n/**\n * Display the Warlock.js version banner\n */\nexport async function displayWarlockVersionInTerminal() {\n const version = await getWarlockVersion();\n console.log(`⚡ ${colors.bold(\"Warlock.js\")} ${colors.greenBright(`v${version}`)}`);\n}\n\ntype StartBannerOptions = {\n environment: Environment;\n};\n\nfunction getTextColorMethod(environment: Environment) {\n switch (environment) {\n case \"development\":\n return colors.yellowBright;\n case \"production\":\n return colors.greenBright;\n case \"test\":\n return colors.blueBright;\n default:\n return colors.whiteBright;\n }\n}\n\n/**\n * Display CLI startup banner\n */\nexport async function displayStartupBanner({ environment }: StartBannerOptions) {\n const version = await getWarlockVersion();\n const textColorMethod = getTextColorMethod(environment);\n console.log(` ⚡ ${colors.bold(textColorMethod(\"Warlock.js\"))} ${colors.dim(`v${version}`)}`);\n console.log();\n}\n\ntype ProductionReadyBannerOptions = {\n bootDurationMs?: number;\n};\n\n/**\n * Announce that the production server is up — printed on **stdout**, and only\n * after the child process reported a completed boot.\n *\n * Stdout is the channel a supervisor or CI gate greps to decide the service is\n * healthy, so nothing optimistic may be written to it: this function is the\n * single place allowed to say \"started\", and it is called from exactly one\n * place, the `warlock:ready` handler in the `start` command.\n */\nexport async function displayProductionReadyBanner({\n bootDurationMs,\n}: ProductionReadyBannerOptions = {}) {\n const version = await getWarlockVersion();\n const duration = bootDurationMs ? colors.dim(` in ${bootDurationMs}ms`) : \"\";\n\n console.log(\n ` ⚡ ${colors.bold(colors.greenBright(\"Warlock.js\"))} ${colors.dim(`v${version}`)} ${colors.green(\"✔\")} production server started${duration}`,\n );\n console.log();\n}\n\n/**\n * Report that the production server died before it ever served anything.\n *\n * Written to **stderr** only. `warlock start` inherits both child streams, and\n * production launchers commonly merge them (`2>&1`), so mirroring this block to\n * stdout prints every line twice. The command's non-zero exit code is the\n * machine-readable failure signal; stdout remains reserved for the ready\n * banner.\n *\n * `causeWasCaptured` gates the \"the cause is printed above\" line. The\n * supervisor cannot always guarantee the child wrote anything before it\n * died — an import that throws before the logger configures its channels,\n * a bundle with no console channel at all — and claiming a cause is above\n * when nothing was ever captured sends the developer looking for output\n * that doesn't exist, which is worse than admitting the gap.\n */\nexport function displayProductionStartFailure(\n exitCode: number,\n causeWasCaptured: boolean,\n) {\n const causeLine = causeWasCaptured\n ? ` ${colors.dim(\"the cause is printed above, in the application's own output\")}`\n : ` ${colors.dim(\"no output was captured from the application process — its cause did not reach this terminal\")}`;\n\n const lines = [\n \"\",\n ` ${colors.red(\"✖\")} ${colors.bold(\"warlock start\")} failed — the server never finished booting`,\n ` ${colors.dim(`the application process exited with code ${exitCode}`)}`,\n causeLine,\n \"\",\n ];\n\n for (const line of lines) {\n console.error(line);\n }\n}\n\n/**\n * Note that a running child has not reported readiness yet.\n *\n * Emitted on **stderr** only. The process may be perfectly healthy — an older\n * bundle has no readiness signal at all — so saying anything on stdout would be\n * the very false-green this channel exists to prevent.\n *\n * The wording offers the likely causes rather than asserting one. It cannot\n * distinguish an old bundle from a slow boot from a boot that is about to fail,\n * and an earlier version claimed \"the bundle predates readiness reporting\" —\n * which was simply wrong when a current bundle was mid-crash, and sent the\n * reader after the wrong thing.\n */\nexport function displayMissingReadinessNotice(waitedMs: number) {\n console.error();\n console.error(\n ` ${colors.yellow(\"!\")} still running after ${Math.round(waitedMs / 1000)}s with no readiness signal`,\n );\n console.error(` ${colors.dim(\"either the boot is still in progress, or this bundle was built\")}`);\n console.error(\n ` ${colors.dim(\"before readiness reporting — re-run\")} ${colors.cyan(\"warlock build\")} ${colors.dim(\"if the banner never appears\")}`,\n );\n console.error();\n}\n\n/**\n * Display command execution header\n */\nexport function displayExecutingCommand(commandName: string) {\n console.log(` ${colors.cyan(\"›\")} Running ${colors.bold(colors.white(commandName))}...`);\n console.log();\n}\n\n/**\n * Display command not found error with optional suggestions\n */\nexport function displayCommandNotFound(commandName: string, suggestions?: string[]) {\n console.log();\n console.log(` ${colors.red(\"✖\")} Command ${colors.magenta(commandName)} not found`);\n\n if (suggestions && suggestions.length > 0) {\n console.log();\n console.log(` ${colors.yellow(\"Did you mean?\")}`);\n suggestions.forEach((suggestion) => {\n console.log(` ${colors.cyan(\"→\")} ${colors.white(suggestion)}`);\n });\n }\n\n console.log();\n console.log(\n ` ${colors.dim(\"Run\")} ${colors.cyan(\"warlock --help\")} ${colors.dim(\"to see available commands\")}`,\n );\n console.log();\n}\n\n/**\n * Display missing command error\n */\nexport function displayMissingCommand() {\n console.log();\n console.log(` ${colors.red(\"✖\")} No command specified`);\n console.log(\n ` ${colors.dim(\"Run\")} ${colors.cyan(\"warlock --help\")} ${colors.dim(\"to see available commands\")}`,\n );\n console.log();\n}\n\n/**\n * Display command success message.\n *\n * Written to STDERR, not stdout. This banner is printed around EVERY command,\n * including ones whose stdout is a machine payload a script is meant to parse —\n * `warlock routes --json` is the clearest case. On stdout it produced this,\n * which is not JSON and cannot be piped anywhere:\n *\n * ```\n * []\n *\n * ✔ routes completed successfully (696ms)\n * ```\n *\n * stdout belongs to the command's output; status chrome belongs to stderr,\n * where it stays visible to a person in a terminal and out of a pipe.\n */\nexport function displayCommandSuccess(commandName: string, durationMs?: number) {\n const duration = durationMs ? colors.dim(` (${durationMs}ms)`) : \"\";\n console.error();\n console.error(\n ` ${colors.green(\"✔\")} ${colors.bold(commandName)} completed successfully${duration}`,\n );\n console.error();\n}\n\n/**\n * Display command error message.\n *\n * On stderr for the same reason as {@link displayCommandSuccess}, and one\n * stronger: a failure interleaved into a machine payload corrupts the payload\n * AND hides the failure from anyone reading stderr for it.\n */\nexport function displayCommandError(commandName: string, error: Error) {\n console.error();\n console.error(` ${colors.red(\"✖\")} ${colors.bold(commandName)} failed`);\n console.error(` ${colors.dim(error.message)}`);\n console.error();\n}\n\n/**\n * Display a FATAL boot/preload failure and exit information.\n *\n * Preload failures — a bad import in a config file, a connector that throws\n * on startup, a missing module export — happen BEFORE the command's run loop\n * exists, so there is nothing to recover into: they are always fatal. Unlike\n * `displayCommandError`, this prints the full stack (not just `error.message`)\n * because the stack names the exact file + line of the offending import, which\n * is the single most useful clue when a config file pulls in a broken module.\n *\n * Surfacing this loudly is the difference between a clear error and a silent\n * hang: an unhandled preload rejection used to escape while the already-started\n * loader worker thread kept the process alive, leaving `warlock dev` frozen\n * just after the banner with no message at all.\n *\n * @example\n * // SyntaxError: The requested module '@warlock.js/cascade' does not\n * // provide an export named 'belongsTo'\n * // at src/app/.../permission.model.ts:2\n */\nexport function displayBootError(commandName: string, error: Error) {\n console.log();\n console.log(` ${colors.red(\"✖\")} ${colors.bold(commandName)} failed to start`);\n console.log(` ${colors.red(error.message)}`);\n if (error.stack) {\n console.log();\n console.log(colors.dim(error.stack));\n }\n console.log();\n}\n\n/**\n * Display missing required options error\n */\nexport function displayMissingOptions(options: { name: string; text: string }[]) {\n console.log();\n console.log(` ${colors.red(\"✖\")} Missing required options:`);\n options.forEach((opt) => {\n console.log(` ${colors.yellow(opt.text)} ${colors.dim(`(--${opt.name})`)}`);\n });\n console.log();\n}\n\n/**\n * Command info for help display\n */\nexport type HelpCommandInfo = {\n name: string;\n alias?: string;\n description?: string;\n source: \"framework\" | \"plugin\" | \"project\";\n};\n\n/**\n * Display global help with all commands grouped by source\n */\nexport async function displayHelp(commands: HelpCommandInfo[]) {\n const version = await getWarlockVersion();\n\n console.log();\n console.log(\n ` ⚡ ${colors.bold(colors.yellowBright(\"Warlock.js\"))} CLI ${colors.dim(`v${version}`)}`,\n );\n console.log();\n console.log(\n ` ${colors.bold(\"Usage:\")} ${colors.cyan(\"warlock\")} ${colors.dim(\"<command>\")} ${colors.dim(\"[options]\")}`,\n );\n console.log();\n\n // Group by source\n const grouped: Record<string, HelpCommandInfo[]> = {\n framework: [],\n plugin: [],\n project: [],\n };\n\n commands.forEach((cmd) => {\n grouped[cmd.source]?.push(cmd);\n });\n\n // Display each group\n const groupLabels: Record<string, string> = {\n framework: \"Framework Commands\",\n plugin: \"Plugin Commands\",\n project: \"Project Commands\",\n };\n\n for (const [source, cmds] of Object.entries(grouped)) {\n if (cmds.length === 0) continue;\n\n console.log(` ${colors.bold(colors.white(groupLabels[source]))}`);\n console.log();\n\n // Find max name length for alignment\n const maxLen = Math.max(...cmds.map((c) => c.name.length + (c.alias ? c.alias.length + 4 : 0)));\n\n cmds.forEach((cmd) => {\n const aliasStr = cmd.alias ? colors.dim(` (${cmd.alias})`) : \"\";\n const nameWithAlias = cmd.name + (cmd.alias ? ` (${cmd.alias})` : \"\");\n const padding = \" \".repeat(maxLen - nameWithAlias.length + 2);\n // const desc = cmd.description || colors.dim(\"No description\");\n const desc = cmd.description || \"\";\n console.log(` ${colors.cyan(cmd.name)}${aliasStr}${padding}${desc}`);\n });\n console.log();\n }\n\n // Display global flags\n console.log(` ${colors.bold(colors.white(\"Global Flags\"))}`);\n console.log();\n\n const globalFlags = [\n { flag: \"--help, -h\", description: \"Show help for a command\" },\n { flag: \"--version, -v\", description: \"Show Warlock version\" },\n { flag: \"--no-cache\", description: \"Force reload without cache\" },\n { flag: \"--warm-cache\", description: \"Pre-cache all project commands\" },\n ];\n\n const maxFlagLen = Math.max(...globalFlags.map((f) => f.flag.length));\n\n globalFlags.forEach(({ flag, description }) => {\n const padding = \" \".repeat(maxFlagLen - flag.length + 2);\n console.log(` ${colors.yellow(flag)}${padding}${description}`);\n });\n console.log();\n\n console.log(\n ` ${colors.dim(\"Run\")} ${colors.cyan(\"warlock <command> --help\")} ${colors.dim(\"for command-specific help\")}`,\n );\n console.log();\n}\n\n/**\n * Display help for a specific command\n */\nexport function displayCommandHelp(command: {\n name: string;\n alias?: string;\n description?: string;\n options?: { name: string; text: string; description?: string; required?: boolean }[];\n}) {\n console.log();\n console.log(\n ` ${colors.bold(colors.cyan(command.name))}${command.alias ? colors.dim(` (${command.alias})`) : \"\"}`,\n );\n\n if (command.description) {\n console.log(` ${command.description}`);\n }\n console.log();\n\n if (command.options && command.options.length > 0) {\n console.log(` ${colors.bold(\"Options:\")}`);\n console.log();\n\n const maxLen = Math.max(...command.options.map((o) => o.text.length));\n\n command.options.forEach((opt) => {\n const padding = \" \".repeat(maxLen - opt.text.length + 2);\n const required = opt.required ? colors.red(\" (required)\") : \"\";\n const desc = opt.description || \"\";\n console.log(` ${colors.green(opt.text)}${padding}${desc}${required}`);\n });\n console.log();\n } else {\n console.log(` ${colors.dim(\"No options available\")}`);\n console.log();\n }\n}\n"],"mappings":"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"}
|
|
@@ -15,19 +15,40 @@ import readline from "node:readline";
|
|
|
15
15
|
*/
|
|
16
16
|
var DevServerShortcuts = class {
|
|
17
17
|
/**
|
|
18
|
-
* @param
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
18
|
+
* @param explicitInput The stream to read keys from. Tests pass a fake TTY;
|
|
19
|
+
* production code omits it and gets `process.stdin`,
|
|
20
|
+
* resolved lazily — see {@link input}.
|
|
21
|
+
* @param onInterrupt What `Ctrl+C` does once raw mode has taken it away
|
|
22
|
+
* from the terminal driver. Defaults to re-raising
|
|
23
|
+
* `SIGINT`.
|
|
22
24
|
*/
|
|
23
|
-
constructor(
|
|
24
|
-
this.
|
|
25
|
+
constructor(explicitInput, onInterrupt = raiseInterrupt) {
|
|
26
|
+
this.explicitInput = explicitInput;
|
|
25
27
|
this.onInterrupt = onInterrupt;
|
|
26
28
|
this.shortcuts = /* @__PURE__ */ new Map();
|
|
27
29
|
this.listening = false;
|
|
28
30
|
this.busy = false;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
33
|
+
* The stream to read keys from, resolved on first USE rather than at
|
|
34
|
+
* construction.
|
|
35
|
+
*
|
|
36
|
+
* `devServerShortcuts` below is a process-wide singleton, and every CLI
|
|
37
|
+
* command loads it merely by importing `dev-server.command.ts` —
|
|
38
|
+
* `framework-cli-commands.ts` statically imports every command module up
|
|
39
|
+
* front, for `warlock add`/`migrate`/`routes`/etc. just as much as for
|
|
40
|
+
* `warlock dev`. A constructor-default of `process.stdin` used to run at
|
|
41
|
+
* THAT import, not at `register()`, so simply running any command touched
|
|
42
|
+
* `process.stdin` and made Node construct a real stdin handle nobody asked
|
|
43
|
+
* for — including one-shot commands that never call `register()` and have
|
|
44
|
+
* no terminal to manage. A getter defers the touch to the methods that
|
|
45
|
+
* actually need a stream (`isSupported`/`register`/`listen`/`release`),
|
|
46
|
+
* none of which run for a command that never offers a shortcut.
|
|
47
|
+
*/
|
|
48
|
+
get input() {
|
|
49
|
+
return this.explicitInput ?? process.stdin;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
31
52
|
* Whether the current terminal can deliver individual keypresses. False in
|
|
32
53
|
* CI, when stdin is a pipe, and in any non-interactive shell.
|
|
33
54
|
*/
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shortcuts.mjs","names":[],"sources":["../../../../../../../core/src/dev-server/shortcuts.ts"],"sourcesContent":["import readline from \"node:readline\";\n\n/**\n * A single-keypress shortcut offered by the dev server while it runs.\n */\nexport type DevServerShortcut = {\n /** The key that triggers it, e.g. `\"u\"`. Matched case-insensitively. */\n key: string;\n /** Short human label, used when we print the available shortcuts. */\n description: string;\n /** What to run when the key is pressed. Rejections are swallowed. */\n handler: () => void | Promise<void>;\n};\n\n/**\n * Single-keypress shortcuts for the dev server (`press u to update`, …).\n *\n * Reading one key at a time requires putting stdin into raw mode, which\n * means this process — not the terminal driver — becomes responsible for\n * `Ctrl+C`. The manager therefore re-raises `SIGINT` itself so the existing\n * graceful-shutdown handlers keep working exactly as before.\n *\n * Everything is opt-in and self-guarding: with no TTY (piped output, CI,\n * a supervisor) `register()` reports `false` and stdin is never touched, so\n * callers can fall back to printing a plain \"run `npx warlock update`\" hint.\n */\nexport class DevServerShortcuts {\n private readonly shortcuts = new Map<string, DevServerShortcut>();\n\n /** Whether we currently hold stdin in raw mode. */\n private listening = false;\n\n /** Set while a handler runs, so a second keypress can't re-enter it. */\n private busy = false;\n\n private keypressListener?: (character: string, key: KeypressEvent) => void;\n\n /**\n * @param
|
|
1
|
+
{"version":3,"file":"shortcuts.mjs","names":[],"sources":["../../../../../../../core/src/dev-server/shortcuts.ts"],"sourcesContent":["import readline from \"node:readline\";\n\n/**\n * A single-keypress shortcut offered by the dev server while it runs.\n */\nexport type DevServerShortcut = {\n /** The key that triggers it, e.g. `\"u\"`. Matched case-insensitively. */\n key: string;\n /** Short human label, used when we print the available shortcuts. */\n description: string;\n /** What to run when the key is pressed. Rejections are swallowed. */\n handler: () => void | Promise<void>;\n};\n\n/**\n * Single-keypress shortcuts for the dev server (`press u to update`, …).\n *\n * Reading one key at a time requires putting stdin into raw mode, which\n * means this process — not the terminal driver — becomes responsible for\n * `Ctrl+C`. The manager therefore re-raises `SIGINT` itself so the existing\n * graceful-shutdown handlers keep working exactly as before.\n *\n * Everything is opt-in and self-guarding: with no TTY (piped output, CI,\n * a supervisor) `register()` reports `false` and stdin is never touched, so\n * callers can fall back to printing a plain \"run `npx warlock update`\" hint.\n */\nexport class DevServerShortcuts {\n private readonly shortcuts = new Map<string, DevServerShortcut>();\n\n /** Whether we currently hold stdin in raw mode. */\n private listening = false;\n\n /** Set while a handler runs, so a second keypress can't re-enter it. */\n private busy = false;\n\n private keypressListener?: (character: string, key: KeypressEvent) => void;\n\n /**\n * @param explicitInput The stream to read keys from. Tests pass a fake TTY;\n * production code omits it and gets `process.stdin`,\n * resolved lazily — see {@link input}.\n * @param onInterrupt What `Ctrl+C` does once raw mode has taken it away\n * from the terminal driver. Defaults to re-raising\n * `SIGINT`.\n */\n public constructor(\n private readonly explicitInput?: NodeJS.ReadStream,\n private readonly onInterrupt: () => void = raiseInterrupt,\n ) {}\n\n /**\n * The stream to read keys from, resolved on first USE rather than at\n * construction.\n *\n * `devServerShortcuts` below is a process-wide singleton, and every CLI\n * command loads it merely by importing `dev-server.command.ts` —\n * `framework-cli-commands.ts` statically imports every command module up\n * front, for `warlock add`/`migrate`/`routes`/etc. just as much as for\n * `warlock dev`. A constructor-default of `process.stdin` used to run at\n * THAT import, not at `register()`, so simply running any command touched\n * `process.stdin` and made Node construct a real stdin handle nobody asked\n * for — including one-shot commands that never call `register()` and have\n * no terminal to manage. A getter defers the touch to the methods that\n * actually need a stream (`isSupported`/`register`/`listen`/`release`),\n * none of which run for a command that never offers a shortcut.\n */\n private get input(): NodeJS.ReadStream {\n return this.explicitInput ?? process.stdin;\n }\n\n /**\n * Whether the current terminal can deliver individual keypresses. False in\n * CI, when stdin is a pipe, and in any non-interactive shell.\n */\n public isSupported(): boolean {\n return Boolean(this.input.isTTY && typeof this.input.setRawMode === \"function\");\n }\n\n /**\n * Offer a shortcut. Returns whether it was actually registered — `false`\n * means the terminal can't support keypresses and the caller should print\n * a copy-and-paste command instead.\n */\n public register(shortcut: DevServerShortcut): boolean {\n if (!this.isSupported()) {\n return false;\n }\n\n this.shortcuts.set(shortcut.key.toLowerCase(), shortcut);\n this.listen();\n\n return true;\n }\n\n /** Every armed shortcut, in the order it was registered. */\n public list(): DevServerShortcut[] {\n return [...this.shortcuts.values()];\n }\n\n /** Drop a shortcut, releasing the terminal once none are left. */\n public unregister(key: string): void {\n this.shortcuts.delete(key.toLowerCase());\n\n if (this.shortcuts.size === 0) {\n this.release();\n }\n }\n\n /**\n * Hand the terminal back: leave raw mode and stop listening. Call this\n * before spawning a child process that needs stdin (the package manager\n * install), and on shutdown. Registered shortcuts are kept, so `register()`\n * (or `resume()`) can take the terminal again afterwards.\n */\n public release(): void {\n if (!this.listening) {\n return;\n }\n\n this.listening = false;\n\n if (this.keypressListener) {\n this.input.off(\"keypress\", this.keypressListener);\n this.keypressListener = undefined;\n }\n\n if (this.input.isTTY) {\n this.input.setRawMode(false);\n }\n\n this.input.pause();\n }\n\n /** Re-take the terminal after a {@link release}, if shortcuts remain. */\n public resume(): void {\n if (this.shortcuts.size > 0) {\n this.listen();\n }\n }\n\n /** Start (or keep) listening for keypresses. */\n private listen(): void {\n if (this.listening || !this.isSupported()) {\n return;\n }\n\n this.listening = true;\n\n readline.emitKeypressEvents(this.input);\n this.input.setRawMode(true);\n this.input.resume();\n\n this.keypressListener = (_character, key) => {\n void this.handleKeypress(key);\n };\n\n this.input.on(\"keypress\", this.keypressListener);\n }\n\n /**\n * Route a keypress to its shortcut. In raw mode the terminal no longer\n * turns `Ctrl+C` into a signal for us, so we translate it back into a\n * `SIGINT` and let the dev server's shutdown handlers take it from there.\n */\n private async handleKeypress(key: KeypressEvent | undefined): Promise<void> {\n if (!key) {\n return;\n }\n\n if (key.ctrl && (key.name === \"c\" || key.name === \"d\")) {\n this.release();\n this.onInterrupt();\n return;\n }\n\n if (this.busy) {\n return;\n }\n\n const shortcut = key.name ? this.shortcuts.get(key.name.toLowerCase()) : undefined;\n\n if (!shortcut) {\n return;\n }\n\n this.busy = true;\n\n try {\n await shortcut.handler();\n } catch {\n // A shortcut is a convenience — never let it take the dev server down.\n } finally {\n this.busy = false;\n }\n }\n}\n\n/** Re-raise the interrupt the terminal would have sent outside raw mode. */\nfunction raiseInterrupt(): void {\n process.kill(process.pid, \"SIGINT\");\n}\n\n/** The keypress shape emitted by `readline.emitKeypressEvents`. */\ntype KeypressEvent = {\n name?: string;\n ctrl?: boolean;\n meta?: boolean;\n shift?: boolean;\n};\n\n/** Process-wide shortcuts for the running dev server. */\nexport const devServerShortcuts = new DevServerShortcuts();\n"],"mappings":";;;;;;;;;;;;;;;AA0BA,IAAa,qBAAb,MAAgC;;;;;;;;;CAmB9B,AAAO,YACL,AAAiB,eACjB,AAAiB,cAA0B,gBAC3C;EAFiB;EACA;mCApBU,IAAI,IAA+B;mBAG5C;cAGL;CAeZ;;;;;;;;;;;;;;;;;CAkBH,IAAY,QAA2B;EACrC,OAAO,KAAK,iBAAiB,QAAQ;CACvC;;;;;CAMA,AAAO,cAAuB;EAC5B,OAAO,QAAQ,KAAK,MAAM,SAAS,OAAO,KAAK,MAAM,eAAe,UAAU;CAChF;;;;;;CAOA,AAAO,SAAS,UAAsC;EACpD,IAAI,CAAC,KAAK,YAAY,GACpB,OAAO;EAGT,KAAK,UAAU,IAAI,SAAS,IAAI,YAAY,GAAG,QAAQ;EACvD,KAAK,OAAO;EAEZ,OAAO;CACT;;CAGA,AAAO,OAA4B;EACjC,OAAO,CAAC,GAAG,KAAK,UAAU,OAAO,CAAC;CACpC;;CAGA,AAAO,WAAW,KAAmB;EACnC,KAAK,UAAU,OAAO,IAAI,YAAY,CAAC;EAEvC,IAAI,KAAK,UAAU,SAAS,GAC1B,KAAK,QAAQ;CAEjB;;;;;;;CAQA,AAAO,UAAgB;EACrB,IAAI,CAAC,KAAK,WACR;EAGF,KAAK,YAAY;EAEjB,IAAI,KAAK,kBAAkB;GACzB,KAAK,MAAM,IAAI,YAAY,KAAK,gBAAgB;GAChD,KAAK,mBAAmB;EAC1B;EAEA,IAAI,KAAK,MAAM,OACb,KAAK,MAAM,WAAW,KAAK;EAG7B,KAAK,MAAM,MAAM;CACnB;;CAGA,AAAO,SAAe;EACpB,IAAI,KAAK,UAAU,OAAO,GACxB,KAAK,OAAO;CAEhB;;CAGA,AAAQ,SAAe;EACrB,IAAI,KAAK,aAAa,CAAC,KAAK,YAAY,GACtC;EAGF,KAAK,YAAY;EAEjB,SAAS,mBAAmB,KAAK,KAAK;EACtC,KAAK,MAAM,WAAW,IAAI;EAC1B,KAAK,MAAM,OAAO;EAElB,KAAK,oBAAoB,YAAY,QAAQ;GAC3C,AAAK,KAAK,eAAe,GAAG;EAC9B;EAEA,KAAK,MAAM,GAAG,YAAY,KAAK,gBAAgB;CACjD;;;;;;CAOA,MAAc,eAAe,KAA+C;EAC1E,IAAI,CAAC,KACH;EAGF,IAAI,IAAI,SAAS,IAAI,SAAS,OAAO,IAAI,SAAS,MAAM;GACtD,KAAK,QAAQ;GACb,KAAK,YAAY;GACjB;EACF;EAEA,IAAI,KAAK,MACP;EAGF,MAAM,WAAW,IAAI,OAAO,KAAK,UAAU,IAAI,IAAI,KAAK,YAAY,CAAC,IAAI;EAEzE,IAAI,CAAC,UACH;EAGF,KAAK,OAAO;EAEZ,IAAI;GACF,MAAM,SAAS,QAAQ;EACzB,QAAQ,CAER,UAAU;GACR,KAAK,OAAO;EACd;CACF;AACF;;AAGA,SAAS,iBAAuB;CAC9B,QAAQ,KAAK,QAAQ,KAAK,QAAQ;AACpC;;AAWA,MAAa,qBAAqB,IAAI,mBAAmB"}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { rootPath } from "../../utils/paths.mjs";
|
|
2
2
|
import "../../utils/index.mjs";
|
|
3
|
+
import { patchTsconfigInclude } from "./shared/patch-tsconfig-include.mjs";
|
|
3
4
|
import { colors } from "@mongez/copper";
|
|
4
|
-
import { ensureDirectoryAsync, fileExistsAsync,
|
|
5
|
+
import { ensureDirectoryAsync, fileExistsAsync, putFileAsync } from "@warlock.js/fs";
|
|
5
6
|
|
|
6
7
|
//#region ../core/src/generations/features/react-email.feature.ts
|
|
7
8
|
async function completeReactEmailInstallation(_options) {
|
|
@@ -42,14 +43,7 @@ export default function WelcomeEmail({ name }: WelcomeEmailProps) {
|
|
|
42
43
|
`);
|
|
43
44
|
console.log(`${colors.green("✓")} Created emails/welcome-email.tsx`);
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
-
const tsconfig = await getJsonFileAsync(tsconfigPath);
|
|
47
|
-
if (!tsconfig.include) tsconfig.include = [];
|
|
48
|
-
if (!tsconfig.include.includes("emails")) {
|
|
49
|
-
tsconfig.include.push("emails");
|
|
50
|
-
await putJsonFileAsync(tsconfigPath, tsconfig);
|
|
51
|
-
console.log(`${colors.green("✓")} Added "emails" to tsconfig.json include`);
|
|
52
|
-
}
|
|
46
|
+
await patchTsconfigInclude("emails", "Without it, the email components under emails/ are outside the project and do not typecheck.");
|
|
53
47
|
}
|
|
54
48
|
const reactEmailFeature = {
|
|
55
49
|
description: "Installs react-email for building email templates with React and Tailwind",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"react-email.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/react-email.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport {
|
|
1
|
+
{"version":3,"file":"react-email.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/react-email.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { ensureDirectoryAsync, fileExistsAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath } from \"../../utils\";\r\nimport { patchTsconfigInclude } from \"./shared/patch-tsconfig-include\";\r\nimport type { FeatureDefinition } from \"./types\";\r\n\r\nasync function completeReactEmailInstallation(_options: CommandActionData) {\r\n // 1. Create emails/ folder with a sample component\r\n const emailsFolderPath = rootPath(\"emails\");\r\n const sampleEmailPath = rootPath(\"emails/welcome-email.tsx\");\r\n\r\n if (!(await fileExistsAsync(sampleEmailPath))) {\r\n await ensureDirectoryAsync(emailsFolderPath);\r\n await putFileAsync(\r\n sampleEmailPath,\r\n `import { Body, Container, Head, Html, Text } from \"@react-email/components\";\r\nimport { Tailwind } from \"@react-email/tailwind\";\r\n\r\ninterface WelcomeEmailProps {\r\n name: string;\r\n}\r\n\r\n/**\r\n * Sample welcome email component.\r\n * Preview with: yarn email:preview\r\n */\r\nexport default function WelcomeEmail({ name }: WelcomeEmailProps) {\r\n return (\r\n <Html>\r\n <Head />\r\n <Tailwind>\r\n <Body className=\"bg-gray-100 font-sans\">\r\n <Container className=\"mx-auto max-w-xl py-8 px-4\">\r\n <Text className=\"text-2xl font-bold text-gray-900\">\r\n Welcome, {name}!\r\n </Text>\r\n <Text className=\"text-gray-600 mt-2\">\r\n You're all set. We're glad to have you on board.\r\n </Text>\r\n </Container>\r\n </Body>\r\n </Tailwind>\r\n </Html>\r\n );\r\n}\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created emails/welcome-email.tsx`);\r\n }\r\n\r\n // 2. Patch tsconfig.json — add \"emails\" to include if missing.\r\n await patchTsconfigInclude(\r\n \"emails\",\r\n \"Without it, the email components under emails/ are outside the project and do not typecheck.\",\r\n );\r\n}\r\n\r\nexport const reactEmailFeature: FeatureDefinition = {\r\n description: \"Installs react-email for building email templates with React and Tailwind\",\r\n requires: [\"mail\", \"react\"],\r\n dependencies: {\r\n \"react-email\": \"^5.2.10\",\r\n \"@react-email/components\": \"^1.0.11\",\r\n \"@react-email/render\": \"^2.0.5\",\r\n \"@react-email/tailwind\": \"^2.0.7\",\r\n },\r\n devDependencies: {\r\n \"@react-email/preview-server\": \"5.2.10\",\r\n },\r\n script: {\r\n \"email:preview\": \"npx react-email dev\",\r\n },\r\n onExecuting: completeReactEmailInstallation,\r\n};\r\n"],"mappings":";;;;;;;AAOA,eAAe,+BAA+B,UAA6B;CAEzE,MAAM,mBAAmB,SAAS,QAAQ;CAC1C,MAAM,kBAAkB,SAAS,0BAA0B;CAE3D,IAAI,CAAE,MAAM,gBAAgB,eAAe,GAAI;EAC7C,MAAM,qBAAqB,gBAAgB;EAC3C,MAAM,aACJ,iBACA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA+BF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,kCAAkC;CACrE;CAGA,MAAM,qBACJ,UACA,8FACF;AACF;AAEA,MAAa,oBAAuC;CAClD,aAAa;CACb,UAAU,CAAC,QAAQ,OAAO;CAC1B,cAAc;EACZ,eAAe;EACf,2BAA2B;EAC3B,uBAAuB;EACvB,yBAAyB;CAC3B;CACA,iBAAiB,EACf,+BAA+B,SACjC;CACA,QAAQ,EACN,iBAAiB,sBACnB;CACA,aAAa;AACf"}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { rootPath } from "../../../utils/paths.mjs";
|
|
2
|
+
import "../../../utils/index.mjs";
|
|
3
|
+
import { colors } from "@mongez/copper";
|
|
4
|
+
import { fileExistsAsync, getFileAsync, putFileAsync } from "@warlock.js/fs";
|
|
5
|
+
|
|
6
|
+
//#region ../core/src/generations/features/shared/patch-tsconfig-include.ts
|
|
7
|
+
/**
|
|
8
|
+
* Insert one entry into the `include` array of a tsconfig's SOURCE TEXT.
|
|
9
|
+
*
|
|
10
|
+
* **String surgery, never parse-and-write.** The project template's
|
|
11
|
+
* `tsconfig.json` carries `//` comments — it is JSONC, which TypeScript accepts
|
|
12
|
+
* and `JSON.parse` rejects. A parse-first patch therefore throws on exactly the
|
|
13
|
+
* projects a feature is generated into: `warlock add react-email` failed on
|
|
14
|
+
* every freshly scaffolded app with
|
|
15
|
+
*
|
|
16
|
+
* ```
|
|
17
|
+
* ✖ add <features...> failed
|
|
18
|
+
* Expected double-quoted property name in JSON at position 454 (line 17 column 5)
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* where line 17 is the comment explaining the project's `moduleResolution`. And
|
|
22
|
+
* even where a parse succeeded, writing the object back would silently delete
|
|
23
|
+
* every comment in the file — comments that are load-bearing documentation
|
|
24
|
+
* there.
|
|
25
|
+
*
|
|
26
|
+
* `shadcn.feature.ts`'s `addWebPathAlias` already reached this conclusion for
|
|
27
|
+
* `compilerOptions.paths`; this is the same rule for `include`, kept in one
|
|
28
|
+
* place so the next feature that needs it cannot get it wrong again.
|
|
29
|
+
*
|
|
30
|
+
* @param source The tsconfig file's current text.
|
|
31
|
+
* @param entry The include entry to add, e.g. `"emails"`.
|
|
32
|
+
* @returns What happened, and the new text when there is any.
|
|
33
|
+
*/
|
|
34
|
+
function insertIncludeEntry(source, entry) {
|
|
35
|
+
if (new RegExp(`["']${escapeForRegExp(entry)}["']`).test(source)) return { status: "already-present" };
|
|
36
|
+
const includeArray = /"include"\s*:\s*\[/.exec(source);
|
|
37
|
+
if (!includeArray) return { status: "unrecognised" };
|
|
38
|
+
const insertAt = includeArray.index + includeArray[0].length;
|
|
39
|
+
return {
|
|
40
|
+
status: "added",
|
|
41
|
+
next: `${source.slice(0, insertAt)}${JSON.stringify(entry)}, ${source.slice(insertAt)}`
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Add one entry to the project's `tsconfig.json` `include` array.
|
|
46
|
+
*
|
|
47
|
+
* Never throws. A tsconfig this cannot patch produces a printed instruction the
|
|
48
|
+
* developer can follow by hand — a feature must not abort a whole `add` because
|
|
49
|
+
* one optional convenience could not be applied.
|
|
50
|
+
*
|
|
51
|
+
* @param entry The include entry to add, e.g. `"emails"`.
|
|
52
|
+
* @param reason One line explaining what breaks without it, printed when the
|
|
53
|
+
* patch has to be handed back to the developer.
|
|
54
|
+
*/
|
|
55
|
+
async function patchTsconfigInclude(entry, reason) {
|
|
56
|
+
const tsconfigPath = rootPath("tsconfig.json");
|
|
57
|
+
const printManualInstruction = (problem) => {
|
|
58
|
+
console.log(`${colors.yellowBright("!")} ${colors.yellowBright("tsconfig.json")} ${problem} — add ${JSON.stringify(entry)} to its \`include\` array yourself.\n ${reason}`);
|
|
59
|
+
};
|
|
60
|
+
if (!await fileExistsAsync(tsconfigPath)) {
|
|
61
|
+
printManualInstruction("not found");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const insertion = insertIncludeEntry(await getFileAsync(tsconfigPath), entry);
|
|
65
|
+
if (insertion.status === "already-present") return;
|
|
66
|
+
if (insertion.status === "unrecognised") {
|
|
67
|
+
printManualInstruction("has no recognisable `include` array");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
await putFileAsync(tsconfigPath, insertion.next);
|
|
71
|
+
console.log(`${colors.green("✓")} Added ${JSON.stringify(entry)} to tsconfig.json include`);
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Escape a literal string for embedding in a regular expression.
|
|
75
|
+
*
|
|
76
|
+
* @param value The literal to escape.
|
|
77
|
+
* @returns The escaped literal.
|
|
78
|
+
*/
|
|
79
|
+
function escapeForRegExp(value) {
|
|
80
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
//#endregion
|
|
84
|
+
export { patchTsconfigInclude };
|
|
85
|
+
//# sourceMappingURL=patch-tsconfig-include.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"patch-tsconfig-include.mjs","names":[],"sources":["../../../../../../../../../core/src/generations/features/shared/patch-tsconfig-include.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport { fileExistsAsync, getFileAsync, putFileAsync } from \"@warlock.js/fs\";\nimport { rootPath } from \"../../../utils\";\n\n/** What {@link insertIncludeEntry} did, or could not do, to the source it was given. */\nexport type IncludeInsertion =\n | { status: \"added\"; next: string }\n | { status: \"already-present\" }\n | { status: \"unrecognised\" };\n\n/**\n * Insert one entry into the `include` array of a tsconfig's SOURCE TEXT.\n *\n * **String surgery, never parse-and-write.** The project template's\n * `tsconfig.json` carries `//` comments — it is JSONC, which TypeScript accepts\n * and `JSON.parse` rejects. A parse-first patch therefore throws on exactly the\n * projects a feature is generated into: `warlock add react-email` failed on\n * every freshly scaffolded app with\n *\n * ```\n * ✖ add <features...> failed\n * Expected double-quoted property name in JSON at position 454 (line 17 column 5)\n * ```\n *\n * where line 17 is the comment explaining the project's `moduleResolution`. And\n * even where a parse succeeded, writing the object back would silently delete\n * every comment in the file — comments that are load-bearing documentation\n * there.\n *\n * `shadcn.feature.ts`'s `addWebPathAlias` already reached this conclusion for\n * `compilerOptions.paths`; this is the same rule for `include`, kept in one\n * place so the next feature that needs it cannot get it wrong again.\n *\n * @param source The tsconfig file's current text.\n * @param entry The include entry to add, e.g. `\"emails\"`.\n * @returns What happened, and the new text when there is any.\n */\nexport function insertIncludeEntry(source: string, entry: string): IncludeInsertion {\n // Matches the entry whichever quote style the file uses, so a re-run against a\n // hand-edited tsconfig does not stack a second copy.\n if (new RegExp(`[\"']${escapeForRegExp(entry)}[\"']`).test(source)) {\n return { status: \"already-present\" };\n }\n\n const includeArray = /\"include\"\\s*:\\s*\\[/.exec(source);\n\n if (!includeArray) {\n return { status: \"unrecognised\" };\n }\n\n const insertAt = includeArray.index + includeArray[0].length;\n\n return {\n status: \"added\",\n next: `${source.slice(0, insertAt)}${JSON.stringify(entry)}, ${source.slice(insertAt)}`,\n };\n}\n\n/**\n * Add one entry to the project's `tsconfig.json` `include` array.\n *\n * Never throws. A tsconfig this cannot patch produces a printed instruction the\n * developer can follow by hand — a feature must not abort a whole `add` because\n * one optional convenience could not be applied.\n *\n * @param entry The include entry to add, e.g. `\"emails\"`.\n * @param reason One line explaining what breaks without it, printed when the\n * patch has to be handed back to the developer.\n */\nexport async function patchTsconfigInclude(entry: string, reason: string): Promise<void> {\n const tsconfigPath = rootPath(\"tsconfig.json\");\n\n const printManualInstruction = (problem: string) => {\n console.log(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(\"tsconfig.json\")} ${problem} — ` +\n `add ${JSON.stringify(entry)} to its \\`include\\` array yourself.\\n ${reason}`,\n );\n };\n\n if (!(await fileExistsAsync(tsconfigPath))) {\n printManualInstruction(\"not found\");\n\n return;\n }\n\n const insertion = insertIncludeEntry(await getFileAsync(tsconfigPath), entry);\n\n if (insertion.status === \"already-present\") {\n return;\n }\n\n if (insertion.status === \"unrecognised\") {\n printManualInstruction(\"has no recognisable `include` array\");\n\n return;\n }\n\n await putFileAsync(tsconfigPath, insertion.next);\n console.log(`${colors.green(\"✓\")} Added ${JSON.stringify(entry)} to tsconfig.json include`);\n}\n\n/**\n * Escape a literal string for embedding in a regular expression.\n *\n * @param value The literal to escape.\n * @returns The escaped literal.\n */\nfunction escapeForRegExp(value: string): string {\n return value.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqCA,SAAgB,mBAAmB,QAAgB,OAAiC;CAGlF,IAAI,IAAI,OAAO,OAAO,gBAAgB,KAAK,EAAE,KAAK,CAAC,CAAC,KAAK,MAAM,GAC7D,OAAO,EAAE,QAAQ,kBAAkB;CAGrC,MAAM,eAAe,qBAAqB,KAAK,MAAM;CAErD,IAAI,CAAC,cACH,OAAO,EAAE,QAAQ,eAAe;CAGlC,MAAM,WAAW,aAAa,QAAQ,aAAa,EAAE,CAAC;CAEtD,OAAO;EACL,QAAQ;EACR,MAAM,GAAG,OAAO,MAAM,GAAG,QAAQ,IAAI,KAAK,UAAU,KAAK,EAAE,IAAI,OAAO,MAAM,QAAQ;CACtF;AACF;;;;;;;;;;;;AAaA,eAAsB,qBAAqB,OAAe,QAA+B;CACvF,MAAM,eAAe,SAAS,eAAe;CAE7C,MAAM,0BAA0B,YAAoB;EAClD,QAAQ,IACN,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,eAAe,EAAE,GAAG,QAAQ,SACtE,KAAK,UAAU,KAAK,EAAE,yCAAyC,QAC1E;CACF;CAEA,IAAI,CAAE,MAAM,gBAAgB,YAAY,GAAI;EAC1C,uBAAuB,WAAW;EAElC;CACF;CAEA,MAAM,YAAY,mBAAmB,MAAM,aAAa,YAAY,GAAG,KAAK;CAE5E,IAAI,UAAU,WAAW,mBACvB;CAGF,IAAI,UAAU,WAAW,gBAAgB;EACvC,uBAAuB,qCAAqC;EAE5D;CACF;CAEA,MAAM,aAAa,cAAc,UAAU,IAAI;CAC/C,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,SAAS,KAAK,UAAU,KAAK,EAAE,0BAA0B;AAC5F;;;;;;;AAQA,SAAS,gBAAgB,OAAuB;CAC9C,OAAO,MAAM,QAAQ,uBAAuB,MAAM;AACpD"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { srcPath } from "../../../utils/paths.mjs";
|
|
2
|
+
import "../../../utils/index.mjs";
|
|
3
|
+
import { getFileAsync, putFileAsync } from "@warlock.js/fs";
|
|
4
|
+
import glob from "fast-glob";
|
|
5
|
+
|
|
6
|
+
//#region ../core/src/generations/features/shared/relocate-conflicting-home-route.ts
|
|
7
|
+
/**
|
|
8
|
+
* A TOP-LEVEL `router.get("/", ...)` — anchored at column 0 on purpose.
|
|
9
|
+
*
|
|
10
|
+
* Routes nested in a `router.group({ prefix: "/x" }, ...)` are indented by every
|
|
11
|
+
* formatter this codebase runs, and their real path is `/x`, not `/`. Anchoring
|
|
12
|
+
* is what keeps the notifications feature's own `router.get("/", ...)` (inside
|
|
13
|
+
* the `/notifications` group) from reading as a homepage collision.
|
|
14
|
+
*
|
|
15
|
+
* Only the path literal is captured. The handler — a bare identifier in the
|
|
16
|
+
* template, but possibly an inline arrow spanning lines — is never matched, so
|
|
17
|
+
* the rewrite below cannot damage it.
|
|
18
|
+
*/
|
|
19
|
+
const TOP_LEVEL_ROOT_GET = /^router\s*\.\s*get\(\s*(["'`])\/\1/gm;
|
|
20
|
+
/**
|
|
21
|
+
* Whether `/welcome` is already spoken for, so relocating onto it would trade
|
|
22
|
+
* one duplicate-route 500 for another.
|
|
23
|
+
*/
|
|
24
|
+
const TOP_LEVEL_WELCOME_GET = /^router\s*\.\s*get\(\s*(["'`])\/welcome\1/m;
|
|
25
|
+
/**
|
|
26
|
+
* Decide what to do about `src/app/**\/routes.{ts,tsx}` claiming `/`, and
|
|
27
|
+
* produce the rewritten source when exactly one file does.
|
|
28
|
+
*
|
|
29
|
+
* Pure by design: no filesystem access here, so this is the part a test can
|
|
30
|
+
* exercise directly without touching disk. {@link relocateConflictingHomeRoute}
|
|
31
|
+
* is the thin I/O wrapper — it reads the files, calls this, and writes back
|
|
32
|
+
* whatever this returns in `rewrites`.
|
|
33
|
+
*
|
|
34
|
+
* @param files Every `routes.{ts,tsx}` file under `src/app`, with its current text.
|
|
35
|
+
* @returns The collision outcome, and the (possibly empty) list of files to write back.
|
|
36
|
+
*/
|
|
37
|
+
function resolveHomeRouteCollision(files) {
|
|
38
|
+
const claimants = files.map((file) => ({
|
|
39
|
+
file,
|
|
40
|
+
matches: file.source.match(TOP_LEVEL_ROOT_GET) ?? []
|
|
41
|
+
})).filter(({ matches }) => matches.length > 0);
|
|
42
|
+
if (claimants.length === 0) return {
|
|
43
|
+
collision: { outcome: "absent" },
|
|
44
|
+
rewrites: []
|
|
45
|
+
};
|
|
46
|
+
if (claimants.length > 1) return {
|
|
47
|
+
collision: {
|
|
48
|
+
outcome: "conflict",
|
|
49
|
+
reason: `multiple files declare a top-level GET "/" (${claimants.map(({ file }) => file.relativePath).join(", ")})`
|
|
50
|
+
},
|
|
51
|
+
rewrites: []
|
|
52
|
+
};
|
|
53
|
+
const [{ file, matches }] = claimants;
|
|
54
|
+
if (matches.length > 1) return {
|
|
55
|
+
collision: {
|
|
56
|
+
outcome: "conflict",
|
|
57
|
+
reason: `${file.relativePath} declares ${matches.length} top-level GET "/" routes`
|
|
58
|
+
},
|
|
59
|
+
rewrites: []
|
|
60
|
+
};
|
|
61
|
+
if (TOP_LEVEL_WELCOME_GET.test(file.source)) return {
|
|
62
|
+
collision: {
|
|
63
|
+
outcome: "conflict",
|
|
64
|
+
reason: `${file.relativePath} already declares GET "/welcome", so the usual relocation target is taken`
|
|
65
|
+
},
|
|
66
|
+
rewrites: []
|
|
67
|
+
};
|
|
68
|
+
const next = file.source.replace(TOP_LEVEL_ROOT_GET, (match, quote) => match.replace(`${quote}/${quote}`, `${quote}/welcome${quote}`));
|
|
69
|
+
if (next === file.source) return {
|
|
70
|
+
collision: {
|
|
71
|
+
outcome: "conflict",
|
|
72
|
+
reason: `${file.relativePath}'s GET "/" route could not be rewritten`
|
|
73
|
+
},
|
|
74
|
+
rewrites: []
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
collision: {
|
|
78
|
+
outcome: "relocated",
|
|
79
|
+
relativePath: file.relativePath
|
|
80
|
+
},
|
|
81
|
+
rewrites: [{
|
|
82
|
+
relativePath: file.relativePath,
|
|
83
|
+
source: next
|
|
84
|
+
}]
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Make room for a page that declares `route.path = "/"`.
|
|
89
|
+
*
|
|
90
|
+
* The project template registers `router.get("/", homePageController)` and the
|
|
91
|
+
* page stub declares `route.path = "/"`. Fastify rejects the second registration
|
|
92
|
+
* (`Method 'GET' already declared for route '/'`) and the homepage 500s at
|
|
93
|
+
* request time — so `warlock add web` cannot just write the page and hope.
|
|
94
|
+
*
|
|
95
|
+
* This used to check exactly one hardcoded path, `src/app/shared/routes.ts`.
|
|
96
|
+
* That guard never fired on a real scaffold: the template registers the home
|
|
97
|
+
* route at `src/app/home/routes.ts`, `fileExistsAsync` on the wrong path always
|
|
98
|
+
* failed, and every call silently returned `absent` — the collision-avoidance
|
|
99
|
+
* logic below existed and was correct, but was pointed at a file that does not
|
|
100
|
+
* exist, so it never ran. It now scans every `src/app/**\/routes.{ts,tsx}`
|
|
101
|
+
* instead of trusting one filename, so renaming the template's routes file
|
|
102
|
+
* again cannot reintroduce the same silent no-op.
|
|
103
|
+
*
|
|
104
|
+
* Of the three ways out, this RELOCATES the JSON route to `/welcome` rather than
|
|
105
|
+
* deleting it or refusing to scaffold:
|
|
106
|
+
*
|
|
107
|
+
* - Deleting the controller is what the scaffolder's own `react` feature does,
|
|
108
|
+
* but it may do that: it owns the file it is deleting, seconds after writing
|
|
109
|
+
* it. `warlock add web` runs against a project a human has been living in, and
|
|
110
|
+
* silently unlinking their code is not a thing an `add` command gets to do.
|
|
111
|
+
* - Writing the page anyway and printing a warning ships a project whose
|
|
112
|
+
* homepage 500s. A warning above a broken app is still a broken app.
|
|
113
|
+
* - Relocating keeps BOTH surfaces working: the React homepage takes `/`, the
|
|
114
|
+
* JSON welcome answers at `/welcome`, and no line of user code disappears.
|
|
115
|
+
*
|
|
116
|
+
* Only the exact top-level shape is rewritten, and only the path literal inside
|
|
117
|
+
* it. Anything else that claims `/` is reported and left completely alone — we
|
|
118
|
+
* do not guess at code we cannot recognise.
|
|
119
|
+
*/
|
|
120
|
+
async function relocateConflictingHomeRoute() {
|
|
121
|
+
const relativePaths = await glob("**/routes.{ts,tsx}", {
|
|
122
|
+
cwd: srcPath("app"),
|
|
123
|
+
absolute: false
|
|
124
|
+
});
|
|
125
|
+
if (relativePaths.length === 0) return { outcome: "absent" };
|
|
126
|
+
let files;
|
|
127
|
+
try {
|
|
128
|
+
files = await Promise.all(relativePaths.map(async (relativePath) => ({
|
|
129
|
+
relativePath: `app/${relativePath}`,
|
|
130
|
+
source: await getFileAsync(srcPath("app", relativePath))
|
|
131
|
+
})));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
return {
|
|
134
|
+
outcome: "failed",
|
|
135
|
+
reason: `could not be read (${error.message})`
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const { collision, rewrites } = resolveHomeRouteCollision(files);
|
|
139
|
+
if (rewrites.length === 0) return collision;
|
|
140
|
+
try {
|
|
141
|
+
await Promise.all(rewrites.map((file) => putFileAsync(srcPath(file.relativePath), file.source)));
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return {
|
|
144
|
+
outcome: "failed",
|
|
145
|
+
reason: `could not be written (${error.message})`
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
return collision;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
//#endregion
|
|
152
|
+
export { relocateConflictingHomeRoute };
|
|
153
|
+
//# sourceMappingURL=relocate-conflicting-home-route.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"relocate-conflicting-home-route.mjs","names":[],"sources":["../../../../../../../../../core/src/generations/features/shared/relocate-conflicting-home-route.ts"],"sourcesContent":["import { getFileAsync, putFileAsync } from \"@warlock.js/fs\";\nimport glob from \"fast-glob\";\nimport { srcPath } from \"../../../utils\";\n\n/**\n * A TOP-LEVEL `router.get(\"/\", ...)` — anchored at column 0 on purpose.\n *\n * Routes nested in a `router.group({ prefix: \"/x\" }, ...)` are indented by every\n * formatter this codebase runs, and their real path is `/x`, not `/`. Anchoring\n * is what keeps the notifications feature's own `router.get(\"/\", ...)` (inside\n * the `/notifications` group) from reading as a homepage collision.\n *\n * Only the path literal is captured. The handler — a bare identifier in the\n * template, but possibly an inline arrow spanning lines — is never matched, so\n * the rewrite below cannot damage it.\n */\nconst TOP_LEVEL_ROOT_GET = /^router\\s*\\.\\s*get\\(\\s*([\"'`])\\/\\1/gm;\n\n/**\n * Whether `/welcome` is already spoken for, so relocating onto it would trade\n * one duplicate-route 500 for another.\n */\nconst TOP_LEVEL_WELCOME_GET = /^router\\s*\\.\\s*get\\(\\s*([\"'`])\\/welcome\\1/m;\n\nexport type HomeRouteCollision =\n /** Nothing under `src/app` claims a top-level `/` — write the page as normal. */\n | { outcome: \"absent\" }\n /** The one file that claimed `/` was moved to `/welcome`; the page is safe to write. */\n | { outcome: \"relocated\"; relativePath: string }\n /** Something claims `/` that we will not rewrite. The page is NOT written. */\n | { outcome: \"conflict\"; reason: string }\n /** We tried to relocate and could not. The page is NOT written. */\n | { outcome: \"failed\"; reason: string };\n\n/** One routes source file, as read from disk (or handed to the pure resolver in a test). */\nexport type RoutesFileSource = {\n relativePath: string;\n source: string;\n};\n\n/** What {@link resolveHomeRouteCollision} decided, plus the sources it rewrote. */\nexport type HomeRouteResolution = {\n collision: HomeRouteCollision;\n rewrites: RoutesFileSource[];\n};\n\n/**\n * Decide what to do about `src/app/**\\/routes.{ts,tsx}` claiming `/`, and\n * produce the rewritten source when exactly one file does.\n *\n * Pure by design: no filesystem access here, so this is the part a test can\n * exercise directly without touching disk. {@link relocateConflictingHomeRoute}\n * is the thin I/O wrapper — it reads the files, calls this, and writes back\n * whatever this returns in `rewrites`.\n *\n * @param files Every `routes.{ts,tsx}` file under `src/app`, with its current text.\n * @returns The collision outcome, and the (possibly empty) list of files to write back.\n */\nexport function resolveHomeRouteCollision(files: RoutesFileSource[]): HomeRouteResolution {\n const claimants = files\n .map((file) => ({ file, matches: file.source.match(TOP_LEVEL_ROOT_GET) ?? [] }))\n .filter(({ matches }) => matches.length > 0);\n\n if (claimants.length === 0) {\n return { collision: { outcome: \"absent\" }, rewrites: [] };\n }\n\n if (claimants.length > 1) {\n const names = claimants.map(({ file }) => file.relativePath).join(\", \");\n\n return {\n collision: {\n outcome: \"conflict\",\n reason: `multiple files declare a top-level GET \"/\" (${names})`,\n },\n rewrites: [],\n };\n }\n\n const [{ file, matches }] = claimants;\n\n if (matches.length > 1) {\n return {\n collision: {\n outcome: \"conflict\",\n reason: `${file.relativePath} declares ${matches.length} top-level GET \"/\" routes`,\n },\n rewrites: [],\n };\n }\n\n if (TOP_LEVEL_WELCOME_GET.test(file.source)) {\n return {\n collision: {\n outcome: \"conflict\",\n reason: `${file.relativePath} already declares GET \"/welcome\", so the usual relocation target is taken`,\n },\n rewrites: [],\n };\n }\n\n const next = file.source.replace(TOP_LEVEL_ROOT_GET, (match, quote: string) =>\n match.replace(`${quote}/${quote}`, `${quote}/welcome${quote}`),\n );\n\n if (next === file.source) {\n return {\n collision: {\n outcome: \"conflict\",\n reason: `${file.relativePath}'s GET \"/\" route could not be rewritten`,\n },\n rewrites: [],\n };\n }\n\n return {\n collision: { outcome: \"relocated\", relativePath: file.relativePath },\n rewrites: [{ relativePath: file.relativePath, source: next }],\n };\n}\n\n/**\n * Make room for a page that declares `route.path = \"/\"`.\n *\n * The project template registers `router.get(\"/\", homePageController)` and the\n * page stub declares `route.path = \"/\"`. Fastify rejects the second registration\n * (`Method 'GET' already declared for route '/'`) and the homepage 500s at\n * request time — so `warlock add web` cannot just write the page and hope.\n *\n * This used to check exactly one hardcoded path, `src/app/shared/routes.ts`.\n * That guard never fired on a real scaffold: the template registers the home\n * route at `src/app/home/routes.ts`, `fileExistsAsync` on the wrong path always\n * failed, and every call silently returned `absent` — the collision-avoidance\n * logic below existed and was correct, but was pointed at a file that does not\n * exist, so it never ran. It now scans every `src/app/**\\/routes.{ts,tsx}`\n * instead of trusting one filename, so renaming the template's routes file\n * again cannot reintroduce the same silent no-op.\n *\n * Of the three ways out, this RELOCATES the JSON route to `/welcome` rather than\n * deleting it or refusing to scaffold:\n *\n * - Deleting the controller is what the scaffolder's own `react` feature does,\n * but it may do that: it owns the file it is deleting, seconds after writing\n * it. `warlock add web` runs against a project a human has been living in, and\n * silently unlinking their code is not a thing an `add` command gets to do.\n * - Writing the page anyway and printing a warning ships a project whose\n * homepage 500s. A warning above a broken app is still a broken app.\n * - Relocating keeps BOTH surfaces working: the React homepage takes `/`, the\n * JSON welcome answers at `/welcome`, and no line of user code disappears.\n *\n * Only the exact top-level shape is rewritten, and only the path literal inside\n * it. Anything else that claims `/` is reported and left completely alone — we\n * do not guess at code we cannot recognise.\n */\nexport async function relocateConflictingHomeRoute(): Promise<HomeRouteCollision> {\n const relativePaths = await glob(\"**/routes.{ts,tsx}\", {\n cwd: srcPath(\"app\"),\n absolute: false,\n });\n\n if (relativePaths.length === 0) {\n return { outcome: \"absent\" };\n }\n\n let files: RoutesFileSource[];\n\n try {\n files = await Promise.all(\n relativePaths.map(async (relativePath) => ({\n relativePath: `app/${relativePath}`,\n source: await getFileAsync(srcPath(\"app\", relativePath)),\n })),\n );\n } catch (error) {\n return {\n outcome: \"failed\",\n reason: `could not be read (${(error as Error).message})`,\n };\n }\n\n const { collision, rewrites } = resolveHomeRouteCollision(files);\n\n if (rewrites.length === 0) {\n return collision;\n }\n\n try {\n await Promise.all(\n rewrites.map((file) => putFileAsync(srcPath(file.relativePath), file.source)),\n );\n } catch (error) {\n return {\n outcome: \"failed\",\n reason: `could not be written (${(error as Error).message})`,\n };\n }\n\n return collision;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAgBA,MAAM,qBAAqB;;;;;AAM3B,MAAM,wBAAwB;;;;;;;;;;;;;AAoC9B,SAAgB,0BAA0B,OAAgD;CACxF,MAAM,YAAY,MACf,KAAK,UAAU;EAAE;EAAM,SAAS,KAAK,OAAO,MAAM,kBAAkB,KAAK,CAAC;CAAE,EAAE,CAAC,CAC/E,QAAQ,EAAE,cAAc,QAAQ,SAAS,CAAC;CAE7C,IAAI,UAAU,WAAW,GACvB,OAAO;EAAE,WAAW,EAAE,SAAS,SAAS;EAAG,UAAU,CAAC;CAAE;CAG1D,IAAI,UAAU,SAAS,GAGrB,OAAO;EACL,WAAW;GACT,SAAS;GACT,QAAQ,+CALE,UAAU,KAAK,EAAE,WAAW,KAAK,YAAY,CAAC,CAAC,KAAK,IAKH,EAAE;EAC/D;EACA,UAAU,CAAC;CACb;CAGF,MAAM,CAAC,EAAE,MAAM,aAAa;CAE5B,IAAI,QAAQ,SAAS,GACnB,OAAO;EACL,WAAW;GACT,SAAS;GACT,QAAQ,GAAG,KAAK,aAAa,YAAY,QAAQ,OAAO;EAC1D;EACA,UAAU,CAAC;CACb;CAGF,IAAI,sBAAsB,KAAK,KAAK,MAAM,GACxC,OAAO;EACL,WAAW;GACT,SAAS;GACT,QAAQ,GAAG,KAAK,aAAa;EAC/B;EACA,UAAU,CAAC;CACb;CAGF,MAAM,OAAO,KAAK,OAAO,QAAQ,qBAAqB,OAAO,UAC3D,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,UAAU,OAAO,CAC/D;CAEA,IAAI,SAAS,KAAK,QAChB,OAAO;EACL,WAAW;GACT,SAAS;GACT,QAAQ,GAAG,KAAK,aAAa;EAC/B;EACA,UAAU,CAAC;CACb;CAGF,OAAO;EACL,WAAW;GAAE,SAAS;GAAa,cAAc,KAAK;EAAa;EACnE,UAAU,CAAC;GAAE,cAAc,KAAK;GAAc,QAAQ;EAAK,CAAC;CAC9D;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,eAAsB,+BAA4D;CAChF,MAAM,gBAAgB,MAAM,KAAK,sBAAsB;EACrD,KAAK,QAAQ,KAAK;EAClB,UAAU;CACZ,CAAC;CAED,IAAI,cAAc,WAAW,GAC3B,OAAO,EAAE,SAAS,SAAS;CAG7B,IAAI;CAEJ,IAAI;EACF,QAAQ,MAAM,QAAQ,IACpB,cAAc,IAAI,OAAO,kBAAkB;GACzC,cAAc,OAAO;GACrB,QAAQ,MAAM,aAAa,QAAQ,OAAO,YAAY,CAAC;EACzD,EAAE,CACJ;CACF,SAAS,OAAO;EACd,OAAO;GACL,SAAS;GACT,QAAQ,sBAAuB,MAAgB,QAAQ;EACzD;CACF;CAEA,MAAM,EAAE,WAAW,aAAa,0BAA0B,KAAK;CAE/D,IAAI,SAAS,WAAW,GACtB,OAAO;CAGT,IAAI;EACF,MAAM,QAAQ,IACZ,SAAS,KAAK,SAAS,aAAa,QAAQ,KAAK,YAAY,GAAG,KAAK,MAAM,CAAC,CAC9E;CACF,SAAS,OAAO;EACd,OAAO;GACL,SAAS;GACT,QAAQ,yBAA0B,MAAgB,QAAQ;EAC5D;CACF;CAEA,OAAO;AACT"}
|