@warlock.js/core 5.3.2 → 5.4.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 +17 -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/web.feature.mjs +4 -89
- 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/package.json +12 -12
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,23 @@ 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.4.0 - 2026-09-07
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- `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.
|
|
14
|
+
- `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.
|
|
15
|
+
- `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`.
|
|
16
|
+
- `warlock add web` generated code that failed the scaffold's own lint gate: twelve `prettier/prettier` errors in files the developer had not written.
|
|
17
|
+
- `warlock add notifications` generated a controller that did not compile — seven `TS2345` errors from passing `request.user` where a `Notifiable | Id` was required.
|
|
18
|
+
- 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.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- Feature generators that patch `tsconfig.json` now edit its text instead of parsing and rewriting it, so the file's comments survive.
|
|
24
|
+
- `warlock add web` locates an existing `GET /` by scanning `src/app/**/routes.ts` rather than assuming one hardcoded path.
|
|
25
|
+
|
|
9
26
|
## 5.3.2 - 2026-09-05
|
|
10
27
|
|
|
11
28
|
### 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"}
|
|
@@ -2,6 +2,7 @@ import { rootPath, srcPath } from "../../utils/paths.mjs";
|
|
|
2
2
|
import "../../utils/index.mjs";
|
|
3
3
|
import { webContactControllerStub, webContactRoutesStub, webHomePageStub, webHomeRegisterStub, webRootStub } from "../stubs.mjs";
|
|
4
4
|
import { INSTALLED_WARLOCK_VERSION } from "./types.mjs";
|
|
5
|
+
import { relocateConflictingHomeRoute } from "./shared/relocate-conflicting-home-route.mjs";
|
|
5
6
|
import { colors } from "@mongez/copper";
|
|
6
7
|
import { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from "@warlock.js/fs";
|
|
7
8
|
|
|
@@ -47,92 +48,6 @@ async function registerWebConnector() {
|
|
|
47
48
|
console.log(`${colors.green("✓")} Registered webConnector in warlock.config.ts`);
|
|
48
49
|
}
|
|
49
50
|
/**
|
|
50
|
-
* The app routes file the project template registers `GET /` in. Only this one
|
|
51
|
-
* path is inspected: `warlock add web` is not a codebase-wide route auditor, and
|
|
52
|
-
* a project that keeps its routes elsewhere lands on the `absent` outcome below,
|
|
53
|
-
* which writes the page exactly as before.
|
|
54
|
-
*/
|
|
55
|
-
const APP_ROUTES_FILE = "app/shared/routes.ts";
|
|
56
|
-
/**
|
|
57
|
-
* A TOP-LEVEL `router.get("/", ...)` — anchored at column 0 on purpose.
|
|
58
|
-
*
|
|
59
|
-
* Routes nested in a `router.group({ prefix: "/x" }, ...)` are indented by every
|
|
60
|
-
* formatter this codebase runs, and their real path is `/x`, not `/`. Anchoring
|
|
61
|
-
* is what keeps the notifications feature's own `router.get("/", ...)` (inside
|
|
62
|
-
* the `/notifications` group) from reading as a homepage collision.
|
|
63
|
-
*
|
|
64
|
-
* Only the path literal is captured. The handler — a bare identifier in the
|
|
65
|
-
* template, but possibly an inline arrow spanning lines — is never matched, so
|
|
66
|
-
* the rewrite below cannot damage it.
|
|
67
|
-
*/
|
|
68
|
-
const TOP_LEVEL_ROOT_GET = /^router\s*\.\s*get\(\s*(["'`])\/\1/gm;
|
|
69
|
-
/**
|
|
70
|
-
* Whether `/welcome` is already spoken for, so relocating onto it would trade
|
|
71
|
-
* one duplicate-route 500 for another.
|
|
72
|
-
*/
|
|
73
|
-
const TOP_LEVEL_WELCOME_GET = /^router\s*\.\s*get\(\s*(["'`])\/welcome\1/m;
|
|
74
|
-
/**
|
|
75
|
-
* Make room for a page that declares `route.path = "/"`.
|
|
76
|
-
*
|
|
77
|
-
* The project template registers `router.get("/", homePageController)` and the
|
|
78
|
-
* page stub declares `route.path = "/"`. Fastify rejects the second registration
|
|
79
|
-
* (`Method 'GET' already declared for route '/'`) and the homepage 500s at
|
|
80
|
-
* request time — so `warlock add web` cannot just write the page and hope.
|
|
81
|
-
*
|
|
82
|
-
* Of the three ways out, this RELOCATES the JSON route to `/welcome` rather than
|
|
83
|
-
* deleting it or refusing to scaffold:
|
|
84
|
-
*
|
|
85
|
-
* - Deleting the controller is what the scaffolder's own `react` feature does,
|
|
86
|
-
* but it may do that: it owns the file it is deleting, seconds after writing
|
|
87
|
-
* it. `warlock add web` runs against a project a human has been living in, and
|
|
88
|
-
* silently unlinking their code is not a thing an `add` command gets to do.
|
|
89
|
-
* - Writing the page anyway and printing a warning ships a project whose
|
|
90
|
-
* homepage 500s. A warning above a broken app is still a broken app.
|
|
91
|
-
* - Relocating keeps BOTH surfaces working: the React homepage takes `/`, the
|
|
92
|
-
* JSON welcome answers at `/welcome`, and no line of user code disappears.
|
|
93
|
-
*
|
|
94
|
-
* Only the exact top-level shape is rewritten, and only the path literal inside
|
|
95
|
-
* it. Anything else that claims `/` is reported and left completely alone — we
|
|
96
|
-
* do not guess at code we cannot recognise.
|
|
97
|
-
*/
|
|
98
|
-
async function relocateConflictingHomeRoute() {
|
|
99
|
-
const routesPath = srcPath(APP_ROUTES_FILE);
|
|
100
|
-
if (!await fileExistsAsync(routesPath)) return { outcome: "absent" };
|
|
101
|
-
let current;
|
|
102
|
-
try {
|
|
103
|
-
current = await getFileAsync(routesPath);
|
|
104
|
-
} catch (error) {
|
|
105
|
-
return {
|
|
106
|
-
outcome: "failed",
|
|
107
|
-
reason: `could not be read (${error.message})`
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
const matches = current.match(TOP_LEVEL_ROOT_GET) ?? [];
|
|
111
|
-
if (matches.length === 0) return { outcome: "absent" };
|
|
112
|
-
if (matches.length > 1) return {
|
|
113
|
-
outcome: "conflict",
|
|
114
|
-
reason: `declares ${matches.length} top-level GET "/" routes`
|
|
115
|
-
};
|
|
116
|
-
if (TOP_LEVEL_WELCOME_GET.test(current)) return {
|
|
117
|
-
outcome: "conflict",
|
|
118
|
-
reason: "already declares GET \"/welcome\", so the usual relocation target is taken"
|
|
119
|
-
};
|
|
120
|
-
const next = current.replace(TOP_LEVEL_ROOT_GET, (match, quote) => match.replace(`${quote}/${quote}`, `${quote}/welcome${quote}`));
|
|
121
|
-
if (next === current) return {
|
|
122
|
-
outcome: "conflict",
|
|
123
|
-
reason: "its GET \"/\" route could not be rewritten"
|
|
124
|
-
};
|
|
125
|
-
try {
|
|
126
|
-
await putFileAsync(routesPath, next);
|
|
127
|
-
} catch (error) {
|
|
128
|
-
return {
|
|
129
|
-
outcome: "failed",
|
|
130
|
-
reason: `could not be written (${error.message})`
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
return { outcome: "relocated" };
|
|
134
|
-
}
|
|
135
|
-
/**
|
|
136
51
|
* Scaffold the smallest page layer that renders, and register the connector.
|
|
137
52
|
*
|
|
138
53
|
* `src/web/root.tsx` is the sentinel for "already scaffolded" — the framework
|
|
@@ -146,11 +61,11 @@ async function completeWebInstallation(_options) {
|
|
|
146
61
|
await putFileAsync(rootFile, webRootStub);
|
|
147
62
|
console.log(`${colors.green("✓")} Created src/web/root.tsx`);
|
|
148
63
|
const collision = await relocateConflictingHomeRoute();
|
|
149
|
-
if (collision.outcome === "relocated") console.log(`${colors.green("✓")} Moved the existing ${colors.yellowBright("GET \"/\"")} route to ${colors.yellowBright("\"/welcome\"")} in ${colors.yellowBright(`src/${
|
|
64
|
+
if (collision.outcome === "relocated") console.log(`${colors.green("✓")} Moved the existing ${colors.yellowBright("GET \"/\"")} route to ${colors.yellowBright("\"/welcome\"")} in ${colors.yellowBright(`src/${collision.relativePath}`)} — the new page owns \`/\` now, and the JSON welcome route still answers at /welcome.`);
|
|
150
65
|
if (collision.outcome === "conflict" || collision.outcome === "failed") {
|
|
151
66
|
const verb = collision.outcome === "failed" ? colors.redBright("✗") : colors.yellowBright("!");
|
|
152
|
-
console.log(`${verb} Did not create src/web/index.page.tsx: ${
|
|
153
|
-
Free up ${colors.yellowBright("GET \"/\"")}
|
|
67
|
+
console.log(`${verb} Did not create src/web/index.page.tsx: ${collision.reason}.\n The page stub declares ${colors.yellowBright("route.path = \"/\"")}, and two handlers on one path is a 500 at request time, not a startup error.
|
|
68
|
+
Free up ${colors.yellowBright("GET \"/\"")} under src/app — move it to a path of its own, or remove it — then create src/web/index.page.tsx yourself. Giving the page a \`route\` other than \`/\` works too.`);
|
|
154
69
|
process.exitCode = 1;
|
|
155
70
|
} else {
|
|
156
71
|
await putFileAsync(srcPath("web/index.page.tsx"), webHomePageStub);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"web.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/web.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport {\r\n ensureDirectoryAsync,\r\n fileExistsAsync,\r\n getFileAsync,\r\n putFileAsync,\r\n} from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport {\n webContactControllerStub,\n webContactRoutesStub,\n webHomePageStub,\n webHomeRegisterStub,\n webRootStub,\n} from \"../stubs\";\nimport { type FeatureDefinition, INSTALLED_WARLOCK_VERSION } from \"./types\";\n\r\n/**\r\n * Register the WebConnector in `warlock.config.ts`, and ONLY there.\r\n *\r\n * It belongs to the config array or to app code, never both. Both halves are\r\n * registered before app code loads — the CLI preloader in dev, the generated\r\n * entry in production — so also calling `connectorsManager.register(...)` in\r\n * `src/app/main.ts` boots the connector twice and installs every page route\r\n * twice. That surfaces at PRODUCTION boot as `Route name \"...\" is already\r\n * taken`, because pages and API routes share one route-name namespace.\r\n *\r\n * The config array is the half to prefer: `warlock build` reads the same array\r\n * to drain each connector's build contribution, so \"built for\" and \"boots with\"\r\n * cannot drift.\r\n *\r\n * String surgery rather than a TypeScript parse: `warlock.config.ts` is an\r\n * app-owned file that may carry any formatting, and a parse-and-print would\r\n * reformat the parts we did not come to change.\r\n */\r\nasync function registerWebConnector(): Promise<void> {\r\n const configPath = rootPath(\"warlock.config.ts\");\r\n\r\n if (!(await fileExistsAsync(configPath))) {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} not found — add this yourself:\\n` +\r\n ` import { webConnector } from \"@warlock.js/web/connector\";\\n` +\r\n ` export default defineConfig({ connectors: [webConnector()] });`,\r\n );\r\n\r\n return;\r\n }\r\n\r\n const current = await getFileAsync(configPath);\r\n\r\n if (current.includes(\"webConnector\")) {\r\n console.log(`${colors.yellowBright(\"webConnector\")} already registered, skipping...`);\r\n\r\n return;\r\n }\r\n\r\n const importLine = 'import { webConnector } from \"@warlock.js/web/connector\";';\r\n let next = current.includes(importLine) ? current : `${importLine}\\n${current}`;\r\n\r\n // An existing `connectors: [` gains one entry; otherwise the key is added to\r\n // the object `defineConfig` receives.\r\n if (/connectors:\\s*\\[/.test(next)) {\r\n next = next.replace(/connectors:\\s*\\[/, \"connectors: [webConnector(),\");\r\n } else if (next.includes(\"defineConfig({\")) {\r\n next = next.replace(\"defineConfig({\", \"defineConfig({\\n connectors: [webConnector()],\");\r\n } else {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} has no recognisable defineConfig({...}) — ` +\r\n \"add `connectors: [webConnector()]` yourself.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n await putFileAsync(configPath, next);\r\n console.log(`${colors.green(\"✓\")} Registered webConnector in warlock.config.ts`);\r\n}\r\n\r\n/**\r\n * The app routes file the project template registers `GET /` in. Only this one\r\n * path is inspected: `warlock add web` is not a codebase-wide route auditor, and\r\n * a project that keeps its routes elsewhere lands on the `absent` outcome below,\r\n * which writes the page exactly as before.\r\n */\r\nconst APP_ROUTES_FILE = \"app/shared/routes.ts\";\r\n\r\n/**\r\n * A TOP-LEVEL `router.get(\"/\", ...)` — anchored at column 0 on purpose.\r\n *\r\n * Routes nested in a `router.group({ prefix: \"/x\" }, ...)` are indented by every\r\n * formatter this codebase runs, and their real path is `/x`, not `/`. Anchoring\r\n * is what keeps the notifications feature's own `router.get(\"/\", ...)` (inside\r\n * the `/notifications` group) from reading as a homepage collision.\r\n *\r\n * Only the path literal is captured. The handler — a bare identifier in the\r\n * template, but possibly an inline arrow spanning lines — is never matched, so\r\n * the rewrite below cannot damage it.\r\n */\r\nconst TOP_LEVEL_ROOT_GET = /^router\\s*\\.\\s*get\\(\\s*([\"'`])\\/\\1/gm;\r\n\r\n/**\r\n * Whether `/welcome` is already spoken for, so relocating onto it would trade\r\n * one duplicate-route 500 for another.\r\n */\r\nconst TOP_LEVEL_WELCOME_GET = /^router\\s*\\.\\s*get\\(\\s*([\"'`])\\/welcome\\1/m;\r\n\r\ntype HomeRouteCollision =\r\n /** No app routes file, or nothing claims `/` — write the page as normal. */\r\n | { outcome: \"absent\" }\r\n /** The template's `GET /` was moved to `/welcome`; the page is safe to write. */\r\n | { outcome: \"relocated\" }\r\n /** Something claims `/` that we will not rewrite. The page is NOT written. */\r\n | { outcome: \"conflict\"; reason: string }\r\n /** We tried to relocate and could not. The page is NOT written. */\r\n | { outcome: \"failed\"; reason: string };\r\n\r\n/**\r\n * Make room for a page that declares `route.path = \"/\"`.\n *\r\n * The project template registers `router.get(\"/\", homePageController)` and the\r\n * page stub declares `route.path = \"/\"`. Fastify rejects the second registration\n * (`Method 'GET' already declared for route '/'`) and the homepage 500s at\r\n * request time — so `warlock add web` cannot just write the page and hope.\r\n *\r\n * Of the three ways out, this RELOCATES the JSON route to `/welcome` rather than\r\n * deleting it or refusing to scaffold:\r\n *\r\n * - Deleting the controller is what the scaffolder's own `react` feature does,\r\n * but it may do that: it owns the file it is deleting, seconds after writing\r\n * it. `warlock add web` runs against a project a human has been living in, and\r\n * silently unlinking their code is not a thing an `add` command gets to do.\r\n * - Writing the page anyway and printing a warning ships a project whose\r\n * homepage 500s. A warning above a broken app is still a broken app.\r\n * - Relocating keeps BOTH surfaces working: the React homepage takes `/`, the\r\n * JSON welcome answers at `/welcome`, and no line of user code disappears.\r\n *\r\n * Only the exact top-level shape is rewritten, and only the path literal inside\r\n * it. Anything else that claims `/` is reported and left completely alone — we\r\n * do not guess at code we cannot recognise.\r\n */\r\nasync function relocateConflictingHomeRoute(): Promise<HomeRouteCollision> {\r\n const routesPath = srcPath(APP_ROUTES_FILE);\r\n\r\n // Not every project comes from the template. No file is not a problem.\r\n if (!(await fileExistsAsync(routesPath))) {\r\n return { outcome: \"absent\" };\r\n }\r\n\r\n let current: string;\r\n\r\n try {\r\n current = await getFileAsync(routesPath);\r\n } catch (error) {\r\n return {\r\n outcome: \"failed\",\r\n reason: `could not be read (${(error as Error).message})`,\r\n };\r\n }\r\n\r\n const matches = current.match(TOP_LEVEL_ROOT_GET) ?? [];\r\n\r\n if (matches.length === 0) {\r\n return { outcome: \"absent\" };\r\n }\r\n\r\n if (matches.length > 1) {\r\n return {\r\n outcome: \"conflict\",\r\n reason: `declares ${matches.length} top-level GET \"/\" routes`,\r\n };\r\n }\r\n\r\n if (TOP_LEVEL_WELCOME_GET.test(current)) {\r\n return {\r\n outcome: \"conflict\",\r\n reason: 'already declares GET \"/welcome\", so the usual relocation target is taken',\r\n };\r\n }\r\n\r\n const next = current.replace(TOP_LEVEL_ROOT_GET, (match, quote: string) =>\r\n match.replace(`${quote}/${quote}`, `${quote}/welcome${quote}`),\r\n );\r\n\r\n if (next === current) {\r\n return { outcome: \"conflict\", reason: 'its GET \"/\" route could not be rewritten' };\r\n }\r\n\r\n try {\r\n await putFileAsync(routesPath, next);\r\n } catch (error) {\r\n return {\r\n outcome: \"failed\",\r\n reason: `could not be written (${(error as Error).message})`,\r\n };\r\n }\r\n\r\n return { outcome: \"relocated\" };\r\n}\r\n\r\n/**\r\n * Scaffold the smallest page layer that renders, and register the connector.\r\n *\r\n * `src/web/root.tsx` is the sentinel for \"already scaffolded\" — the framework\r\n * ships a default root, so its presence means a human has been here.\r\n */\r\nasync function completeWebInstallation(_options: CommandActionData) {\r\n const rootFile = srcPath(\"web/root.tsx\");\r\n\r\n if (await fileExistsAsync(rootFile)) {\r\n console.log(`${colors.yellowBright(\"src/web\")} already scaffolded, skipping...`);\r\n } else {\r\n await ensureDirectoryAsync(srcPath(\"web\"));\r\n await putFileAsync(rootFile, webRootStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/root.tsx`);\r\n\r\n const collision = await relocateConflictingHomeRoute();\r\n\r\n if (collision.outcome === \"relocated\") {\r\n console.log(\r\n `${colors.green(\"✓\")} Moved the existing ${colors.yellowBright('GET \"/\"')} route to ` +\r\n `${colors.yellowBright('\"/welcome\"')} in ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} — ` +\r\n \"the new page owns `/` now, and the JSON welcome route still answers at /welcome.\",\r\n );\r\n }\r\n\r\n // The page is written ONLY when `/` is provably free. Writing it while\r\n // another handler holds `/` produces a homepage that 500s on first request,\r\n // which is precisely the outcome a scaffolder must never hand back.\r\n if (collision.outcome === \"conflict\" || collision.outcome === \"failed\") {\r\n const verb = collision.outcome === \"failed\" ? colors.redBright(\"✗\") : colors.yellowBright(\"!\");\r\n\r\n console.log(\r\n `${verb} Did not create src/web/index.page.tsx: ` +\n `${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route.path = \"/\"')}, and two handlers on one ` +\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} in that file — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/index.page.tsx yourself. Giving the page a `route` other \" +\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\n await putFileAsync(srcPath(\"web/index.page.tsx\"), webHomePageStub);\n await putFileAsync(srcPath(\"web/index.register.ts\"), webHomeRegisterStub);\n await ensureDirectoryAsync(srcPath(\"app/contact/controllers\"));\n await putFileAsync(\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\n webContactControllerStub,\n );\n await putFileAsync(srcPath(\"app/contact/routes.ts\"), webContactRoutesStub);\n console.log(`${colors.green(\"✓\")} Created src/web/index.page.tsx`);\n console.log(`${colors.green(\"✓\")} Created POST /api/contact starter route`);\n }\r\n }\r\n\r\n await registerWebConnector();\r\n}\r\n\r\nexport const webFeature: FeatureDefinition = {\r\n description:\r\n \"Installs @warlock.js/web — SSR React pages served by the Warlock HTTP server. Scaffolds src/web (root.tsx + a home page) and registers the WebConnector in warlock.config.ts. Pages are opt-in: a Warlock app is an API until you add this.\",\r\n dependencies: {\r\n \"@warlock.js/web\": INSTALLED_WARLOCK_VERSION,\n \"@mongez/http\": \"^3.5.0\",\n \"@mongez/react-form\": \"^4.0.0\",\n \"@mongez/react-localization\": \"^3.4.7\",\n react: \"^19.2.3\",\r\n \"react-dom\": \"^19.2.3\",\r\n },\r\n devDependencies: {\r\n \"@types/react\": \"^19.2.7\",\r\n \"@types/react-dom\": \"^19.2.3\",\r\n // Loaded through `await import()` by the dev server only, so both are\r\n // optional peers of `web` rather than hard dependencies.\r\n vite: \"^7.3.5\",\r\n \"@vitejs/plugin-react\": \"^5.2.0\",\r\n },\r\n onExecuting: completeWebInstallation,\r\n};\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"web.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/web.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { ensureDirectoryAsync, fileExistsAsync, getFileAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport { relocateConflictingHomeRoute } from \"./shared/relocate-conflicting-home-route\";\r\nimport {\r\n webContactControllerStub,\r\n webContactRoutesStub,\r\n webHomePageStub,\r\n webHomeRegisterStub,\r\n webRootStub,\r\n} from \"../stubs\";\r\nimport { type FeatureDefinition, INSTALLED_WARLOCK_VERSION } from \"./types\";\r\n\r\n/**\r\n * Register the WebConnector in `warlock.config.ts`, and ONLY there.\r\n *\r\n * It belongs to the config array or to app code, never both. Both halves are\r\n * registered before app code loads — the CLI preloader in dev, the generated\r\n * entry in production — so also calling `connectorsManager.register(...)` in\r\n * `src/app/main.ts` boots the connector twice and installs every page route\r\n * twice. That surfaces at PRODUCTION boot as `Route name \"...\" is already\r\n * taken`, because pages and API routes share one route-name namespace.\r\n *\r\n * The config array is the half to prefer: `warlock build` reads the same array\r\n * to drain each connector's build contribution, so \"built for\" and \"boots with\"\r\n * cannot drift.\r\n *\r\n * String surgery rather than a TypeScript parse: `warlock.config.ts` is an\r\n * app-owned file that may carry any formatting, and a parse-and-print would\r\n * reformat the parts we did not come to change.\r\n */\r\nasync function registerWebConnector(): Promise<void> {\r\n const configPath = rootPath(\"warlock.config.ts\");\r\n\r\n if (!(await fileExistsAsync(configPath))) {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} not found — add this yourself:\\n` +\r\n ` import { webConnector } from \"@warlock.js/web/connector\";\\n` +\r\n ` export default defineConfig({ connectors: [webConnector()] });`,\r\n );\r\n\r\n return;\r\n }\r\n\r\n const current = await getFileAsync(configPath);\r\n\r\n if (current.includes(\"webConnector\")) {\r\n console.log(`${colors.yellowBright(\"webConnector\")} already registered, skipping...`);\r\n\r\n return;\r\n }\r\n\r\n const importLine = 'import { webConnector } from \"@warlock.js/web/connector\";';\r\n let next = current.includes(importLine) ? current : `${importLine}\\n${current}`;\r\n\r\n // An existing `connectors: [` gains one entry; otherwise the key is added to\r\n // the object `defineConfig` receives.\r\n if (/connectors:\\s*\\[/.test(next)) {\r\n next = next.replace(/connectors:\\s*\\[/, \"connectors: [webConnector(),\");\r\n } else if (next.includes(\"defineConfig({\")) {\r\n next = next.replace(\"defineConfig({\", \"defineConfig({\\n connectors: [webConnector()],\");\r\n } else {\r\n console.log(\r\n `${colors.yellowBright(\"warlock.config.ts\")} has no recognisable defineConfig({...}) — ` +\r\n \"add `connectors: [webConnector()]` yourself.\",\r\n );\r\n\r\n return;\r\n }\r\n\r\n await putFileAsync(configPath, next);\r\n console.log(`${colors.green(\"✓\")} Registered webConnector in warlock.config.ts`);\r\n}\r\n\r\n/**\r\n * Scaffold the smallest page layer that renders, and register the connector.\r\n *\r\n * `src/web/root.tsx` is the sentinel for \"already scaffolded\" — the framework\r\n * ships a default root, so its presence means a human has been here.\r\n */\r\nasync function completeWebInstallation(_options: CommandActionData) {\r\n const rootFile = srcPath(\"web/root.tsx\");\r\n\r\n if (await fileExistsAsync(rootFile)) {\r\n console.log(`${colors.yellowBright(\"src/web\")} already scaffolded, skipping...`);\r\n } else {\r\n await ensureDirectoryAsync(srcPath(\"web\"));\r\n await putFileAsync(rootFile, webRootStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/root.tsx`);\r\n\r\n const collision = await relocateConflictingHomeRoute();\r\n\r\n if (collision.outcome === \"relocated\") {\r\n console.log(\r\n `${colors.green(\"✓\")} Moved the existing ${colors.yellowBright('GET \"/\"')} route to ` +\r\n `${colors.yellowBright('\"/welcome\"')} in ${colors.yellowBright(`src/${collision.relativePath}`)} — ` +\r\n \"the new page owns `/` now, and the JSON welcome route still answers at /welcome.\",\r\n );\r\n }\r\n\r\n // The page is written ONLY when `/` is provably free. Writing it while\r\n // another handler holds `/` produces a homepage that 500s on first request,\r\n // which is precisely the outcome a scaffolder must never hand back.\r\n if (collision.outcome === \"conflict\" || collision.outcome === \"failed\") {\r\n const verb = collision.outcome === \"failed\" ? colors.redBright(\"✗\") : colors.yellowBright(\"!\");\r\n\r\n console.log(\r\n `${verb} Did not create src/web/index.page.tsx: ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route.path = \"/\"')}, and two handlers on one ` +\r\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} under src/app — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/index.page.tsx yourself. Giving the page a `route` other \" +\r\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\r\n await putFileAsync(srcPath(\"web/index.page.tsx\"), webHomePageStub);\r\n await putFileAsync(srcPath(\"web/index.register.ts\"), webHomeRegisterStub);\r\n await ensureDirectoryAsync(srcPath(\"app/contact/controllers\"));\r\n await putFileAsync(\r\n srcPath(\"app/contact/controllers/contact.controller.ts\"),\r\n webContactControllerStub,\r\n );\r\n await putFileAsync(srcPath(\"app/contact/routes.ts\"), webContactRoutesStub);\r\n console.log(`${colors.green(\"✓\")} Created src/web/index.page.tsx`);\r\n console.log(`${colors.green(\"✓\")} Created POST /api/contact starter route`);\r\n }\r\n }\r\n\r\n await registerWebConnector();\r\n}\r\n\r\nexport const webFeature: FeatureDefinition = {\r\n description:\r\n \"Installs @warlock.js/web — SSR React pages served by the Warlock HTTP server. Scaffolds src/web (root.tsx + a home page) and registers the WebConnector in warlock.config.ts. Pages are opt-in: a Warlock app is an API until you add this.\",\r\n dependencies: {\r\n \"@warlock.js/web\": INSTALLED_WARLOCK_VERSION,\r\n \"@mongez/http\": \"^3.5.0\",\r\n \"@mongez/react-form\": \"^4.0.0\",\r\n \"@mongez/react-localization\": \"^3.4.7\",\r\n react: \"^19.2.3\",\r\n \"react-dom\": \"^19.2.3\",\r\n },\r\n devDependencies: {\r\n \"@types/react\": \"^19.2.7\",\r\n \"@types/react-dom\": \"^19.2.3\",\r\n // Loaded through `await import()` by the dev server only, so both are\r\n // optional peers of `web` rather than hard dependencies.\r\n vite: \"^7.3.5\",\r\n \"@vitejs/plugin-react\": \"^5.2.0\",\r\n },\r\n onExecuting: completeWebInstallation,\r\n};\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAe,uBAAsC;CACnD,MAAM,aAAa,SAAS,mBAAmB;CAE/C,IAAI,CAAE,MAAM,gBAAgB,UAAU,GAAI;EACxC,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,+JAG9C;EAEA;CACF;CAEA,MAAM,UAAU,MAAM,aAAa,UAAU;CAE7C,IAAI,QAAQ,SAAS,cAAc,GAAG;EACpC,QAAQ,IAAI,GAAG,OAAO,aAAa,cAAc,EAAE,iCAAiC;EAEpF;CACF;CAEA,MAAM,aAAa;CACnB,IAAI,OAAO,QAAQ,SAAS,UAAU,IAAI,UAAU,GAAG,WAAW,IAAI;CAItE,IAAI,mBAAmB,KAAK,IAAI,GAC9B,OAAO,KAAK,QAAQ,oBAAoB,8BAA8B;MACjE,IAAI,KAAK,SAAS,gBAAgB,GACvC,OAAO,KAAK,QAAQ,kBAAkB,iDAAiD;MAClF;EACL,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,0FAE9C;EAEA;CACF;CAEA,MAAM,aAAa,YAAY,IAAI;CACnC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,8CAA8C;AACjF;;;;;;;AAQA,eAAe,wBAAwB,UAA6B;CAClE,MAAM,WAAW,QAAQ,cAAc;CAEvC,IAAI,MAAM,gBAAgB,QAAQ,GAChC,QAAQ,IAAI,GAAG,OAAO,aAAa,SAAS,EAAE,iCAAiC;MAC1E;EACL,MAAM,qBAAqB,QAAQ,KAAK,CAAC;EACzC,MAAM,aAAa,UAAU,WAAW;EACxC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,0BAA0B;EAE3D,MAAM,YAAY,MAAM,6BAA6B;EAErD,IAAI,UAAU,YAAY,aACxB,QAAQ,IACN,GAAG,OAAO,MAAM,GAAG,EAAE,sBAAsB,OAAO,aAAa,WAAS,EAAE,YACrE,OAAO,aAAa,cAAY,EAAE,MAAM,OAAO,aAAa,OAAO,UAAU,cAAc,EAAE,sFAEpG;EAMF,IAAI,UAAU,YAAY,cAAc,UAAU,YAAY,UAAU;GACtE,MAAM,OAAO,UAAU,YAAY,WAAW,OAAO,UAAU,GAAG,IAAI,OAAO,aAAa,GAAG;GAE7F,QAAQ,IACN,GAAG,KAAK,0CAA0C,UAAU,OAAO,8BACrC,OAAO,aAAa,oBAAkB,EAAE;YAEvD,OAAO,aAAa,WAAS,EAAE,mKAGhD;GAUA,QAAQ,WAAW;EACrB,OAAO;GACL,MAAM,aAAa,QAAQ,oBAAoB,GAAG,eAAe;GACjE,MAAM,aAAa,QAAQ,uBAAuB,GAAG,mBAAmB;GACxE,MAAM,qBAAqB,QAAQ,yBAAyB,CAAC;GAC7D,MAAM,aACJ,QAAQ,+CAA+C,GACvD,wBACF;GACA,MAAM,aAAa,QAAQ,uBAAuB,GAAG,oBAAoB;GACzE,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,gCAAgC;GACjE,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,yCAAyC;EAC5E;CACF;CAEA,MAAM,qBAAqB;AAC7B;AAEA,MAAa,aAAgC;CAC3C,aACE;CACF,cAAc;EACZ,mBAAmB;EACnB,gBAAgB;EAChB,sBAAsB;EACtB,8BAA8B;EAC9B,OAAO;EACP,aAAa;CACf;CACA,iBAAiB;EACf,gBAAgB;EAChB,oBAAoB;EAGpB,MAAM;EACN,wBAAwB;CAC1B;CACA,aAAa;AACf"}
|
|
@@ -461,7 +461,7 @@ import { Notification } from "../notification.model";
|
|
|
461
461
|
export default Migration.create(Notification, notificationColumns(Notification));
|
|
462
462
|
`;
|
|
463
463
|
const notificationControllersStub = `import { type RequestHandler } from "@warlock.js/core";
|
|
464
|
-
import { inApp } from "@warlock.js/notifications";
|
|
464
|
+
import { inApp, type Id } from "@warlock.js/notifications";
|
|
465
465
|
|
|
466
466
|
/**
|
|
467
467
|
* The authenticated user's notification HTTP surface — thin wrappers over the
|
|
@@ -470,9 +470,28 @@ import { inApp } from "@warlock.js/notifications";
|
|
|
470
470
|
* is no create. Trim or split these as your app grows.
|
|
471
471
|
*/
|
|
472
472
|
|
|
473
|
+
/**
|
|
474
|
+
* Read \`id\` off \`request.user\` without assuming this app's \`RequestUser\`
|
|
475
|
+
* augmentation declares it — \`RequestUser\` is empty by default (see
|
|
476
|
+
* \`@warlock.js/core\`'s \`RequestUser\` docs), so a narrow runtime read survives
|
|
477
|
+
* any augmentation shape instead of assuming \`.id\` exists at the type level.
|
|
478
|
+
* \`inApp\` only ever needs the id (it reduces a \`Notifiable\` to one via
|
|
479
|
+
* \`recipient.id\` internally), so reading it here — rather than forwarding
|
|
480
|
+
* \`request.user\` itself — also skips a needless \`Notifiable\` cast.
|
|
481
|
+
*/
|
|
482
|
+
function recipientId(user: unknown): Id {
|
|
483
|
+
if (user && typeof user === "object" && "id" in user) {
|
|
484
|
+
const id = (user as { id?: unknown }).id;
|
|
485
|
+
|
|
486
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
throw new Error("Authenticated request is missing a usable user id");
|
|
490
|
+
}
|
|
491
|
+
|
|
473
492
|
/** GET /notifications — list, most recent first (page / limit / type / unread via query). */
|
|
474
493
|
export const listNotificationsController: RequestHandler = async ({ request, response }) => {
|
|
475
|
-
const { data, pagination } = await inApp.list(request.user
|
|
494
|
+
const { data, pagination } = await inApp.list(recipientId(request.user), request.all());
|
|
476
495
|
|
|
477
496
|
return response.success({ notifications: data, pagination });
|
|
478
497
|
};
|
|
@@ -484,7 +503,7 @@ export const unreadNotificationsCountController: RequestHandler = async ({
|
|
|
484
503
|
request,
|
|
485
504
|
response,
|
|
486
505
|
}) => {
|
|
487
|
-
const count = await inApp.countUnread(request.user
|
|
506
|
+
const count = await inApp.countUnread(recipientId(request.user));
|
|
488
507
|
|
|
489
508
|
return response.success({ count });
|
|
490
509
|
};
|
|
@@ -494,9 +513,10 @@ unreadNotificationsCountController.description = "Unread notifications count";
|
|
|
494
513
|
/** PATCH /notifications/:id/read — mark one read, return the updated row. */
|
|
495
514
|
export const markNotificationReadController: RequestHandler = async ({ request, response }) => {
|
|
496
515
|
const id = request.input("id");
|
|
516
|
+
const userId = recipientId(request.user);
|
|
497
517
|
|
|
498
|
-
await inApp.markAsRead(
|
|
499
|
-
const notification = await inApp.find(
|
|
518
|
+
await inApp.markAsRead(userId, id);
|
|
519
|
+
const notification = await inApp.find(userId, id);
|
|
500
520
|
|
|
501
521
|
return response.success({ notification });
|
|
502
522
|
};
|
|
@@ -508,7 +528,7 @@ export const markAllNotificationsReadController: RequestHandler = async ({
|
|
|
508
528
|
request,
|
|
509
529
|
response,
|
|
510
530
|
}) => {
|
|
511
|
-
const count = await inApp.markAsRead(request.user
|
|
531
|
+
const count = await inApp.markAsRead(recipientId(request.user));
|
|
512
532
|
|
|
513
533
|
return response.success({ count });
|
|
514
534
|
};
|
|
@@ -517,7 +537,7 @@ markAllNotificationsReadController.description = "Mark all notifications read";
|
|
|
517
537
|
|
|
518
538
|
/** DELETE /notifications — dismiss all for the user. */
|
|
519
539
|
export const clearNotificationsController: RequestHandler = async ({ request, response }) => {
|
|
520
|
-
await inApp.dismiss(request.user
|
|
540
|
+
await inApp.dismiss(recipientId(request.user));
|
|
521
541
|
|
|
522
542
|
return response.noContent();
|
|
523
543
|
};
|
|
@@ -526,7 +546,7 @@ clearNotificationsController.description = "Clear notifications";
|
|
|
526
546
|
|
|
527
547
|
/** DELETE /notifications/:id — dismiss one. */
|
|
528
548
|
export const deleteNotificationController: RequestHandler = async ({ request, response }) => {
|
|
529
|
-
await inApp.dismiss(request.user
|
|
549
|
+
await inApp.dismiss(recipientId(request.user), request.input("id"));
|
|
530
550
|
|
|
531
551
|
return response.noContent();
|
|
532
552
|
};
|
|
@@ -570,8 +590,8 @@ router.group({ prefix: "/notifications", middleware: [authMiddleware([])] }, ()
|
|
|
570
590
|
* reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller
|
|
571
591
|
* shape: middleware, an app-level loader, locales, an ErrorBoundary.
|
|
572
592
|
*/
|
|
573
|
-
const webRootStub = `import {
|
|
574
|
-
import
|
|
593
|
+
const webRootStub = `import type { AppProps } from "@warlock.js/web";
|
|
594
|
+
import { Head, Scripts } from "@warlock.js/web";
|
|
575
595
|
|
|
576
596
|
/**
|
|
577
597
|
* The application root.
|
|
@@ -636,7 +656,10 @@ export const contactSchema = v.object({
|
|
|
636
656
|
export type ContactSchema = Infer.Output<typeof contactSchema>;
|
|
637
657
|
|
|
638
658
|
/** POST /api/contact — validates the starter contact form. */
|
|
639
|
-
export const contactController: RequestHandler<Request<ContactSchema>> = async ({
|
|
659
|
+
export const contactController: RequestHandler<Request<ContactSchema>> = async ({
|
|
660
|
+
request,
|
|
661
|
+
response,
|
|
662
|
+
}) => {
|
|
640
663
|
const contact = request.validated();
|
|
641
664
|
|
|
642
665
|
// Replace this with delivery/persistence for your app. Keeping the accepted
|
|
@@ -697,12 +720,12 @@ export function register() {
|
|
|
697
720
|
* the moment this finishes.
|
|
698
721
|
*/
|
|
699
722
|
const webHomePageStub = `import { http } from "@mongez/http";
|
|
700
|
-
import { Form, useFormControl, type FormControlProps } from "@mongez/react-form";
|
|
701
723
|
import { setCurrentLocaleCode } from "@mongez/localization";
|
|
724
|
+
import { Form, useFormControl, type FormControlProps } from "@mongez/react-form";
|
|
702
725
|
import { transX } from "@mongez/react-localization";
|
|
703
726
|
import { v } from "@warlock.js/seal";
|
|
704
|
-
import { useState } from "react";
|
|
705
727
|
import { Link, type PageProps } from "@warlock.js/web";
|
|
728
|
+
import { useState } from "react";
|
|
706
729
|
|
|
707
730
|
export { register } from "./index.register";
|
|
708
731
|
|
|
@@ -818,8 +841,12 @@ export default function HomePage(_props: PageProps) {
|
|
|
818
841
|
|
|
819
842
|
<main className="wk-home" dir={locale === "ar" ? "rtl" : "ltr"}>
|
|
820
843
|
<nav className="wk-links" aria-label="Starter links">
|
|
821
|
-
<a href="https://warlock.js.org" target="_blank" rel="noreferrer">
|
|
822
|
-
|
|
844
|
+
<a href="https://warlock.js.org" target="_blank" rel="noreferrer">
|
|
845
|
+
Docs
|
|
846
|
+
</a>
|
|
847
|
+
<Link href="/" aria-current="page">
|
|
848
|
+
Home
|
|
849
|
+
</Link>
|
|
823
850
|
<button
|
|
824
851
|
className="wk-language"
|
|
825
852
|
type="button"
|
|
@@ -836,7 +863,7 @@ export default function HomePage(_props: PageProps) {
|
|
|
836
863
|
<section className="wk-check">
|
|
837
864
|
<label>If this number goes up when you click, React is hydrated:</label>
|
|
838
865
|
<strong>{count}</strong>
|
|
839
|
-
<button type="button" onClick={() => setCount(c => c + 1)}>
|
|
866
|
+
<button type="button" onClick={() => setCount((c) => c + 1)}>
|
|
840
867
|
Count up
|
|
841
868
|
</button>
|
|
842
869
|
</section>
|
|
@@ -874,11 +901,24 @@ export default function HomePage(_props: PageProps) {
|
|
|
874
901
|
}}
|
|
875
902
|
>
|
|
876
903
|
<TextInput name="name" label={transX("starter.name")} autoComplete="name" />
|
|
877
|
-
<TextInput
|
|
904
|
+
<TextInput
|
|
905
|
+
name="email"
|
|
906
|
+
label={transX("starter.email")}
|
|
907
|
+
type="email"
|
|
908
|
+
autoComplete="email"
|
|
909
|
+
/>
|
|
878
910
|
<ContactMessage />
|
|
879
911
|
<button type="submit">{transX("starter.submit")}</button>
|
|
880
|
-
{submitError &&
|
|
881
|
-
|
|
912
|
+
{submitError && (
|
|
913
|
+
<p className="wk-submit-error" role="alert">
|
|
914
|
+
{submitError}
|
|
915
|
+
</p>
|
|
916
|
+
)}
|
|
917
|
+
{submitted && (
|
|
918
|
+
<p className="wk-success" role="status">
|
|
919
|
+
{transX("starter.sent")}
|
|
920
|
+
</p>
|
|
921
|
+
)}
|
|
882
922
|
</Form>
|
|
883
923
|
</section>
|
|
884
924
|
</main>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\r\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\r\n\r\n/**\r\n * Authorization configuration — read by @warlock.js/access on boot.\r\n *\r\n * The resolver is the one required piece: it tells the engine how to read a\r\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\r\n * from the user_roles table and maps them through the roles catalog table (so\r\n * roles + their permissions are managed at runtime, in the DB).\r\n *\r\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\r\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\r\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\r\n *\r\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\r\n * tenant from the request; checks then scope to it automatically.\r\n */\r\nconst access: AccessConfigurations = {\r\n resolver: new DatabaseAccessResolver(),\r\n\r\n // Cache resolved permission sets (default \"10m\").\r\n // cache: { ttl: \"10m\" },\r\n};\r\n\r\nexport default access;\r\n`;\r\n\r\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\r\n\r\n// >>> warlock:ai-packages (auto-managed) >>>\r\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\r\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\r\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\r\n// side-effect import below; keep them so the augmentation + runtime registration\r\n// load before the ai connector applies this config.\r\n// <<< warlock:ai-packages <<<\r\n\r\n/**\r\n * AI configuration — applied on boot by the ai connector, which calls\r\n * ai.config(...) with the object below. Cross-cutting defaults live here\r\n * (shared cache / snapshot stores, observability); per-call options always win.\r\n *\r\n * Wire a default model from a provider you installed, e.g.:\r\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\r\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\r\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\r\n */\r\nconst ai: Partial<AIConfig> = {\r\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\r\n // defaultStore: cache.driver(\"redis\", { client }),\r\n\r\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\r\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\r\n};\r\n\r\nexport default ai;\r\n`;\r\n\r\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the roles catalog — mirrors the migration columns\r\n * (snake_case). Each row is a role name plus the permission strings it grants;\r\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\r\n * assigned role names through this table to their effective permissions.\r\n */\r\nexport const roleSchema = v.object({\r\n name: v.string(),\r\n permissions: v.array(v.string()).default([]),\r\n});\r\n\r\nexport type RoleSchema = Infer<typeof roleSchema>;\r\n\r\n/**\r\n * The roles catalog — role name → the permissions it grants. Managed at runtime\r\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\r\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\r\n */\r\n@RegisterModel()\r\nexport class Role extends Model<RoleSchema> {\r\n public static table = \"roles\";\r\n\r\n public static schema = roleSchema;\r\n\r\n /** The permission strings this role grants. */\r\n public get permissions(): string[] {\r\n return this.get<string[]>(\"permissions\", []);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\r\n`;\r\n\r\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\r\nimport { Role } from \"../role.model\";\r\n\r\n/**\r\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\r\n * text array of the permission strings the role grants.\r\n */\r\nexport default Migration.create(Role, {\r\n name: text().notNullable().unique(),\r\n permissions: arrayText().nullable(),\r\n});\r\n`;\r\n\r\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for a role assignment — mirrors the migration columns\r\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\r\n */\r\nexport const userRoleSchema = v.object({\r\n user_id: v.string(),\r\n user_type: v.string(),\r\n role: v.string(),\r\n tenant: v.string().optional(),\r\n});\r\n\r\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\r\n\r\n/**\r\n * The role-assignment table — which roles a user holds, optionally per tenant.\r\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\r\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\r\n * never need to call \\`access.flush(user, tenant)\\` themselves.\r\n */\r\n@RegisterModel()\r\nexport class UserRole extends Model<UserRoleSchema> {\r\n public static table = \"user_roles\";\r\n\r\n public static schema = userRoleSchema;\r\n\r\n /**\r\n * Role names assigned to the user in the given tenant.\r\n *\r\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\r\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\r\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\r\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\r\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\r\n */\r\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\r\n const rows = await this.query()\r\n .where({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n tenant: tenant ?? null,\r\n })\r\n .get();\r\n\r\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\r\n // existence check) can't distort the resolved set.\r\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\r\n }\r\n\r\n /**\r\n * Assign a role to the user. No-op if the assignment already exists.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\r\n const existing = await this.first({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n if (existing) return;\r\n\r\n await this.create({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n\r\n /**\r\n * Remove a role assignment from the user.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\r\n await this.delete({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\r\n`;\r\n\r\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\r\nimport { UserRole } from \"../user-role.model\";\r\n\r\n/**\r\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\r\n * user ids are integers. The composite index powers the per-user (per-tenant)\r\n * lookup the resolver runs on every check.\r\n */\r\nexport default Migration.create(\r\n UserRole,\r\n {\r\n user_id: uuid().notNullable().index(),\r\n user_type: text().notNullable(),\r\n role: text().notNullable().index(),\r\n tenant: text().nullable().index(),\r\n },\r\n {\r\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\r\n },\r\n);\r\n`;\r\n\r\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Role } from \"app/access/models/role\";\r\nimport { UserRole } from \"app/access/models/user-role\";\r\n\r\n/**\r\n * The app's access adapter — connects @warlock.js/access to the ejected role\r\n * tables. Roles come from the user_roles assignment table; permissions are\r\n * expanded by mapping those role names through the roles catalog table. Both\r\n * are managed at runtime (in the DB), so admins can add roles + edit their\r\n * permissions without a deploy.\r\n *\r\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\r\n * resolver only fetches — keep it dumb, never cache inside it.\r\n */\r\nexport class DatabaseAccessResolver implements AccessResolver {\r\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\r\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\r\n return UserRole.rolesFor(user, tenant);\r\n }\r\n\r\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\r\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\r\n const names = await this.resolveRoles(user, tenant);\r\n\r\n if (names.length === 0) return [];\r\n\r\n const roles = await Role.query().whereIn(\"name\", names).get();\r\n\r\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\r\n return [...new Set(roles.flatMap((role) => role.permissions))];\r\n }\r\n\r\n /**\r\n * Optional. Resolve the ambient tenant when a check doesn't pass one\r\n * explicitly — derive it from the authenticated user (safer than reading\r\n * client request input, which a caller could spoof). Uncomment + adapt for a\r\n * multi-tenant app (single-tenant apps leave this off and return undefined).\r\n */\r\n // public resolveTenant(user: Auth): string | undefined {\r\n // return user.get(\"organization_id\");\r\n // }\r\n}\r\n`;\r\n\r\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\r\n\r\n/**\r\n * Socket.IO configuration — read by the framework's socket connector\r\n * on boot. When the HTTP server is running the socket server attaches\r\n * to it; otherwise it listens on its own configured port.\r\n *\r\n * Remove this file to disable the socket server entirely.\r\n */\r\nexport default {\r\n options: {\r\n cors: {\r\n origin: \"*\",\r\n },\r\n },\r\n} as SocketOptions;\r\n`;\r\n\r\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\r\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\r\n\r\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\r\n driver: \"rabbitmq\",\r\n name: \"default\",\r\n isDefault: true,\r\n\r\n // ============================================================================\r\n // Connection Settings\r\n // ============================================================================\r\n\r\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\r\n port: env(\"RABBITMQ_PORT\", 5672),\r\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\r\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\r\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\r\n\r\n // Or use connection URI (takes precedence over host/port)\r\n // uri: env(\"RABBITMQ_URL\"),\r\n\r\n // ============================================================================\r\n // Connection Options\r\n // ============================================================================\r\n\r\n /** Heartbeat interval in seconds */\r\n heartbeat: 60,\r\n\r\n /** Connection timeout in milliseconds */\r\n connectionTimeout: 10000,\r\n\r\n /** Enable automatic reconnection on disconnect */\r\n reconnect: true,\r\n\r\n /** Delay between reconnection attempts in milliseconds */\r\n reconnectDelay: 5_000,\r\n\r\n // ============================================================================\r\n // Consumer Options\r\n // ============================================================================\r\n\r\n /** Default prefetch count (number of unacknowledged messages per consumer) */\r\n prefetch: 10,\r\n\r\n // ============================================================================\r\n // Client Options (Native amqplib options)\r\n // ============================================================================\r\n // These options are passed directly to amqplib.connect()\r\n // for low-level configuration like frame size, TLS, socket options, etc.\r\n // ============================================================================\r\n clientOptions: {\r\n // Frame max size in bytes (0 = no limit)\r\n // frameMax: 0,\r\n\r\n // Channel max (0 = unlimited)\r\n // channelMax: 0,\r\n\r\n // Socket options\r\n socket: {\r\n // Enable TCP keep-alive\r\n keepAlive: true,\r\n\r\n // Disable Nagle's algorithm for lower latency\r\n noDelay: true,\r\n\r\n // Socket timeout (in addition to heartbeat)\r\n // timeout: 30000,\r\n },\r\n\r\n // TLS/SSL options (uncomment for secure connections)\r\n // socket: {\r\n // ca: fs.readFileSync('/path/to/ca.pem'),\r\n // cert: fs.readFileSync('/path/to/cert.pem'),\r\n // key: fs.readFileSync('/path/to/key.pem'),\r\n // rejectUnauthorized: true,\r\n // },\r\n },\r\n};\r\n\r\nexport default heraldConfigurations;\r\n`;\r\n\r\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"app/notifications/notification.model\";\r\n\r\n/**\r\n * Notifications configuration. Auto-loaded from src/config on boot — the\r\n * framework's notifications connector reads this default export and hands it to\r\n * setNotificationConfig, so this file stays declarative (no side-effect call).\r\n *\r\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\r\n * and defineNotification are type-checked against the registry.\r\n *\r\n * Channels enabled here:\r\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\r\n * The \"from\" address defaults to config/mail.ts; override per\r\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\r\n * - database in-app store backed by the Notification model. The \"inApp\"\r\n * facade exposes the recipient-scoped read API: listUnread,\r\n * countUnread, markAsRead, dismiss, ...\r\n *\r\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\r\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\r\n * queue line below.\r\n */\r\nconst config: NotificationConfig = {\r\n channels: {\r\n mail: mailChannel(),\r\n database: inApp.configure({ model: Notification }),\r\n },\r\n\r\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\r\n // queue: heraldQueue(),\r\n};\r\n\r\nexport default config;\r\n`;\r\n\r\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\r\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\r\nimport { v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the notifications table — mirrors the migration\r\n * columns (snake_case). Cascade validates + casts every write against it:\r\n * nullable columns use .nullish() (may be absent or null), and payload is\r\n * free-form JSON. Keep this in sync with the migration + columnMap when you\r\n * add or rename columns.\r\n */\r\nconst notificationSchema = v.object({\r\n user_id: v.string(),\r\n type: v.string(),\r\n title: v.string(),\r\n body: v.string().nullish(),\r\n payload: v.record(v.any()).nullish(),\r\n read_at: v.date().nullish(),\r\n idempotency_key: v.string().nullish(),\r\n});\r\n\r\n/**\r\n * In-app notification model.\r\n *\r\n * Extends the package's DatabaseNotification base, which provides the stable\r\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\r\n * from the columnMap below. The read/write API lives on the inApp facade\r\n * (configured in config/notifications.ts); you rarely touch this class directly.\r\n */\r\n@RegisterModel()\r\nexport class Notification extends DatabaseNotification {\r\n public static table = \"notifications\";\r\n public static schema = notificationSchema;\r\n\r\n /**\r\n * Maps the in-app store's roles to your columns. This default is\r\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\r\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\r\n * track a boolean read flag. The migration + accessors all follow this map.\r\n */\r\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\r\n}\r\n`;\r\n\r\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\r\nimport { notificationColumns } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"../notification.model\";\r\n\r\n/**\r\n * Notifications table.\r\n *\r\n * Columns come from notificationColumns(Notification) — the recipient / tenant\r\n * / read-state names follow the model's columnMap; type / title / body /\r\n * payload / idempotency_key are fixed. Spread it to add your own columns\r\n * (remember to mirror them in the model schema):\r\n *\r\n * import { uuid } from \"@warlock.js/cascade\";\r\n *\r\n * export default Migration.create(Notification, {\r\n * ...notificationColumns(Notification),\r\n * // category_id: uuid().index().nullable(),\r\n * });\r\n */\r\nexport default Migration.create(Notification, notificationColumns(Notification));\r\n`;\r\n\r\nexport const notificationControllersStub = `import { type RequestHandler } from \"@warlock.js/core\";\r\nimport { inApp } from \"@warlock.js/notifications\";\r\n\r\n/**\r\n * The authenticated user's notification HTTP surface — thin wrappers over the\r\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\r\n * rows). Notifications are produced by domain events, never over HTTP, so there\r\n * is no create. Trim or split these as your app grows.\r\n */\r\n\r\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\r\nexport const listNotificationsController: RequestHandler = async ({ request, response }) => {\r\n const { data, pagination } = await inApp.list(request.user!, request.all());\r\n\r\n return response.success({ notifications: data, pagination });\r\n};\r\n\r\nlistNotificationsController.description = \"List notifications\";\r\n\r\n/** GET /notifications/unread-count — drives the bell badge. */\r\nexport const unreadNotificationsCountController: RequestHandler = async ({\r\n request,\r\n response,\r\n}) => {\r\n const count = await inApp.countUnread(request.user!);\r\n\r\n return response.success({ count });\r\n};\r\n\r\nunreadNotificationsCountController.description = \"Unread notifications count\";\r\n\r\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\r\nexport const markNotificationReadController: RequestHandler = async ({ request, response }) => {\r\n const id = request.input(\"id\");\r\n\r\n await inApp.markAsRead(request.user!, id);\r\n const notification = await inApp.find(request.user!, id);\r\n\r\n return response.success({ notification });\r\n};\r\n\r\nmarkNotificationReadController.description = \"Mark notification read\";\r\n\r\n/** PATCH /notifications/read-all — mark every unread one read. */\r\nexport const markAllNotificationsReadController: RequestHandler = async ({\r\n request,\r\n response,\r\n}) => {\r\n const count = await inApp.markAsRead(request.user!);\r\n\r\n return response.success({ count });\r\n};\r\n\r\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\r\n\r\n/** DELETE /notifications — dismiss all for the user. */\r\nexport const clearNotificationsController: RequestHandler = async ({ request, response }) => {\r\n await inApp.dismiss(request.user!);\r\n\r\n return response.noContent();\r\n};\r\n\r\nclearNotificationsController.description = \"Clear notifications\";\r\n\r\n/** DELETE /notifications/:id — dismiss one. */\r\nexport const deleteNotificationController: RequestHandler = async ({ request, response }) => {\r\n await inApp.dismiss(request.user!, request.input(\"id\"));\r\n\r\n return response.noContent();\r\n};\r\n\r\ndeleteNotificationController.description = \"Delete notification\";\r\n`;\r\n\r\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\r\nimport { router } from \"@warlock.js/core\";\r\nimport {\r\n clearNotificationsController,\r\n deleteNotificationController,\r\n listNotificationsController,\r\n markAllNotificationsReadController,\r\n markNotificationReadController,\r\n unreadNotificationsCountController,\r\n} from \"./controllers/notifications.controller\";\r\n\r\n/**\r\n * Notification routes — the authenticated user's read + dismiss surface.\r\n *\r\n * Notifications are produced by domain events (never created over HTTP), so\r\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\r\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\r\n * don't need; if your app reads notifications over sockets/GraphQL instead,\r\n * delete this file + the controllers entirely.\r\n */\r\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\r\n router.get(\"/\", listNotificationsController);\r\n router.get(\"/unread-count\", unreadNotificationsCountController);\r\n router.patch(\"/read-all\", markAllNotificationsReadController);\r\n router.patch(\"/:id/read\", markNotificationReadController);\r\n router.delete(\"/\", clearNotificationsController);\r\n router.delete(\"/:id\", deleteNotificationController);\r\n});\r\n`;\r\n\r\n/**\r\n * `src/web/root.tsx` — the application root for the SSR page layer.\r\n *\r\n * Deliberately minimal. The framework ships a default root, so this exists to\r\n * give you a place to start rather than because anything requires it. The\r\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\r\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\r\n */\r\nexport const webRootStub = `import { Head, Scripts } from \"@warlock.js/web\";\r\nimport type { AppProps } from \"@warlock.js/web\";\r\n\r\n/**\r\n * The application root.\r\n *\r\n * NOT async, and it receives no request/response: it renders on the server and\r\n * again in the browser during hydration, where neither exists.\r\n */\r\nexport default function App({ children }: AppProps) {\r\n return (\r\n <html lang=\"en\">\r\n <head>\r\n {/*\r\n Placement only. The framework injects the page's \\`metadata\\`, the\r\n stylesheet and preload tags for this route, and the canonical links\r\n into <head> by default — <Head /> just says WHERE they land.\r\n\r\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\r\n that emits one too produces two.\r\n */}\r\n <Head />\r\n <link rel=\"icon\" href=\"data:,\" />\r\n </head>\r\n <body>\r\n {/*\r\n REQUIRED — this is the hydration mount point, not a styling wrapper.\r\n\r\n The browser runtime looks up \\`#root\\` and hydrates that element only.\r\n Remove this div, or rename the id, and the page still renders from the\r\n server but never becomes interactive: the runtime throws in the console\r\n and nothing on screen changes.\r\n\r\n Wrap it in your own markup freely, and put anything that must live\r\n outside the hydrated tree (a static footer, a portal target) outside\r\n it — just keep an element with \\`id=\"root\"\\` around {children}.\r\n */}\r\n <div id=\"root\">{children}</div>\r\n {/*\r\n The hydration payload and module tags. Written explicitly because\r\n placement occasionally matters — a CSP nonce, or ordering against\r\n your own scripts.\r\n */}\r\n <Scripts />\r\n </body>\r\n </html>\r\n );\r\n}\r\n`;\r\n\r\n/**\r\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\r\n * for the Web starter's contact form. It intentionally has no persistence\r\n * dependency: replace the acknowledgement with a mail/job/database action.\r\n */\r\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\nexport const contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\r\n\r\n/** POST /api/contact — validates the starter contact form. */\r\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({ request, response }) => {\r\n const contact = request.validated();\r\n\r\n // Replace this with delivery/persistence for your app. Keeping the accepted\r\n // payload visible makes the endpoint useful while remaining side-effect free.\r\n return response.success({\r\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\r\n });\r\n};\r\n\r\ncontactController.validation = { schema: contactSchema };\r\n`;\r\n\r\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\r\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\r\nimport { contactController } from \"./controllers/contact.controller\";\r\n\r\nrouter.post(\"/api/contact\", contactController);\r\n`;\r\n\r\n/**\r\n * `src/web/index.register.ts` — universal static setup for the starter page.\r\n *\r\n * The page re-exports this stable binding so Warlock's `register()` lifecycle\r\n * still sees it in both realms without making React Fast Refresh treat every\r\n * JSX edit as an incompatible function-export replacement.\r\n */\r\nexport const webHomeRegisterStub = `import { extend } from \"@mongez/localization\";\r\n\r\nexport function register() {\r\n extend(\"en\", {\r\n starter: {\r\n title: \"Your Warlock app is running.\",\r\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\r\n language: \"العربية\",\r\n contact: \"Send a message\",\r\n name: \"Name\",\r\n email: \"Email\",\r\n message: \"Message\",\r\n submit: \"Send message\",\r\n sent: \"Thanks — your message has been received.\",\r\n },\r\n });\r\n extend(\"ar\", {\r\n starter: {\r\n title: \"تطبيق Warlock يعمل الآن.\",\r\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\r\n language: \"English\",\r\n contact: \"أرسل رسالة\",\r\n name: \"الاسم\",\r\n email: \"البريد الإلكتروني\",\r\n message: \"الرسالة\",\r\n submit: \"إرسال الرسالة\",\r\n sent: \"شكرًا — تم استلام رسالتك.\",\r\n },\r\n });\r\n}\r\n`;\r\n\r\n/**\r\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\r\n * the moment this finishes.\r\n */\r\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\r\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\r\nimport { setCurrentLocaleCode } from \"@mongez/localization\";\r\nimport { transX } from \"@mongez/react-localization\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { useState } from \"react\";\r\nimport { Link, type PageProps } from \"@warlock.js/web\";\r\n\r\nexport { register } from \"./index.register\";\r\n\r\n/**\r\n * A page route is an ordinary Warlock route whose handler renders React\r\n * instead of returning JSON.\r\n *\r\n * The URL and stable hydration name are the ones this file DECLARES below.\r\n * This page answers \\`GET \"/\"\\` because \\`route.path = \"/\"\\`, not because of\r\n * where the file lives. A page file with\r\n * no \\`route\\` export is REFUSED by both the dev server and the build.\r\n */\r\nexport const route = { path: \"/\", name: \"index\" } as const;\r\n\r\nexport const metadata = { title: \"Home\" };\r\n\r\nconst contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\r\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor={controlProps.name}>{label}</label>\r\n <input {...getInputProps()} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n\r\n/**\r\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\r\n * \\`data\\`, typed:\r\n *\r\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\r\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\r\n */\r\nexport default function HomePage(_props: PageProps) {\r\n // Live state. If the button below does nothing, the page rendered on the\r\n // server but never hydrated — the runtime never mounted at \\`#root\\`. This is\r\n // deliberately here so that failure is impossible to miss.\r\n const [count, setCount] = useState(0);\r\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\r\n const [submitted, setSubmitted] = useState(false);\r\n const [submitError, setSubmitError] = useState<string | null>(null);\r\n\r\n const toggleLocale = () => {\r\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\r\n setCurrentLocaleCode(nextLocale);\r\n setLocale(nextLocale);\r\n };\r\n\r\n return (\r\n <>\r\n {/*\r\n Self-contained, dependency-free styling: plain CSS, system fonts, and\r\n CSS custom properties, scoped to this page. No CSS framework, no utility\r\n classes, no external stylesheet — this page looks the same whether or\r\n not \\`warlock add tailwind\\` has ever been run.\r\n */}\r\n <style>{\\`\r\n .wk-home {\r\n --wk-fg: #0f172a;\r\n --wk-muted: #64748b;\r\n --wk-accent: #4f46e5;\r\n --wk-border: #e2e8f0;\r\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\r\n color: var(--wk-fg);\r\n max-width: 42rem;\r\n margin: 4rem auto;\r\n padding: 0 1.5rem;\r\n line-height: 1.6;\r\n }\r\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\r\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\r\n .wk-home code {\r\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\r\n background: #f1f5f9;\r\n padding: 0.1rem 0.35rem;\r\n border-radius: 0.25rem;\r\n }\r\n .wk-check {\r\n border: 1px solid var(--wk-border);\r\n border-radius: 0.75rem;\r\n padding: 1.25rem 1.5rem;\r\n margin: 2rem 0;\r\n }\r\n .wk-check strong { display: block; font-size: 1.5rem; }\r\n .wk-check button {\r\n font: inherit;\r\n cursor: pointer;\r\n background: var(--wk-accent);\r\n color: #fff;\r\n border: 0;\r\n border-radius: 0.5rem;\r\n padding: 0.5rem 1rem;\r\n margin-top: 0.75rem;\r\n }\r\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\r\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\r\n .wk-links a:hover { text-decoration: underline; }\r\n .wk-language { margin-left: auto; }\r\n .wk-contact { margin-top: 2rem; }\r\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\r\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\r\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\r\n .wk-success { color: #047857; }\r\n \\`}</style>\r\n\r\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\r\n <nav className=\"wk-links\" aria-label=\"Starter links\">\r\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">Docs</a>\r\n <Link href=\"/\" aria-current=\"page\">Home</Link>\r\n <button\r\n className=\"wk-language\"\r\n type=\"button\"\r\n aria-pressed={locale === \"ar\"}\r\n onClick={toggleLocale}\r\n >\r\n {transX(\"starter.language\")}\r\n </button>\r\n </nav>\r\n\r\n <h1>{transX(\"starter.title\")}</h1>\r\n <p>{transX(\"starter.introduction\")}</p>\r\n\r\n <section className=\"wk-check\">\r\n <label>If this number goes up when you click, React is hydrated:</label>\r\n <strong>{count}</strong>\r\n <button type=\"button\" onClick={() => setCount(c => c + 1)}>\r\n Count up\r\n </button>\r\n </section>\r\n\r\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\r\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\r\n <Form<typeof contactSchema>\r\n id=\"contact-form\"\r\n schema={contactSchema}\r\n onSubmit={async ({ form, values }) => {\r\n setSubmitted(false);\r\n setSubmitError(null);\r\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\r\n\r\n if (result.error) {\r\n if (result.error.isValidationError) {\r\n const body = result.error.body as {\r\n errors?: Array<{ input: string; error: string }>;\r\n message?: string;\r\n };\r\n form.setErrors(\r\n Object.fromEntries(\r\n (body.errors ?? []).map(({ input, error }) => [input, error]),\r\n ),\r\n );\r\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\r\n } else {\r\n setSubmitError(\"Your message could not be sent. Please try again.\");\r\n }\r\n return;\r\n }\r\n\r\n setSubmitted(true);\r\n form.reset();\r\n }}\r\n >\r\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\r\n <TextInput name=\"email\" label={transX(\"starter.email\")} type=\"email\" autoComplete=\"email\" />\r\n <ContactMessage />\r\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\r\n {submitError && <p className=\"wk-submit-error\" role=\"alert\">{submitError}</p>}\r\n {submitted && <p className=\"wk-success\" role=\"status\">{transX(\"starter.sent\")}</p>}\r\n </Form>\r\n </section>\r\n </main>\r\n </>\r\n );\r\n}\r\n\r\nfunction ContactMessage() {\r\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\r\n <textarea {...getInputProps()} rows={5} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n`;\r\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0E3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;AA0BxC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnC,MAAa,kBAAkB"}
|
|
1
|
+
{"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\r\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\r\n\r\n/**\r\n * Authorization configuration — read by @warlock.js/access on boot.\r\n *\r\n * The resolver is the one required piece: it tells the engine how to read a\r\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\r\n * from the user_roles table and maps them through the roles catalog table (so\r\n * roles + their permissions are managed at runtime, in the DB).\r\n *\r\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\r\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\r\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\r\n *\r\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\r\n * tenant from the request; checks then scope to it automatically.\r\n */\r\nconst access: AccessConfigurations = {\r\n resolver: new DatabaseAccessResolver(),\r\n\r\n // Cache resolved permission sets (default \"10m\").\r\n // cache: { ttl: \"10m\" },\r\n};\r\n\r\nexport default access;\r\n`;\r\n\r\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\r\n\r\n// >>> warlock:ai-packages (auto-managed) >>>\r\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\r\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\r\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\r\n// side-effect import below; keep them so the augmentation + runtime registration\r\n// load before the ai connector applies this config.\r\n// <<< warlock:ai-packages <<<\r\n\r\n/**\r\n * AI configuration — applied on boot by the ai connector, which calls\r\n * ai.config(...) with the object below. Cross-cutting defaults live here\r\n * (shared cache / snapshot stores, observability); per-call options always win.\r\n *\r\n * Wire a default model from a provider you installed, e.g.:\r\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\r\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\r\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\r\n */\r\nconst ai: Partial<AIConfig> = {\r\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\r\n // defaultStore: cache.driver(\"redis\", { client }),\r\n\r\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\r\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\r\n};\r\n\r\nexport default ai;\r\n`;\r\n\r\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the roles catalog — mirrors the migration columns\r\n * (snake_case). Each row is a role name plus the permission strings it grants;\r\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\r\n * assigned role names through this table to their effective permissions.\r\n */\r\nexport const roleSchema = v.object({\r\n name: v.string(),\r\n permissions: v.array(v.string()).default([]),\r\n});\r\n\r\nexport type RoleSchema = Infer<typeof roleSchema>;\r\n\r\n/**\r\n * The roles catalog — role name → the permissions it grants. Managed at runtime\r\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\r\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\r\n */\r\n@RegisterModel()\r\nexport class Role extends Model<RoleSchema> {\r\n public static table = \"roles\";\r\n\r\n public static schema = roleSchema;\r\n\r\n /** The permission strings this role grants. */\r\n public get permissions(): string[] {\r\n return this.get<string[]>(\"permissions\", []);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\r\n`;\r\n\r\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\r\nimport { Role } from \"../role.model\";\r\n\r\n/**\r\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\r\n * text array of the permission strings the role grants.\r\n */\r\nexport default Migration.create(Role, {\r\n name: text().notNullable().unique(),\r\n permissions: arrayText().nullable(),\r\n});\r\n`;\r\n\r\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for a role assignment — mirrors the migration columns\r\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\r\n */\r\nexport const userRoleSchema = v.object({\r\n user_id: v.string(),\r\n user_type: v.string(),\r\n role: v.string(),\r\n tenant: v.string().optional(),\r\n});\r\n\r\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\r\n\r\n/**\r\n * The role-assignment table — which roles a user holds, optionally per tenant.\r\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\r\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\r\n * never need to call \\`access.flush(user, tenant)\\` themselves.\r\n */\r\n@RegisterModel()\r\nexport class UserRole extends Model<UserRoleSchema> {\r\n public static table = \"user_roles\";\r\n\r\n public static schema = userRoleSchema;\r\n\r\n /**\r\n * Role names assigned to the user in the given tenant.\r\n *\r\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\r\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\r\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\r\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\r\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\r\n */\r\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\r\n const rows = await this.query()\r\n .where({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n tenant: tenant ?? null,\r\n })\r\n .get();\r\n\r\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\r\n // existence check) can't distort the resolved set.\r\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\r\n }\r\n\r\n /**\r\n * Assign a role to the user. No-op if the assignment already exists.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\r\n const existing = await this.first({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n if (existing) return;\r\n\r\n await this.create({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n\r\n /**\r\n * Remove a role assignment from the user.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\r\n await this.delete({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\r\n`;\r\n\r\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\r\nimport { UserRole } from \"../user-role.model\";\r\n\r\n/**\r\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\r\n * user ids are integers. The composite index powers the per-user (per-tenant)\r\n * lookup the resolver runs on every check.\r\n */\r\nexport default Migration.create(\r\n UserRole,\r\n {\r\n user_id: uuid().notNullable().index(),\r\n user_type: text().notNullable(),\r\n role: text().notNullable().index(),\r\n tenant: text().nullable().index(),\r\n },\r\n {\r\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\r\n },\r\n);\r\n`;\r\n\r\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Role } from \"app/access/models/role\";\r\nimport { UserRole } from \"app/access/models/user-role\";\r\n\r\n/**\r\n * The app's access adapter — connects @warlock.js/access to the ejected role\r\n * tables. Roles come from the user_roles assignment table; permissions are\r\n * expanded by mapping those role names through the roles catalog table. Both\r\n * are managed at runtime (in the DB), so admins can add roles + edit their\r\n * permissions without a deploy.\r\n *\r\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\r\n * resolver only fetches — keep it dumb, never cache inside it.\r\n */\r\nexport class DatabaseAccessResolver implements AccessResolver {\r\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\r\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\r\n return UserRole.rolesFor(user, tenant);\r\n }\r\n\r\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\r\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\r\n const names = await this.resolveRoles(user, tenant);\r\n\r\n if (names.length === 0) return [];\r\n\r\n const roles = await Role.query().whereIn(\"name\", names).get();\r\n\r\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\r\n return [...new Set(roles.flatMap((role) => role.permissions))];\r\n }\r\n\r\n /**\r\n * Optional. Resolve the ambient tenant when a check doesn't pass one\r\n * explicitly — derive it from the authenticated user (safer than reading\r\n * client request input, which a caller could spoof). Uncomment + adapt for a\r\n * multi-tenant app (single-tenant apps leave this off and return undefined).\r\n */\r\n // public resolveTenant(user: Auth): string | undefined {\r\n // return user.get(\"organization_id\");\r\n // }\r\n}\r\n`;\r\n\r\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\r\n\r\n/**\r\n * Socket.IO configuration — read by the framework's socket connector\r\n * on boot. When the HTTP server is running the socket server attaches\r\n * to it; otherwise it listens on its own configured port.\r\n *\r\n * Remove this file to disable the socket server entirely.\r\n */\r\nexport default {\r\n options: {\r\n cors: {\r\n origin: \"*\",\r\n },\r\n },\r\n} as SocketOptions;\r\n`;\r\n\r\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\r\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\r\n\r\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\r\n driver: \"rabbitmq\",\r\n name: \"default\",\r\n isDefault: true,\r\n\r\n // ============================================================================\r\n // Connection Settings\r\n // ============================================================================\r\n\r\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\r\n port: env(\"RABBITMQ_PORT\", 5672),\r\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\r\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\r\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\r\n\r\n // Or use connection URI (takes precedence over host/port)\r\n // uri: env(\"RABBITMQ_URL\"),\r\n\r\n // ============================================================================\r\n // Connection Options\r\n // ============================================================================\r\n\r\n /** Heartbeat interval in seconds */\r\n heartbeat: 60,\r\n\r\n /** Connection timeout in milliseconds */\r\n connectionTimeout: 10000,\r\n\r\n /** Enable automatic reconnection on disconnect */\r\n reconnect: true,\r\n\r\n /** Delay between reconnection attempts in milliseconds */\r\n reconnectDelay: 5_000,\r\n\r\n // ============================================================================\r\n // Consumer Options\r\n // ============================================================================\r\n\r\n /** Default prefetch count (number of unacknowledged messages per consumer) */\r\n prefetch: 10,\r\n\r\n // ============================================================================\r\n // Client Options (Native amqplib options)\r\n // ============================================================================\r\n // These options are passed directly to amqplib.connect()\r\n // for low-level configuration like frame size, TLS, socket options, etc.\r\n // ============================================================================\r\n clientOptions: {\r\n // Frame max size in bytes (0 = no limit)\r\n // frameMax: 0,\r\n\r\n // Channel max (0 = unlimited)\r\n // channelMax: 0,\r\n\r\n // Socket options\r\n socket: {\r\n // Enable TCP keep-alive\r\n keepAlive: true,\r\n\r\n // Disable Nagle's algorithm for lower latency\r\n noDelay: true,\r\n\r\n // Socket timeout (in addition to heartbeat)\r\n // timeout: 30000,\r\n },\r\n\r\n // TLS/SSL options (uncomment for secure connections)\r\n // socket: {\r\n // ca: fs.readFileSync('/path/to/ca.pem'),\r\n // cert: fs.readFileSync('/path/to/cert.pem'),\r\n // key: fs.readFileSync('/path/to/key.pem'),\r\n // rejectUnauthorized: true,\r\n // },\r\n },\r\n};\r\n\r\nexport default heraldConfigurations;\r\n`;\r\n\r\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"app/notifications/notification.model\";\r\n\r\n/**\r\n * Notifications configuration. Auto-loaded from src/config on boot — the\r\n * framework's notifications connector reads this default export and hands it to\r\n * setNotificationConfig, so this file stays declarative (no side-effect call).\r\n *\r\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\r\n * and defineNotification are type-checked against the registry.\r\n *\r\n * Channels enabled here:\r\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\r\n * The \"from\" address defaults to config/mail.ts; override per\r\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\r\n * - database in-app store backed by the Notification model. The \"inApp\"\r\n * facade exposes the recipient-scoped read API: listUnread,\r\n * countUnread, markAsRead, dismiss, ...\r\n *\r\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\r\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\r\n * queue line below.\r\n */\r\nconst config: NotificationConfig = {\r\n channels: {\r\n mail: mailChannel(),\r\n database: inApp.configure({ model: Notification }),\r\n },\r\n\r\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\r\n // queue: heraldQueue(),\r\n};\r\n\r\nexport default config;\r\n`;\r\n\r\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\r\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\r\nimport { v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the notifications table — mirrors the migration\r\n * columns (snake_case). Cascade validates + casts every write against it:\r\n * nullable columns use .nullish() (may be absent or null), and payload is\r\n * free-form JSON. Keep this in sync with the migration + columnMap when you\r\n * add or rename columns.\r\n */\r\nconst notificationSchema = v.object({\r\n user_id: v.string(),\r\n type: v.string(),\r\n title: v.string(),\r\n body: v.string().nullish(),\r\n payload: v.record(v.any()).nullish(),\r\n read_at: v.date().nullish(),\r\n idempotency_key: v.string().nullish(),\r\n});\r\n\r\n/**\r\n * In-app notification model.\r\n *\r\n * Extends the package's DatabaseNotification base, which provides the stable\r\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\r\n * from the columnMap below. The read/write API lives on the inApp facade\r\n * (configured in config/notifications.ts); you rarely touch this class directly.\r\n */\r\n@RegisterModel()\r\nexport class Notification extends DatabaseNotification {\r\n public static table = \"notifications\";\r\n public static schema = notificationSchema;\r\n\r\n /**\r\n * Maps the in-app store's roles to your columns. This default is\r\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\r\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\r\n * track a boolean read flag. The migration + accessors all follow this map.\r\n */\r\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\r\n}\r\n`;\r\n\r\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\r\nimport { notificationColumns } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"../notification.model\";\r\n\r\n/**\r\n * Notifications table.\r\n *\r\n * Columns come from notificationColumns(Notification) — the recipient / tenant\r\n * / read-state names follow the model's columnMap; type / title / body /\r\n * payload / idempotency_key are fixed. Spread it to add your own columns\r\n * (remember to mirror them in the model schema):\r\n *\r\n * import { uuid } from \"@warlock.js/cascade\";\r\n *\r\n * export default Migration.create(Notification, {\r\n * ...notificationColumns(Notification),\r\n * // category_id: uuid().index().nullable(),\r\n * });\r\n */\r\nexport default Migration.create(Notification, notificationColumns(Notification));\r\n`;\r\n\r\nexport const notificationControllersStub = `import { type RequestHandler } from \"@warlock.js/core\";\r\nimport { inApp, type Id } from \"@warlock.js/notifications\";\r\n\r\n/**\r\n * The authenticated user's notification HTTP surface — thin wrappers over the\r\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\r\n * rows). Notifications are produced by domain events, never over HTTP, so there\r\n * is no create. Trim or split these as your app grows.\r\n */\r\n\r\n/**\r\n * Read \\`id\\` off \\`request.user\\` without assuming this app's \\`RequestUser\\`\r\n * augmentation declares it — \\`RequestUser\\` is empty by default (see\r\n * \\`@warlock.js/core\\`'s \\`RequestUser\\` docs), so a narrow runtime read survives\r\n * any augmentation shape instead of assuming \\`.id\\` exists at the type level.\r\n * \\`inApp\\` only ever needs the id (it reduces a \\`Notifiable\\` to one via\r\n * \\`recipient.id\\` internally), so reading it here — rather than forwarding\r\n * \\`request.user\\` itself — also skips a needless \\`Notifiable\\` cast.\r\n */\r\nfunction recipientId(user: unknown): Id {\r\n if (user && typeof user === \"object\" && \"id\" in user) {\r\n const id = (user as { id?: unknown }).id;\r\n\r\n if (typeof id === \"string\" || typeof id === \"number\") return id;\r\n }\r\n\r\n throw new Error(\"Authenticated request is missing a usable user id\");\r\n}\r\n\r\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\r\nexport const listNotificationsController: RequestHandler = async ({ request, response }) => {\r\n const { data, pagination } = await inApp.list(recipientId(request.user), request.all());\r\n\r\n return response.success({ notifications: data, pagination });\r\n};\r\n\r\nlistNotificationsController.description = \"List notifications\";\r\n\r\n/** GET /notifications/unread-count — drives the bell badge. */\r\nexport const unreadNotificationsCountController: RequestHandler = async ({\r\n request,\r\n response,\r\n}) => {\r\n const count = await inApp.countUnread(recipientId(request.user));\r\n\r\n return response.success({ count });\r\n};\r\n\r\nunreadNotificationsCountController.description = \"Unread notifications count\";\r\n\r\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\r\nexport const markNotificationReadController: RequestHandler = async ({ request, response }) => {\r\n const id = request.input(\"id\");\r\n const userId = recipientId(request.user);\r\n\r\n await inApp.markAsRead(userId, id);\r\n const notification = await inApp.find(userId, id);\r\n\r\n return response.success({ notification });\r\n};\r\n\r\nmarkNotificationReadController.description = \"Mark notification read\";\r\n\r\n/** PATCH /notifications/read-all — mark every unread one read. */\r\nexport const markAllNotificationsReadController: RequestHandler = async ({\r\n request,\r\n response,\r\n}) => {\r\n const count = await inApp.markAsRead(recipientId(request.user));\r\n\r\n return response.success({ count });\r\n};\r\n\r\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\r\n\r\n/** DELETE /notifications — dismiss all for the user. */\r\nexport const clearNotificationsController: RequestHandler = async ({ request, response }) => {\r\n await inApp.dismiss(recipientId(request.user));\r\n\r\n return response.noContent();\r\n};\r\n\r\nclearNotificationsController.description = \"Clear notifications\";\r\n\r\n/** DELETE /notifications/:id — dismiss one. */\r\nexport const deleteNotificationController: RequestHandler = async ({ request, response }) => {\r\n await inApp.dismiss(recipientId(request.user), request.input(\"id\"));\r\n\r\n return response.noContent();\r\n};\r\n\r\ndeleteNotificationController.description = \"Delete notification\";\r\n`;\r\n\r\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\r\nimport { router } from \"@warlock.js/core\";\r\nimport {\r\n clearNotificationsController,\r\n deleteNotificationController,\r\n listNotificationsController,\r\n markAllNotificationsReadController,\r\n markNotificationReadController,\r\n unreadNotificationsCountController,\r\n} from \"./controllers/notifications.controller\";\r\n\r\n/**\r\n * Notification routes — the authenticated user's read + dismiss surface.\r\n *\r\n * Notifications are produced by domain events (never created over HTTP), so\r\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\r\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\r\n * don't need; if your app reads notifications over sockets/GraphQL instead,\r\n * delete this file + the controllers entirely.\r\n */\r\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\r\n router.get(\"/\", listNotificationsController);\r\n router.get(\"/unread-count\", unreadNotificationsCountController);\r\n router.patch(\"/read-all\", markAllNotificationsReadController);\r\n router.patch(\"/:id/read\", markNotificationReadController);\r\n router.delete(\"/\", clearNotificationsController);\r\n router.delete(\"/:id\", deleteNotificationController);\r\n});\r\n`;\r\n\r\n/**\r\n * `src/web/root.tsx` — the application root for the SSR page layer.\r\n *\r\n * Deliberately minimal. The framework ships a default root, so this exists to\r\n * give you a place to start rather than because anything requires it. The\r\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\r\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\r\n */\r\nexport const webRootStub = `import type { AppProps } from \"@warlock.js/web\";\r\nimport { Head, Scripts } from \"@warlock.js/web\";\r\n\r\n/**\r\n * The application root.\r\n *\r\n * NOT async, and it receives no request/response: it renders on the server and\r\n * again in the browser during hydration, where neither exists.\r\n */\r\nexport default function App({ children }: AppProps) {\r\n return (\r\n <html lang=\"en\">\r\n <head>\r\n {/*\r\n Placement only. The framework injects the page's \\`metadata\\`, the\r\n stylesheet and preload tags for this route, and the canonical links\r\n into <head> by default — <Head /> just says WHERE they land.\r\n\r\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\r\n that emits one too produces two.\r\n */}\r\n <Head />\r\n <link rel=\"icon\" href=\"data:,\" />\r\n </head>\r\n <body>\r\n {/*\r\n REQUIRED — this is the hydration mount point, not a styling wrapper.\r\n\r\n The browser runtime looks up \\`#root\\` and hydrates that element only.\r\n Remove this div, or rename the id, and the page still renders from the\r\n server but never becomes interactive: the runtime throws in the console\r\n and nothing on screen changes.\r\n\r\n Wrap it in your own markup freely, and put anything that must live\r\n outside the hydrated tree (a static footer, a portal target) outside\r\n it — just keep an element with \\`id=\"root\"\\` around {children}.\r\n */}\r\n <div id=\"root\">{children}</div>\r\n {/*\r\n The hydration payload and module tags. Written explicitly because\r\n placement occasionally matters — a CSP nonce, or ordering against\r\n your own scripts.\r\n */}\r\n <Scripts />\r\n </body>\r\n </html>\r\n );\r\n}\r\n`;\r\n\r\n/**\r\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\r\n * for the Web starter's contact form. It intentionally has no persistence\r\n * dependency: replace the acknowledgement with a mail/job/database action.\r\n */\r\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\nexport const contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\r\n\r\n/** POST /api/contact — validates the starter contact form. */\r\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({\r\n request,\r\n response,\r\n}) => {\r\n const contact = request.validated();\r\n\r\n // Replace this with delivery/persistence for your app. Keeping the accepted\r\n // payload visible makes the endpoint useful while remaining side-effect free.\r\n return response.success({\r\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\r\n });\r\n};\r\n\r\ncontactController.validation = { schema: contactSchema };\r\n`;\r\n\r\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\r\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\r\nimport { contactController } from \"./controllers/contact.controller\";\r\n\r\nrouter.post(\"/api/contact\", contactController);\r\n`;\r\n\r\n/**\r\n * `src/web/index.register.ts` — universal static setup for the starter page.\r\n *\r\n * The page re-exports this stable binding so Warlock's `register()` lifecycle\r\n * still sees it in both realms without making React Fast Refresh treat every\r\n * JSX edit as an incompatible function-export replacement.\r\n */\r\nexport const webHomeRegisterStub = `import { extend } from \"@mongez/localization\";\r\n\r\nexport function register() {\r\n extend(\"en\", {\r\n starter: {\r\n title: \"Your Warlock app is running.\",\r\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\r\n language: \"العربية\",\r\n contact: \"Send a message\",\r\n name: \"Name\",\r\n email: \"Email\",\r\n message: \"Message\",\r\n submit: \"Send message\",\r\n sent: \"Thanks — your message has been received.\",\r\n },\r\n });\r\n extend(\"ar\", {\r\n starter: {\r\n title: \"تطبيق Warlock يعمل الآن.\",\r\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\r\n language: \"English\",\r\n contact: \"أرسل رسالة\",\r\n name: \"الاسم\",\r\n email: \"البريد الإلكتروني\",\r\n message: \"الرسالة\",\r\n submit: \"إرسال الرسالة\",\r\n sent: \"شكرًا — تم استلام رسالتك.\",\r\n },\r\n });\r\n}\r\n`;\r\n\r\n/**\r\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\r\n * the moment this finishes.\r\n */\r\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\r\nimport { setCurrentLocaleCode } from \"@mongez/localization\";\r\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\r\nimport { transX } from \"@mongez/react-localization\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { Link, type PageProps } from \"@warlock.js/web\";\r\nimport { useState } from \"react\";\r\n\r\nexport { register } from \"./index.register\";\r\n\r\n/**\r\n * A page route is an ordinary Warlock route whose handler renders React\r\n * instead of returning JSON.\r\n *\r\n * The URL and stable hydration name are the ones this file DECLARES below.\r\n * This page answers \\`GET \"/\"\\` because \\`route.path = \"/\"\\`, not because of\r\n * where the file lives. A page file with\r\n * no \\`route\\` export is REFUSED by both the dev server and the build.\r\n */\r\nexport const route = { path: \"/\", name: \"index\" } as const;\r\n\r\nexport const metadata = { title: \"Home\" };\r\n\r\nconst contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\r\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor={controlProps.name}>{label}</label>\r\n <input {...getInputProps()} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n\r\n/**\r\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\r\n * \\`data\\`, typed:\r\n *\r\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\r\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\r\n */\r\nexport default function HomePage(_props: PageProps) {\r\n // Live state. If the button below does nothing, the page rendered on the\r\n // server but never hydrated — the runtime never mounted at \\`#root\\`. This is\r\n // deliberately here so that failure is impossible to miss.\r\n const [count, setCount] = useState(0);\r\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\r\n const [submitted, setSubmitted] = useState(false);\r\n const [submitError, setSubmitError] = useState<string | null>(null);\r\n\r\n const toggleLocale = () => {\r\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\r\n setCurrentLocaleCode(nextLocale);\r\n setLocale(nextLocale);\r\n };\r\n\r\n return (\r\n <>\r\n {/*\r\n Self-contained, dependency-free styling: plain CSS, system fonts, and\r\n CSS custom properties, scoped to this page. No CSS framework, no utility\r\n classes, no external stylesheet — this page looks the same whether or\r\n not \\`warlock add tailwind\\` has ever been run.\r\n */}\r\n <style>{\\`\r\n .wk-home {\r\n --wk-fg: #0f172a;\r\n --wk-muted: #64748b;\r\n --wk-accent: #4f46e5;\r\n --wk-border: #e2e8f0;\r\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\r\n color: var(--wk-fg);\r\n max-width: 42rem;\r\n margin: 4rem auto;\r\n padding: 0 1.5rem;\r\n line-height: 1.6;\r\n }\r\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\r\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\r\n .wk-home code {\r\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\r\n background: #f1f5f9;\r\n padding: 0.1rem 0.35rem;\r\n border-radius: 0.25rem;\r\n }\r\n .wk-check {\r\n border: 1px solid var(--wk-border);\r\n border-radius: 0.75rem;\r\n padding: 1.25rem 1.5rem;\r\n margin: 2rem 0;\r\n }\r\n .wk-check strong { display: block; font-size: 1.5rem; }\r\n .wk-check button {\r\n font: inherit;\r\n cursor: pointer;\r\n background: var(--wk-accent);\r\n color: #fff;\r\n border: 0;\r\n border-radius: 0.5rem;\r\n padding: 0.5rem 1rem;\r\n margin-top: 0.75rem;\r\n }\r\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\r\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\r\n .wk-links a:hover { text-decoration: underline; }\r\n .wk-language { margin-left: auto; }\r\n .wk-contact { margin-top: 2rem; }\r\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\r\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\r\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\r\n .wk-success { color: #047857; }\r\n \\`}</style>\r\n\r\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\r\n <nav className=\"wk-links\" aria-label=\"Starter links\">\r\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">\r\n Docs\r\n </a>\r\n <Link href=\"/\" aria-current=\"page\">\r\n Home\r\n </Link>\r\n <button\r\n className=\"wk-language\"\r\n type=\"button\"\r\n aria-pressed={locale === \"ar\"}\r\n onClick={toggleLocale}\r\n >\r\n {transX(\"starter.language\")}\r\n </button>\r\n </nav>\r\n\r\n <h1>{transX(\"starter.title\")}</h1>\r\n <p>{transX(\"starter.introduction\")}</p>\r\n\r\n <section className=\"wk-check\">\r\n <label>If this number goes up when you click, React is hydrated:</label>\r\n <strong>{count}</strong>\r\n <button type=\"button\" onClick={() => setCount((c) => c + 1)}>\r\n Count up\r\n </button>\r\n </section>\r\n\r\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\r\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\r\n <Form<typeof contactSchema>\r\n id=\"contact-form\"\r\n schema={contactSchema}\r\n onSubmit={async ({ form, values }) => {\r\n setSubmitted(false);\r\n setSubmitError(null);\r\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\r\n\r\n if (result.error) {\r\n if (result.error.isValidationError) {\r\n const body = result.error.body as {\r\n errors?: Array<{ input: string; error: string }>;\r\n message?: string;\r\n };\r\n form.setErrors(\r\n Object.fromEntries(\r\n (body.errors ?? []).map(({ input, error }) => [input, error]),\r\n ),\r\n );\r\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\r\n } else {\r\n setSubmitError(\"Your message could not be sent. Please try again.\");\r\n }\r\n return;\r\n }\r\n\r\n setSubmitted(true);\r\n form.reset();\r\n }}\r\n >\r\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\r\n <TextInput\r\n name=\"email\"\r\n label={transX(\"starter.email\")}\r\n type=\"email\"\r\n autoComplete=\"email\"\r\n />\r\n <ContactMessage />\r\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\r\n {submitError && (\r\n <p className=\"wk-submit-error\" role=\"alert\">\r\n {submitError}\r\n </p>\r\n )}\r\n {submitted && (\r\n <p className=\"wk-success\" role=\"status\">\r\n {transX(\"starter.sent\")}\r\n </p>\r\n )}\r\n </Form>\r\n </section>\r\n </main>\r\n </>\r\n );\r\n}\r\n\r\nfunction ContactMessage() {\r\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\r\n <textarea {...getInputProps()} rows={5} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n`;\r\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8F3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BxC,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnC,MAAa,kBAAkB"}
|
package/package.json
CHANGED
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
"@mongez/slug": "^1.0.7",
|
|
26
26
|
"@mongez/supportive-is": "^2.1.4",
|
|
27
27
|
"@mongez/time-wizard": "^1.0.6",
|
|
28
|
-
"@warlock.js/auth": "5.
|
|
29
|
-
"@warlock.js/cache": "5.
|
|
30
|
-
"@warlock.js/cascade": "5.
|
|
31
|
-
"@warlock.js/context": "5.
|
|
32
|
-
"@warlock.js/logger": "5.
|
|
33
|
-
"@warlock.js/seal": "5.
|
|
34
|
-
"@warlock.js/fs": "5.
|
|
28
|
+
"@warlock.js/auth": "5.4.0",
|
|
29
|
+
"@warlock.js/cache": "5.4.0",
|
|
30
|
+
"@warlock.js/cascade": "5.4.0",
|
|
31
|
+
"@warlock.js/context": "5.4.0",
|
|
32
|
+
"@warlock.js/logger": "5.4.0",
|
|
33
|
+
"@warlock.js/seal": "5.4.0",
|
|
34
|
+
"@warlock.js/fs": "5.4.0",
|
|
35
35
|
"chokidar": "^5.0.0",
|
|
36
36
|
"dayjs": "^1.11.19",
|
|
37
37
|
"es-module-lexer": "^2.0.0",
|
|
@@ -57,10 +57,10 @@
|
|
|
57
57
|
"react": "^19.2.3",
|
|
58
58
|
"react-dom": "^19.2.3",
|
|
59
59
|
"@react-email/render": "^2.0.5",
|
|
60
|
-
"@warlock.js/herald": "5.
|
|
61
|
-
"@warlock.js/ai": "5.
|
|
62
|
-
"@warlock.js/access": "5.
|
|
63
|
-
"@warlock.js/notifications": "5.
|
|
60
|
+
"@warlock.js/herald": "5.4.0",
|
|
61
|
+
"@warlock.js/ai": "5.4.0",
|
|
62
|
+
"@warlock.js/access": "5.4.0",
|
|
63
|
+
"@warlock.js/notifications": "5.4.0"
|
|
64
64
|
},
|
|
65
65
|
"peerDependenciesMeta": {
|
|
66
66
|
"sharp": {
|
|
@@ -123,7 +123,7 @@
|
|
|
123
123
|
],
|
|
124
124
|
"author": "hassanzohdy",
|
|
125
125
|
"license": "MIT",
|
|
126
|
-
"version": "5.
|
|
126
|
+
"version": "5.4.0",
|
|
127
127
|
"type": "module",
|
|
128
128
|
"main": "./esm/index.mjs",
|
|
129
129
|
"module": "./esm/index.mjs",
|