@warlock.js/core 5.3.0 → 5.3.2

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.
Files changed (73) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/esm/cli/cli-commands.manager.mjs +2 -0
  3. package/esm/cli/cli-commands.manager.mjs.map +1 -1
  4. package/esm/cli/cli-commands.utils.mjs.map +1 -1
  5. package/esm/connectors/http-connector.d.mts.map +1 -1
  6. package/esm/connectors/http-connector.mjs +26 -4
  7. package/esm/connectors/http-connector.mjs.map +1 -1
  8. package/esm/database/create-database-action.mjs.map +1 -1
  9. package/esm/database/drop-tables-action.mjs.map +1 -1
  10. package/esm/database/migrate-action.mjs.map +1 -1
  11. package/esm/database/seed-command-action.mjs.map +1 -1
  12. package/esm/database/seeds/seeders.manager.mjs.map +1 -1
  13. package/esm/dev-server/boot-precondition-error.mjs +35 -0
  14. package/esm/dev-server/boot-precondition-error.mjs.map +1 -0
  15. package/esm/dev-server/development-server.mjs +6 -1
  16. package/esm/dev-server/development-server.mjs.map +1 -1
  17. package/esm/dev-server/files-orchestrator.mjs.map +1 -1
  18. package/esm/dev-server/health-checker/checkers/eslint-health-checker.mjs.map +1 -1
  19. package/esm/dev-server/health-checker/checkers/typescript-health-checker.mjs.map +1 -1
  20. package/esm/dev-server/start-development-server.mjs +3 -1
  21. package/esm/dev-server/start-development-server.mjs.map +1 -1
  22. package/esm/dev-server/supervisor.mjs +15 -0
  23. package/esm/dev-server/supervisor.mjs.map +1 -1
  24. package/esm/generations/add-command.action.mjs.map +1 -1
  25. package/esm/generations/features/access.feature.mjs.map +1 -1
  26. package/esm/generations/features/ai-anthropic.feature.mjs.map +1 -1
  27. package/esm/generations/features/ai-bedrock.feature.mjs.map +1 -1
  28. package/esm/generations/features/ai-google.feature.mjs.map +1 -1
  29. package/esm/generations/features/ai-ollama.feature.mjs.map +1 -1
  30. package/esm/generations/features/ai-openai.feature.mjs.map +1 -1
  31. package/esm/generations/features/ai-panoptic.feature.mjs.map +1 -1
  32. package/esm/generations/features/ai-tools.feature.mjs.map +1 -1
  33. package/esm/generations/features/ai-workspace.feature.mjs.map +1 -1
  34. package/esm/generations/features/ai.feature.mjs.map +1 -1
  35. package/esm/generations/features/herald.feature.mjs.map +1 -1
  36. package/esm/generations/features/image.feature.mjs.map +1 -1
  37. package/esm/generations/features/index.mjs.map +1 -1
  38. package/esm/generations/features/mail.feature.mjs.map +1 -1
  39. package/esm/generations/features/mongodb.feature.mjs.map +1 -1
  40. package/esm/generations/features/mysql.feature.mjs.map +1 -1
  41. package/esm/generations/features/notifications.feature.mjs.map +1 -1
  42. package/esm/generations/features/postgres.feature.mjs.map +1 -1
  43. package/esm/generations/features/react-email.feature.mjs.map +1 -1
  44. package/esm/generations/features/react.feature.mjs.map +1 -1
  45. package/esm/generations/features/redis.feature.mjs.map +1 -1
  46. package/esm/generations/features/s3.feature.mjs.map +1 -1
  47. package/esm/generations/features/scheduler.feature.mjs.map +1 -1
  48. package/esm/generations/features/ses.feature.mjs.map +1 -1
  49. package/esm/generations/features/shadcn.feature.mjs.map +1 -1
  50. package/esm/generations/features/socket.feature.mjs.map +1 -1
  51. package/esm/generations/features/tailwind.feature.mjs.map +1 -1
  52. package/esm/generations/features/test.feature.mjs.map +1 -1
  53. package/esm/generations/features/types.mjs.map +1 -1
  54. package/esm/generations/features/web.feature.mjs.map +1 -1
  55. package/esm/http/boot-port-preflight.d.mts.map +1 -1
  56. package/esm/http/boot-port-preflight.mjs +17 -3
  57. package/esm/http/boot-port-preflight.mjs.map +1 -1
  58. package/esm/http/resolve-bind-port.mjs +51 -0
  59. package/esm/http/resolve-bind-port.mjs.map +1 -0
  60. package/esm/tests/test-helpers.d.mts.map +1 -1
  61. package/esm/tests/test-helpers.mjs +4 -1
  62. package/esm/tests/test-helpers.mjs.map +1 -1
  63. package/esm/utils/detect-environment-overrides.mjs +67 -0
  64. package/esm/utils/detect-environment-overrides.mjs.map +1 -0
  65. package/esm/utils/load-environment.mjs +24 -0
  66. package/esm/utils/load-environment.mjs.map +1 -1
  67. package/esm/utils/report-environment-overrides.mjs +30 -0
  68. package/esm/utils/report-environment-overrides.mjs.map +1 -0
  69. package/esm/validation/types.d.mts +4 -1
  70. package/esm/validation/types.d.mts.map +1 -1
  71. package/llms-full.txt +10 -2
  72. package/package.json +12 -12
  73. package/skills/run-app/SKILL.md +10 -2
@@ -1 +1 @@
1
- {"version":3,"file":"tailwind.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/tailwind.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport {\n ensureDirectoryAsync,\n fileExistsAsync,\n getFileAsync,\n putFileAsync,\n} from \"@warlock.js/fs\";\nimport { CommandActionData } from \"../../commands/types\";\nimport { rootPath, srcPath } from \"../../utils\";\nimport { FeatureDefinition } from \"./types\";\n\n/**\n * The stylesheet entry, and the whole of Tailwind's configuration.\n *\n * Tailwind v4 is CSS-first: there is no `tailwind.config.js` and no `content`\n * globs to keep in sync with the project layout — the engine discovers the\n * templates that reach it through the bundler graph, and everything a v3 config\n * held (`theme`, `plugins`, `darkMode`) is expressed in CSS beside the import.\n * So this one file is the config, which is why the feature writes it rather\n * than an `ejectConfig` entry: `ejectConfig` lands in `src/config/*.ts`, and a\n * stylesheet that must sit in the bundler's path does not belong there.\n */\nconst appCssStub = `@import \"tailwindcss\";\n\n/*\n Tailwind v4 configures itself from CSS — there is no tailwind.config.js.\n\n Design tokens go in an @theme block, and each one becomes both a CSS variable\n and a utility class:\n\n @theme {\n --color-brand: oklch(0.62 0.19 259);\n --font-display: \"Inter\", sans-serif;\n }\n\n gives you \\`bg-brand\\`, \\`text-brand\\`, \\`font-display\\` and \\`var(--color-brand)\\`.\n\n Plugins are imported here too — \\`@plugin \"@tailwindcss/typography\";\\` — and\n your own non-utility CSS can simply follow this comment.\n*/\n`;\n\n/**\n * PostCSS rather than a Vite config file, and deliberately at the project root.\n *\n * Vite discovers `postcss.config.mjs` from the project root on its own, in the\n * dev server and in `vite build` alike, without the application owning a Vite\n * config at all. That matters here: the only app-facing Vite plugin array in\n * this framework is `webConnector({ plugins })`, which is wired into the DEV\n * server exclusively — the client build composes its own plugin list from its\n * caller. Registering `@tailwindcss/vite` there would produce styles in `dev`\n * and silently drop them from a production build, which is a worse failure than\n * any config file, because it only shows up after deploy.\n *\n * `.mjs` because a Warlock project's `package.json` is not guaranteed to set\n * `\"type\": \"module\"`, and `export default` in a `.js` file would throw in a\n * CommonJS project.\n */\nconst postcssConfigStub = `/**\n * Tailwind v4 runs as a PostCSS plugin. Vite loads this file automatically —\n * in dev and in build — so no Vite configuration is required.\n *\n * The plugin lives in its own package in v4: \\`tailwindcss\\` is the engine,\n * \\`@tailwindcss/postcss\\` is the adapter. Naming \\`tailwindcss\\` here directly is\n * the v3 spelling and will not work.\n */\nexport default {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n};\n`;\n\n/**\n * Every filename Vite/PostCSS will pick up from the project root.\n *\n * Checked as a set rather than just writing our own: two PostCSS configs in one\n * directory is not a merge, it is a coin toss over which one loads, so a project\n * that already has one gets instructions instead of a second file.\n */\nconst POSTCSS_CONFIG_FILES = [\n \"postcss.config.mjs\",\n \"postcss.config.js\",\n \"postcss.config.cjs\",\n \"postcss.config.ts\",\n \"postcss.config.json\",\n \".postcssrc\",\n \".postcssrc.js\",\n \".postcssrc.json\",\n];\n\n/** The side-effect import that pulls the stylesheet into the bundler graph. */\nconst APP_CSS_IMPORT = 'import \"./app.css\";';\n\n/**\n * Create `src/web/app.css` — the sentinel for \"this feature already ran\".\n *\n * Nothing in the project template creates this file (the scaffold ships a\n * static `public/home.css` instead), so its presence means `add tailwind` has\n * been here, and its contents may since have been edited into a real design\n * system. It is never rewritten.\n */\nasync function createStylesheet(): Promise<boolean> {\n const cssFile = srcPath(\"web/app.css\");\n\n if (await fileExistsAsync(cssFile)) {\n console.log(`${colors.yellowBright(\"src/web/app.css\")} already exists, skipping...`);\n\n return false;\n }\n\n await ensureDirectoryAsync(srcPath(\"web\"));\n await putFileAsync(cssFile, appCssStub);\n console.log(`${colors.green(\"✓\")} Created src/web/app.css`);\n\n return true;\n}\n\n/**\n * Write `postcss.config.mjs`, unless the project already has a PostCSS config.\n *\n * An existing config is left completely alone. It is an app-owned build file\n * that may already register autoprefixer, nesting, or a CSS-modules pass, and\n * appending Tailwind to it by string surgery would be guessing at both its\n * module format and its plugin order.\n */\nasync function writePostcssConfig(): Promise<void> {\n for (const name of POSTCSS_CONFIG_FILES) {\n if (!(await fileExistsAsync(rootPath(name)))) continue;\n\n const current = await getFileAsync(rootPath(name)).catch(() => \"\");\n\n if (current.includes(\"@tailwindcss/postcss\")) {\n console.log(`${colors.yellowBright(name)} already registers Tailwind, skipping...`);\n } else {\n console.log(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(name)} already exists — add Tailwind to it yourself:\\n` +\n ` plugins: { \"@tailwindcss/postcss\": {} }\\n` +\n \" Two PostCSS configs in one project is undefined behaviour, so this feature did not write a second one.\",\n );\n }\n\n return;\n }\n\n await putFileAsync(rootPath(\"postcss.config.mjs\"), postcssConfigStub);\n console.log(`${colors.green(\"✓\")} Created postcss.config.mjs`);\n}\n\n/**\n * Import the stylesheet from `src/web/root.tsx`.\n *\n * The root is the one module every SSR page renders through, so importing the\n * stylesheet there is what puts it in the client bundle for every route.\n *\n * `root.tsx` is guaranteed to exist by the time this runs: `requires: [\"web\"]`\n * makes the add command resolve `web` ahead of `tailwind` and run its\n * `onExecuting` first, and that is what creates the file. The check below is\n * therefore for a root a human has since moved or deleted — a case worth a\n * printed instruction, not a failure.\n */\nasync function importStylesheetFromRoot(): Promise<void> {\n const rootFile = srcPath(\"web/root.tsx\");\n\n if (!(await fileExistsAsync(rootFile))) {\n console.log(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(\"src/web/root.tsx\")} not found — ` +\n `add ${colors.yellowBright(APP_CSS_IMPORT)} to your application root yourself.\\n` +\n \" Until the stylesheet is imported from a module the bundler reaches, no Tailwind CSS is emitted.\",\n );\n\n return;\n }\n\n const current = await getFileAsync(rootFile);\n\n // Matches the import whichever quote style and specifier the file uses, so a\n // re-run against a hand-edited root does not stack a second copy.\n if (/import\\s+[\"'].*app\\.css[\"']/.test(current)) {\n console.log(`${colors.yellowBright(\"src/web/root.tsx\")} already imports app.css, skipping...`);\n\n return;\n }\n\n // FIRST line, above the framework imports. A side-effect CSS import has no\n // binding to order against, and putting it at the top keeps it from being\n // swept away by an import sorter that only ranks module specifiers.\n await putFileAsync(rootFile, `${APP_CSS_IMPORT}\\n${current}`);\n console.log(`${colors.green(\"✓\")} Imported app.css in src/web/root.tsx`);\n}\n\n/**\n * Wire Tailwind v4 into the page layer.\n *\n * Three files, none of which needs `node_modules` to be populated: on the\n * `create-warlock` path this runs under `--no-install`, so the dependencies\n * declared below are only recorded in `package.json` and nothing here may\n * import, resolve, or execute Tailwind itself.\n */\nasync function completeTailwindInstallation(_options: CommandActionData) {\n await createStylesheet();\n await writePostcssConfig();\n await importStylesheetFromRoot();\n}\n\nexport const tailwindFeature: FeatureDefinition = {\n description:\n \"Installs Tailwind CSS v4 for the SSR page layer. Creates src/web/app.css (the CSS-first config — v4 has no tailwind.config.js), imports it from src/web/root.tsx, and registers the engine through postcss.config.mjs, which Vite loads in dev and build alike.\",\n // `web` owns root.tsx and the CSS pipeline that serves this stylesheet.\n // Requiring it also fixes the order: the add command resolves requirements\n // depth-first, so `web` scaffolds src/web before this feature writes into it.\n requires: [\"web\"],\n devDependencies: {\n // Build-time only, both of them — Tailwind compiles to a plain stylesheet\n // and nothing it ships is imported at runtime.\n tailwindcss: \"^4.1.16\",\n // The engine and its PostCSS adapter are separate packages in v4 and are\n // released in lockstep; keep these two ranges identical.\n \"@tailwindcss/postcss\": \"^4.1.16\",\n },\n onExecuting: completeTailwindInstallation,\n};\n"],"mappings":";;;;;;;;;;;;;;;;;AAsBA,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnB,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;AAsB1B,MAAM,uBAAuB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAM,iBAAiB;;;;;;;;;AAUvB,eAAe,mBAAqC;CAClD,MAAM,UAAU,QAAQ,aAAa;CAErC,IAAI,MAAM,gBAAgB,OAAO,GAAG;EAClC,QAAQ,IAAI,GAAG,OAAO,aAAa,iBAAiB,EAAE,6BAA6B;EAEnF,OAAO;CACT;CAEA,MAAM,qBAAqB,QAAQ,KAAK,CAAC;CACzC,MAAM,aAAa,SAAS,UAAU;CACtC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,yBAAyB;CAE1D,OAAO;AACT;;;;;;;;;AAUA,eAAe,qBAAoC;CACjD,KAAK,MAAM,QAAQ,sBAAsB;EACvC,IAAI,CAAE,MAAM,gBAAgB,SAAS,IAAI,CAAC,GAAI;EAI9C,KAAI,MAFkB,aAAa,SAAS,IAAI,CAAC,CAAC,CAAC,YAAY,EAAE,EAEtD,CAAC,SAAS,sBAAsB,GACzC,QAAQ,IAAI,GAAG,OAAO,aAAa,IAAI,EAAE,yCAAyC;OAElF,QAAQ,IACN,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,IAAI,EAAE,oMAG3D;EAGF;CACF;CAEA,MAAM,aAAa,SAAS,oBAAoB,GAAG,iBAAiB;CACpE,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,4BAA4B;AAC/D;;;;;;;;;;;;;AAcA,eAAe,2BAA0C;CACvD,MAAM,WAAW,QAAQ,cAAc;CAEvC,IAAI,CAAE,MAAM,gBAAgB,QAAQ,GAAI;EACtC,QAAQ,IACN,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,kBAAkB,EAAE,mBAC9D,OAAO,aAAa,cAAc,EAAE,uIAE/C;EAEA;CACF;CAEA,MAAM,UAAU,MAAM,aAAa,QAAQ;CAI3C,IAAI,8BAA8B,KAAK,OAAO,GAAG;EAC/C,QAAQ,IAAI,GAAG,OAAO,aAAa,kBAAkB,EAAE,sCAAsC;EAE7F;CACF;CAKA,MAAM,aAAa,UAAU,GAAG,eAAe,IAAI,SAAS;CAC5D,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,sCAAsC;AACzE;;;;;;;;;AAUA,eAAe,6BAA6B,UAA6B;CACvE,MAAM,iBAAiB;CACvB,MAAM,mBAAmB;CACzB,MAAM,yBAAyB;AACjC;AAEA,MAAa,kBAAqC;CAChD,aACE;CAIF,UAAU,CAAC,KAAK;CAChB,iBAAiB;EAGf,aAAa;EAGb,wBAAwB;CAC1B;CACA,aAAa;AACf"}
1
+ {"version":3,"file":"tailwind.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/tailwind.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport {\n ensureDirectoryAsync,\n fileExistsAsync,\n getFileAsync,\n putFileAsync,\n} from \"@warlock.js/fs\";\nimport type { CommandActionData } from \"../../commands/types\";\nimport { rootPath, srcPath } from \"../../utils\";\nimport type { FeatureDefinition } from \"./types\";\n\n/**\n * The stylesheet entry, and the whole of Tailwind's configuration.\n *\n * Tailwind v4 is CSS-first: there is no `tailwind.config.js` and no `content`\n * globs to keep in sync with the project layout — the engine discovers the\n * templates that reach it through the bundler graph, and everything a v3 config\n * held (`theme`, `plugins`, `darkMode`) is expressed in CSS beside the import.\n * So this one file is the config, which is why the feature writes it rather\n * than an `ejectConfig` entry: `ejectConfig` lands in `src/config/*.ts`, and a\n * stylesheet that must sit in the bundler's path does not belong there.\n */\nconst appCssStub = `@import \"tailwindcss\";\n\n/*\n Tailwind v4 configures itself from CSS — there is no tailwind.config.js.\n\n Design tokens go in an @theme block, and each one becomes both a CSS variable\n and a utility class:\n\n @theme {\n --color-brand: oklch(0.62 0.19 259);\n --font-display: \"Inter\", sans-serif;\n }\n\n gives you \\`bg-brand\\`, \\`text-brand\\`, \\`font-display\\` and \\`var(--color-brand)\\`.\n\n Plugins are imported here too — \\`@plugin \"@tailwindcss/typography\";\\` — and\n your own non-utility CSS can simply follow this comment.\n*/\n`;\n\n/**\n * PostCSS rather than a Vite config file, and deliberately at the project root.\n *\n * Vite discovers `postcss.config.mjs` from the project root on its own, in the\n * dev server and in `vite build` alike, without the application owning a Vite\n * config at all. That matters here: the only app-facing Vite plugin array in\n * this framework is `webConnector({ plugins })`, which is wired into the DEV\n * server exclusively — the client build composes its own plugin list from its\n * caller. Registering `@tailwindcss/vite` there would produce styles in `dev`\n * and silently drop them from a production build, which is a worse failure than\n * any config file, because it only shows up after deploy.\n *\n * `.mjs` because a Warlock project's `package.json` is not guaranteed to set\n * `\"type\": \"module\"`, and `export default` in a `.js` file would throw in a\n * CommonJS project.\n */\nconst postcssConfigStub = `/**\n * Tailwind v4 runs as a PostCSS plugin. Vite loads this file automatically —\n * in dev and in build — so no Vite configuration is required.\n *\n * The plugin lives in its own package in v4: \\`tailwindcss\\` is the engine,\n * \\`@tailwindcss/postcss\\` is the adapter. Naming \\`tailwindcss\\` here directly is\n * the v3 spelling and will not work.\n */\nexport default {\n plugins: {\n \"@tailwindcss/postcss\": {},\n },\n};\n`;\n\n/**\n * Every filename Vite/PostCSS will pick up from the project root.\n *\n * Checked as a set rather than just writing our own: two PostCSS configs in one\n * directory is not a merge, it is a coin toss over which one loads, so a project\n * that already has one gets instructions instead of a second file.\n */\nconst POSTCSS_CONFIG_FILES = [\n \"postcss.config.mjs\",\n \"postcss.config.js\",\n \"postcss.config.cjs\",\n \"postcss.config.ts\",\n \"postcss.config.json\",\n \".postcssrc\",\n \".postcssrc.js\",\n \".postcssrc.json\",\n];\n\n/** The side-effect import that pulls the stylesheet into the bundler graph. */\nconst APP_CSS_IMPORT = 'import \"./app.css\";';\n\n/**\n * Create `src/web/app.css` — the sentinel for \"this feature already ran\".\n *\n * Nothing in the project template creates this file (the scaffold ships a\n * static `public/home.css` instead), so its presence means `add tailwind` has\n * been here, and its contents may since have been edited into a real design\n * system. It is never rewritten.\n */\nasync function createStylesheet(): Promise<boolean> {\n const cssFile = srcPath(\"web/app.css\");\n\n if (await fileExistsAsync(cssFile)) {\n console.log(`${colors.yellowBright(\"src/web/app.css\")} already exists, skipping...`);\n\n return false;\n }\n\n await ensureDirectoryAsync(srcPath(\"web\"));\n await putFileAsync(cssFile, appCssStub);\n console.log(`${colors.green(\"✓\")} Created src/web/app.css`);\n\n return true;\n}\n\n/**\n * Write `postcss.config.mjs`, unless the project already has a PostCSS config.\n *\n * An existing config is left completely alone. It is an app-owned build file\n * that may already register autoprefixer, nesting, or a CSS-modules pass, and\n * appending Tailwind to it by string surgery would be guessing at both its\n * module format and its plugin order.\n */\nasync function writePostcssConfig(): Promise<void> {\n for (const name of POSTCSS_CONFIG_FILES) {\n if (!(await fileExistsAsync(rootPath(name)))) continue;\n\n const current = await getFileAsync(rootPath(name)).catch(() => \"\");\n\n if (current.includes(\"@tailwindcss/postcss\")) {\n console.log(`${colors.yellowBright(name)} already registers Tailwind, skipping...`);\n } else {\n console.log(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(name)} already exists — add Tailwind to it yourself:\\n` +\n ` plugins: { \"@tailwindcss/postcss\": {} }\\n` +\n \" Two PostCSS configs in one project is undefined behaviour, so this feature did not write a second one.\",\n );\n }\n\n return;\n }\n\n await putFileAsync(rootPath(\"postcss.config.mjs\"), postcssConfigStub);\n console.log(`${colors.green(\"✓\")} Created postcss.config.mjs`);\n}\n\n/**\n * Import the stylesheet from `src/web/root.tsx`.\n *\n * The root is the one module every SSR page renders through, so importing the\n * stylesheet there is what puts it in the client bundle for every route.\n *\n * `root.tsx` is guaranteed to exist by the time this runs: `requires: [\"web\"]`\n * makes the add command resolve `web` ahead of `tailwind` and run its\n * `onExecuting` first, and that is what creates the file. The check below is\n * therefore for a root a human has since moved or deleted — a case worth a\n * printed instruction, not a failure.\n */\nasync function importStylesheetFromRoot(): Promise<void> {\n const rootFile = srcPath(\"web/root.tsx\");\n\n if (!(await fileExistsAsync(rootFile))) {\n console.log(\n `${colors.yellowBright(\"!\")} ${colors.yellowBright(\"src/web/root.tsx\")} not found — ` +\n `add ${colors.yellowBright(APP_CSS_IMPORT)} to your application root yourself.\\n` +\n \" Until the stylesheet is imported from a module the bundler reaches, no Tailwind CSS is emitted.\",\n );\n\n return;\n }\n\n const current = await getFileAsync(rootFile);\n\n // Matches the import whichever quote style and specifier the file uses, so a\n // re-run against a hand-edited root does not stack a second copy.\n if (/import\\s+[\"'].*app\\.css[\"']/.test(current)) {\n console.log(`${colors.yellowBright(\"src/web/root.tsx\")} already imports app.css, skipping...`);\n\n return;\n }\n\n // FIRST line, above the framework imports. A side-effect CSS import has no\n // binding to order against, and putting it at the top keeps it from being\n // swept away by an import sorter that only ranks module specifiers.\n await putFileAsync(rootFile, `${APP_CSS_IMPORT}\\n${current}`);\n console.log(`${colors.green(\"✓\")} Imported app.css in src/web/root.tsx`);\n}\n\n/**\n * Wire Tailwind v4 into the page layer.\n *\n * Three files, none of which needs `node_modules` to be populated: on the\n * `create-warlock` path this runs under `--no-install`, so the dependencies\n * declared below are only recorded in `package.json` and nothing here may\n * import, resolve, or execute Tailwind itself.\n */\nasync function completeTailwindInstallation(_options: CommandActionData) {\n await createStylesheet();\n await writePostcssConfig();\n await importStylesheetFromRoot();\n}\n\nexport const tailwindFeature: FeatureDefinition = {\n description:\n \"Installs Tailwind CSS v4 for the SSR page layer. Creates src/web/app.css (the CSS-first config — v4 has no tailwind.config.js), imports it from src/web/root.tsx, and registers the engine through postcss.config.mjs, which Vite loads in dev and build alike.\",\n // `web` owns root.tsx and the CSS pipeline that serves this stylesheet.\n // Requiring it also fixes the order: the add command resolves requirements\n // depth-first, so `web` scaffolds src/web before this feature writes into it.\n requires: [\"web\"],\n devDependencies: {\n // Build-time only, both of them — Tailwind compiles to a plain stylesheet\n // and nothing it ships is imported at runtime.\n tailwindcss: \"^4.1.16\",\n // The engine and its PostCSS adapter are separate packages in v4 and are\n // released in lockstep; keep these two ranges identical.\n \"@tailwindcss/postcss\": \"^4.1.16\",\n },\n onExecuting: completeTailwindInstallation,\n};\n"],"mappings":";;;;;;;;;;;;;;;;;AAsBA,MAAM,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCnB,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;AAsB1B,MAAM,uBAAuB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAM,iBAAiB;;;;;;;;;AAUvB,eAAe,mBAAqC;CAClD,MAAM,UAAU,QAAQ,aAAa;CAErC,IAAI,MAAM,gBAAgB,OAAO,GAAG;EAClC,QAAQ,IAAI,GAAG,OAAO,aAAa,iBAAiB,EAAE,6BAA6B;EAEnF,OAAO;CACT;CAEA,MAAM,qBAAqB,QAAQ,KAAK,CAAC;CACzC,MAAM,aAAa,SAAS,UAAU;CACtC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,yBAAyB;CAE1D,OAAO;AACT;;;;;;;;;AAUA,eAAe,qBAAoC;CACjD,KAAK,MAAM,QAAQ,sBAAsB;EACvC,IAAI,CAAE,MAAM,gBAAgB,SAAS,IAAI,CAAC,GAAI;EAI9C,KAAI,MAFkB,aAAa,SAAS,IAAI,CAAC,CAAC,CAAC,YAAY,EAAE,EAEtD,CAAC,SAAS,sBAAsB,GACzC,QAAQ,IAAI,GAAG,OAAO,aAAa,IAAI,EAAE,yCAAyC;OAElF,QAAQ,IACN,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,IAAI,EAAE,oMAG3D;EAGF;CACF;CAEA,MAAM,aAAa,SAAS,oBAAoB,GAAG,iBAAiB;CACpE,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,4BAA4B;AAC/D;;;;;;;;;;;;;AAcA,eAAe,2BAA0C;CACvD,MAAM,WAAW,QAAQ,cAAc;CAEvC,IAAI,CAAE,MAAM,gBAAgB,QAAQ,GAAI;EACtC,QAAQ,IACN,GAAG,OAAO,aAAa,GAAG,EAAE,GAAG,OAAO,aAAa,kBAAkB,EAAE,mBAC9D,OAAO,aAAa,cAAc,EAAE,uIAE/C;EAEA;CACF;CAEA,MAAM,UAAU,MAAM,aAAa,QAAQ;CAI3C,IAAI,8BAA8B,KAAK,OAAO,GAAG;EAC/C,QAAQ,IAAI,GAAG,OAAO,aAAa,kBAAkB,EAAE,sCAAsC;EAE7F;CACF;CAKA,MAAM,aAAa,UAAU,GAAG,eAAe,IAAI,SAAS;CAC5D,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,sCAAsC;AACzE;;;;;;;;;AAUA,eAAe,6BAA6B,UAA6B;CACvE,MAAM,iBAAiB;CACvB,MAAM,mBAAmB;CACzB,MAAM,yBAAyB;AACjC;AAEA,MAAa,kBAAqC;CAChD,aACE;CAIF,UAAU,CAAC,KAAK;CAChB,iBAAiB;EAGf,aAAa;EAGb,wBAAwB;CAC1B;CACA,aAAa;AACf"}
@@ -1 +1 @@
1
- {"version":3,"file":"test.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/test.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { fileExistsAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport { FeatureDefinition } from \"./types\";\r\n\r\nasync function completeTestInstallation(options: CommandActionData) {\r\n // Create test-global-setup.ts (runs once before all tests)\r\n const testGlobalSetupPath = srcPath(\"test-global-setup.ts\");\r\n const testGlobalSetupExists = await fileExistsAsync(testGlobalSetupPath);\r\n\r\n if (!testGlobalSetupExists) {\r\n await putFileAsync(\r\n testGlobalSetupPath,\r\n `/**\r\n * Global Test Setup\r\n *\r\n * Runs ONCE before all test workers.\r\n * Starts the HTTP server for integration tests.\r\n */\r\nimport { startHttpTestServer, stopHttpTestServer } from \"@warlock.js/core/tests\";\r\n\r\nexport async function setup() {\r\n await startHttpTestServer();\r\n}\r\n\r\nexport async function teardown() {\r\n await stopHttpTestServer();\r\n}\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-global-setup.ts`);\r\n }\r\n\r\n // Create test-setup.ts (runs before EVERY test file)\r\n const testSetupPath = srcPath(\"test-setup.ts\");\r\n const testSetupExists = await fileExistsAsync(testSetupPath);\r\n\r\n if (!testSetupExists) {\r\n await putFileAsync(\r\n testSetupPath,\r\n `/**\r\n * Test Setup - runs before EVERY test file\r\n *\r\n * Vitest runs setupFiles before each test file and rebuilds the module\r\n * registry with it, so this pair boots and closes the test runtime once per\r\n * test file.\r\n *\r\n * setupTest() is called with no options on purpose: an explicit connectors\r\n * value outranks tests.connectors from src/config/tests.ts, so passing one\r\n * here would erase your project config. Omitting it leaves the config in\r\n * charge.\r\n *\r\n * afterAll(teardownTest) is the other half of the pair: whoever calls\r\n * setupTest() owns closing it in the same runtime context.\r\n */\r\nimport { setupTest, teardownTest } from \"@warlock.js/core/tests\";\r\nimport { afterAll } from \"vitest\";\r\n\r\nawait setupTest();\r\n\r\nafterAll(teardownTest);\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-setup.ts`);\r\n }\r\n\r\n // Create vite.config.ts\r\n const viteConfigPath = rootPath(\"vite.config.ts\");\r\n const viteConfigExists = await fileExistsAsync(viteConfigPath);\r\n\r\n if (!viteConfigExists) {\r\n await putFileAsync(\r\n viteConfigPath,\r\n `import { lowerStage3Decorators } from \"@warlock.js/core/vite\";\r\nimport mongezVite from \"@mongez/vite\";\r\nimport { defineConfig } from \"vitest/config\";\r\n\r\nexport default defineConfig({\r\n // lowerStage3Decorators MUST come first: it lowers native (@RegisterModel, …)\r\n // decorators with esbuild before oxc / the SSR rewrite can mangle them, so\r\n // decorated Cascade models load under Vitest.\r\n plugins: [lowerStage3Decorators(), mongezVite()],\r\n test: {\r\n globalSetup: \"./src/test-global-setup.ts\", // HTTP server - runs once\r\n setupFiles: [\"./src/test-setup.ts\"], // DB/cache - runs per test file\r\n environment: \"node\",\r\n globals: false,\r\n include: [\"src/app/**/*.test.ts\"],\r\n },\r\n});\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created vite.config.ts`);\r\n }\r\n}\r\n\r\nexport const testFeature: FeatureDefinition = {\r\n description: \"Installs warlock test for testing\",\r\n onExecuting: completeTestInstallation,\r\n script: {\r\n test: \"vitest run\",\r\n \"test:coverage\": \"vitest run --coverage\",\r\n \"test:ui\": \"vitest --ui\",\r\n \"test:watch\": \"vitest --watch\",\r\n },\r\n devDependencies: {\r\n \"@mongez/vite\": \"^2.0.4\",\r\n vite: \"^8.0.16\",\r\n vitest: \"^4.1.8\",\r\n \"@vitest/coverage-v8\": \"^4.1.8\",\r\n },\r\n};\r\n"],"mappings":";;;;;;AAMA,eAAe,yBAAyB,SAA4B;CAElE,MAAM,sBAAsB,QAAQ,sBAAsB;CAG1D,IAAI,CAAC,MAF+B,gBAAgB,mBAAmB,GAE3C;EAC1B,MAAM,aACJ,qBACA;;;;;;;;;;;;;;;CAgBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,kCAAkC;CACrE;CAGA,MAAM,gBAAgB,QAAQ,eAAe;CAG7C,IAAI,CAAC,MAFyB,gBAAgB,aAAa,GAErC;EACpB,MAAM,aACJ,eACA;;;;;;;;;;;;;;;;;;;;;CAsBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,2BAA2B;CAC9D;CAGA,MAAM,iBAAiB,SAAS,gBAAgB;CAGhD,IAAI,CAAC,MAF0B,gBAAgB,cAAc,GAEtC;EACrB,MAAM,aACJ,gBACA;;;;;;;;;;;;;;;;;CAkBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,wBAAwB;CAC3D;AACF;AAEA,MAAa,cAAiC;CAC5C,aAAa;CACb,aAAa;CACb,QAAQ;EACN,MAAM;EACN,iBAAiB;EACjB,WAAW;EACX,cAAc;CAChB;CACA,iBAAiB;EACf,gBAAgB;EAChB,MAAM;EACN,QAAQ;EACR,uBAAuB;CACzB;AACF"}
1
+ {"version":3,"file":"test.feature.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/test.feature.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport { fileExistsAsync, putFileAsync } from \"@warlock.js/fs\";\r\nimport type { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport type { FeatureDefinition } from \"./types\";\r\n\r\nasync function completeTestInstallation(options: CommandActionData) {\r\n // Create test-global-setup.ts (runs once before all tests)\r\n const testGlobalSetupPath = srcPath(\"test-global-setup.ts\");\r\n const testGlobalSetupExists = await fileExistsAsync(testGlobalSetupPath);\r\n\r\n if (!testGlobalSetupExists) {\r\n await putFileAsync(\r\n testGlobalSetupPath,\r\n `/**\r\n * Global Test Setup\r\n *\r\n * Runs ONCE before all test workers.\r\n * Starts the HTTP server for integration tests.\r\n */\r\nimport { startHttpTestServer, stopHttpTestServer } from \"@warlock.js/core/tests\";\r\n\r\nexport async function setup() {\r\n await startHttpTestServer();\r\n}\r\n\r\nexport async function teardown() {\r\n await stopHttpTestServer();\r\n}\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-global-setup.ts`);\r\n }\r\n\r\n // Create test-setup.ts (runs before EVERY test file)\r\n const testSetupPath = srcPath(\"test-setup.ts\");\r\n const testSetupExists = await fileExistsAsync(testSetupPath);\r\n\r\n if (!testSetupExists) {\r\n await putFileAsync(\r\n testSetupPath,\r\n `/**\r\n * Test Setup - runs before EVERY test file\r\n *\r\n * Vitest runs setupFiles before each test file and rebuilds the module\r\n * registry with it, so this pair boots and closes the test runtime once per\r\n * test file.\r\n *\r\n * setupTest() is called with no options on purpose: an explicit connectors\r\n * value outranks tests.connectors from src/config/tests.ts, so passing one\r\n * here would erase your project config. Omitting it leaves the config in\r\n * charge.\r\n *\r\n * afterAll(teardownTest) is the other half of the pair: whoever calls\r\n * setupTest() owns closing it in the same runtime context.\r\n */\r\nimport { setupTest, teardownTest } from \"@warlock.js/core/tests\";\r\nimport { afterAll } from \"vitest\";\r\n\r\nawait setupTest();\r\n\r\nafterAll(teardownTest);\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created src/test-setup.ts`);\r\n }\r\n\r\n // Create vite.config.ts\r\n const viteConfigPath = rootPath(\"vite.config.ts\");\r\n const viteConfigExists = await fileExistsAsync(viteConfigPath);\r\n\r\n if (!viteConfigExists) {\r\n await putFileAsync(\r\n viteConfigPath,\r\n `import { lowerStage3Decorators } from \"@warlock.js/core/vite\";\r\nimport mongezVite from \"@mongez/vite\";\r\nimport { defineConfig } from \"vitest/config\";\r\n\r\nexport default defineConfig({\r\n // lowerStage3Decorators MUST come first: it lowers native (@RegisterModel, …)\r\n // decorators with esbuild before oxc / the SSR rewrite can mangle them, so\r\n // decorated Cascade models load under Vitest.\r\n plugins: [lowerStage3Decorators(), mongezVite()],\r\n test: {\r\n globalSetup: \"./src/test-global-setup.ts\", // HTTP server - runs once\r\n setupFiles: [\"./src/test-setup.ts\"], // DB/cache - runs per test file\r\n environment: \"node\",\r\n globals: false,\r\n include: [\"src/app/**/*.test.ts\"],\r\n },\r\n});\r\n`,\r\n );\r\n console.log(`${colors.green(\"✓\")} Created vite.config.ts`);\r\n }\r\n}\r\n\r\nexport const testFeature: FeatureDefinition = {\r\n description: \"Installs warlock test for testing\",\r\n onExecuting: completeTestInstallation,\r\n script: {\r\n test: \"vitest run\",\r\n \"test:coverage\": \"vitest run --coverage\",\r\n \"test:ui\": \"vitest --ui\",\r\n \"test:watch\": \"vitest --watch\",\r\n },\r\n devDependencies: {\r\n \"@mongez/vite\": \"^2.0.4\",\r\n vite: \"^8.0.16\",\r\n vitest: \"^4.1.8\",\r\n \"@vitest/coverage-v8\": \"^4.1.8\",\r\n },\r\n};\r\n"],"mappings":";;;;;;AAMA,eAAe,yBAAyB,SAA4B;CAElE,MAAM,sBAAsB,QAAQ,sBAAsB;CAG1D,IAAI,CAAC,MAF+B,gBAAgB,mBAAmB,GAE3C;EAC1B,MAAM,aACJ,qBACA;;;;;;;;;;;;;;;CAgBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,kCAAkC;CACrE;CAGA,MAAM,gBAAgB,QAAQ,eAAe;CAG7C,IAAI,CAAC,MAFyB,gBAAgB,aAAa,GAErC;EACpB,MAAM,aACJ,eACA;;;;;;;;;;;;;;;;;;;;;CAsBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,2BAA2B;CAC9D;CAGA,MAAM,iBAAiB,SAAS,gBAAgB;CAGhD,IAAI,CAAC,MAF0B,gBAAgB,cAAc,GAEtC;EACrB,MAAM,aACJ,gBACA;;;;;;;;;;;;;;;;;CAkBF;EACA,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,wBAAwB;CAC3D;AACF;AAEA,MAAa,cAAiC;CAC5C,aAAa;CACb,aAAa;CACb,QAAQ;EACN,MAAM;EACN,iBAAiB;EACjB,WAAW;EACX,cAAc;CAChB;CACA,iBAAiB;EACf,gBAAgB;EAChB,MAAM;EACN,QAAQ;EACR,uBAAuB;CACzB;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/types.ts"],"sourcesContent":["import { CommandActionData } from \"../../commands/types\";\n\n/**\n * Internal placeholder for an @warlock.js dependency whose concrete version\n * comes from the installed Core package at command execution time.\n *\n * This is deliberately not a valid package-manager range. If a future code\n * path forgets to resolve it, installation fails loudly instead of silently\n * falling back to a stale framework major copied into a feature definition.\n */\nexport const INSTALLED_WARLOCK_VERSION = \"__INSTALLED_WARLOCK_VERSION__\";\n\r\n/**\r\n * One entry in the feature registry.\r\n *\r\n * Every feature owns a module of its own that exports a single value of this\r\n * shape, with its `onExecuting` body beside it; `features/index.ts` is only the\r\n * ordered index that collects them.\r\n */\r\nexport type FeatureDefinition = {\r\n dependencies?: Record<string, string>;\r\n devDependencies?: Record<string, string>;\r\n description: string;\r\n requires?: string[];\r\n script?: Record<string, string>;\r\n onExecuting?: (options: CommandActionData) => Promise<any>;\r\n ejectConfig?: {\r\n content: string;\r\n name: string;\r\n };\r\n};\r\n"],"mappings":";;;;;;;;;AAUA,MAAa,4BAA4B"}
1
+ {"version":3,"file":"types.mjs","names":[],"sources":["../../../../../../../../core/src/generations/features/types.ts"],"sourcesContent":["import type { CommandActionData } from \"../../commands/types\";\n\n/**\n * Internal placeholder for an @warlock.js dependency whose concrete version\n * comes from the installed Core package at command execution time.\n *\n * This is deliberately not a valid package-manager range. If a future code\n * path forgets to resolve it, installation fails loudly instead of silently\n * falling back to a stale framework major copied into a feature definition.\n */\nexport const INSTALLED_WARLOCK_VERSION = \"__INSTALLED_WARLOCK_VERSION__\";\n\r\n/**\r\n * One entry in the feature registry.\r\n *\r\n * Every feature owns a module of its own that exports a single value of this\r\n * shape, with its `onExecuting` body beside it; `features/index.ts` is only the\r\n * ordered index that collects them.\r\n */\r\nexport type FeatureDefinition = {\r\n dependencies?: Record<string, string>;\r\n devDependencies?: Record<string, string>;\r\n description: string;\r\n requires?: string[];\r\n script?: Record<string, string>;\r\n onExecuting?: (options: CommandActionData) => Promise<any>;\r\n ejectConfig?: {\r\n content: string;\r\n name: string;\r\n };\r\n};\r\n"],"mappings":";;;;;;;;;AAUA,MAAa,4BAA4B"}
@@ -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 { 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 { 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 {\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 +1 @@
1
- {"version":3,"file":"boot-port-preflight.d.mts","names":[],"sources":["../../../../../../../core/src/http/boot-port-preflight.ts"],"mappings":";;AAuBA;;;;AAA+D;AA0C/D;;;;AAA4D;;;;iBA1CtC,8BAAA,IAAkC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA0CzC,2BAAA,IAA+B,OAAO"}
1
+ {"version":3,"file":"boot-port-preflight.d.mts","names":[],"sources":["../../../../../../../core/src/http/boot-port-preflight.ts"],"mappings":";;AAiCA;;;;AAA+D;AA4D/D;;;;AAA4D;;;;iBA5DtC,8BAAA,IAAkC,OAAO;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA4DzC,2BAAA,IAA+B,OAAO"}
@@ -1,8 +1,17 @@
1
1
  import { PortInUseError, assertPortIsAvailable } from "./port-preflight.mjs";
2
+ import { resolveBindPort } from "./resolve-bind-port.mjs";
2
3
  import config from "@mongez/config";
3
4
 
4
5
  //#region ../core/src/http/boot-port-preflight.ts
5
6
  /**
7
+ * Mirrors `dev-server/supervisor.ts`'s `BOOT_PRECONDITION_EXIT_CODE`. Kept as
8
+ * its own literal here (rather than imported) so this file — reached by both
9
+ * the dev-preload path and the generated, standalone production entry — never
10
+ * pulls dev-server's supervisor module (child_process, TTY handling, ...)
11
+ * into a production bundle that has no supervisor at all. EX_CONFIG.
12
+ */
13
+ const BOOT_PRECONDITION_EXIT_CODE = 78;
14
+ /**
6
15
  * Host used when `http.host` is unset — the same default `HttpConnector`
7
16
  * binds with, so the probe tests the address the server will actually take.
8
17
  */
@@ -24,8 +33,13 @@ const DEFAULT_BIND_HOST = "localhost";
24
33
  async function assertConfiguredHttpPortIsFree() {
25
34
  const httpConfig = config.get("http");
26
35
  if (!httpConfig) return;
27
- const port = Number(httpConfig.port);
28
- if (!Number.isInteger(port) || port <= 0 || port > 65535) return;
36
+ let port;
37
+ try {
38
+ port = resolveBindPort(httpConfig.port);
39
+ } catch {
40
+ return;
41
+ }
42
+ if (port === 0) return;
29
43
  await assertPortIsAvailable(port, httpConfig.host || DEFAULT_BIND_HOST);
30
44
  }
31
45
  /**
@@ -60,7 +74,7 @@ async function preflightConfiguredHttpPort() {
60
74
  } catch (error) {
61
75
  if (!(error instanceof PortInUseError)) return;
62
76
  reportPortInUse(error);
63
- process.exit(1);
77
+ process.exit(BOOT_PRECONDITION_EXIT_CODE);
64
78
  }
65
79
  }
66
80
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"boot-port-preflight.mjs","names":[],"sources":["../../../../../../../core/src/http/boot-port-preflight.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport { assertPortIsAvailable, PortInUseError } from \"./port-preflight\";\n\n/**\n * Host used when `http.host` is unset — the same default `HttpConnector`\n * binds with, so the probe tests the address the server will actually take.\n */\nconst DEFAULT_BIND_HOST = \"localhost\";\n\n/**\n * Probe the port declared in `src/config/http.ts`, if there is one.\n *\n * Reads config rather than taking arguments because the only honest answer to\n * \"which port will this boot bind\" lives in the loaded config, and the callers\n * that need this check (the generated production entry) run before anything\n * that could hand it over.\n *\n * A no-op for an app with no `http` config, or an `http.port` that is not a\n * usable port number — those apps never bind, so there is nothing to preflight\n * and nothing to fail on.\n *\n * @throws {PortInUseError} when the configured port is already taken.\n */\nexport async function assertConfiguredHttpPortIsFree(): Promise<void> {\n const httpConfig = config.get(\"http\");\n\n if (!httpConfig) {\n return;\n }\n\n const port = Number(httpConfig.port);\n\n if (!Number.isInteger(port) || port <= 0 || port > 65_535) {\n return;\n }\n\n await assertPortIsAvailable(port, httpConfig.host || DEFAULT_BIND_HOST);\n}\n\n/**\n * Fail the boot immediately, and by name, when the http port is already taken.\n *\n * Called from the generated production entry BEFORE the early-phase connectors,\n * because the port is the cheapest thing in the boot to check and the one most\n * likely to be wrong. `HttpConnector` is a LATE-phase connector, so its own\n * preflight is not reached until the database has connected, the cache has\n * connected and every module of app code has been imported — 7-13 seconds of\n * work, all of it discarded, before anyone learns the port was busy. This check\n * costs a bind-and-release on a socket that is never served.\n *\n * It does NOT replace `HttpConnector`'s preflight: a port can be taken in the\n * seconds between the two, and only the connector's check sits immediately\n * before the real `listen()`. This one exists to make the common case fast and\n * legible.\n *\n * Reports through `console.error` and NOT through `log.fatal`: the logger has\n * no channels yet at this point in the boot (the logger connector is part of\n * the early phase this runs ahead of), so a logged message here would reach\n * nobody. stderr always reaches the terminal, and `warlock start` forwards the\n * child's stderr verbatim.\n *\n * A probe that fails for any OTHER reason is deliberately swallowed: this is a\n * fast-fail convenience, and it must never be the thing that stops an app which\n * would otherwise have booted. `HttpConnector` still runs the real check.\n */\nexport async function preflightConfiguredHttpPort(): Promise<void> {\n try {\n await assertConfiguredHttpPortIsFree();\n } catch (error) {\n if (!(error instanceof PortInUseError)) {\n return;\n }\n\n reportPortInUse(error);\n\n process.exit(1);\n }\n}\n\n/**\n * Print the collision the way the developer needs to read it: the errno they\n * grep for, the port and host by name, and the command that names the process\n * holding it — the supervisor cannot discover the owning PID for them, but it\n * can hand them the one line that will.\n */\nfunction reportPortInUse(error: PortInUseError): void {\n const ownerCommand =\n process.platform === \"win32\"\n ? `netstat -ano | findstr :${error.port}`\n : `lsof -i :${error.port}`;\n\n const lines = [\n \"\",\n ` ✖ EADDRINUSE: port ${error.port} is already in use on ${error.host}`,\n ` the application cannot start because something else is already listening there.`,\n ` find the owning process: ${ownerCommand}`,\n ` then stop it, or change http.port in src/config/http.ts and rebuild.`,\n \"\",\n ];\n\n for (const line of lines) {\n console.error(line);\n }\n}\n"],"mappings":";;;;;;;;AAOA,MAAM,oBAAoB;;;;;;;;;;;;;;;AAgB1B,eAAsB,iCAAgD;CACpE,MAAM,aAAa,OAAO,IAAI,MAAM;CAEpC,IAAI,CAAC,YACH;CAGF,MAAM,OAAO,OAAO,WAAW,IAAI;CAEnC,IAAI,CAAC,OAAO,UAAU,IAAI,KAAK,QAAQ,KAAK,OAAO,OACjD;CAGF,MAAM,sBAAsB,MAAM,WAAW,QAAQ,iBAAiB;AACxE;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,eAAsB,8BAA6C;CACjE,IAAI;EACF,MAAM,+BAA+B;CACvC,SAAS,OAAO;EACd,IAAI,EAAE,iBAAiB,iBACrB;EAGF,gBAAgB,KAAK;EAErB,QAAQ,KAAK,CAAC;CAChB;AACF;;;;;;;AAQA,SAAS,gBAAgB,OAA6B;CACpD,MAAM,eACJ,QAAQ,aAAa,UACjB,2BAA2B,MAAM,SACjC,YAAY,MAAM;CAExB,MAAM,QAAQ;EACZ;EACA,wBAAwB,MAAM,KAAK,wBAAwB,MAAM;EACjE;EACA,8BAA8B;EAC9B;EACA;CACF;CAEA,KAAK,MAAM,QAAQ,OACjB,QAAQ,MAAM,IAAI;AAEtB"}
1
+ {"version":3,"file":"boot-port-preflight.mjs","names":[],"sources":["../../../../../../../core/src/http/boot-port-preflight.ts"],"sourcesContent":["import config from \"@mongez/config\";\nimport { assertPortIsAvailable, PortInUseError } from \"./port-preflight\";\nimport { resolveBindPort } from \"./resolve-bind-port\";\n\n/**\n * Mirrors `dev-server/supervisor.ts`'s `BOOT_PRECONDITION_EXIT_CODE`. Kept as\n * its own literal here (rather than imported) so this file — reached by both\n * the dev-preload path and the generated, standalone production entry — never\n * pulls dev-server's supervisor module (child_process, TTY handling, ...)\n * into a production bundle that has no supervisor at all. EX_CONFIG.\n */\nconst BOOT_PRECONDITION_EXIT_CODE = 78;\n\n/**\n * Host used when `http.host` is unset — the same default `HttpConnector`\n * binds with, so the probe tests the address the server will actually take.\n */\nconst DEFAULT_BIND_HOST = \"localhost\";\n\n/**\n * Probe the port declared in `src/config/http.ts`, if there is one.\n *\n * Reads config rather than taking arguments because the only honest answer to\n * \"which port will this boot bind\" lives in the loaded config, and the callers\n * that need this check (the generated production entry) run before anything\n * that could hand it over.\n *\n * A no-op for an app with no `http` config, or an `http.port` that is not a\n * usable port number — those apps never bind, so there is nothing to preflight\n * and nothing to fail on.\n *\n * @throws {PortInUseError} when the configured port is already taken.\n */\nexport async function assertConfiguredHttpPortIsFree(): Promise<void> {\n const httpConfig = config.get(\"http\");\n\n if (!httpConfig) {\n return;\n }\n\n // This runs BEFORE the connectors in the generated production entry, and\n // `preflightConfiguredHttpPort()` below only exits on `PortInUseError` —\n // any other throw is swallowed there. So a bad port must not throw HERE:\n // it stays a no-op, exactly like the previous hand-rolled coercion, and\n // `HttpConnector` (which now also calls `resolveBindPort`) is the one that\n // reports it, a moment later, with a channel a developer will actually see.\n let port: number;\n\n try {\n port = resolveBindPort(httpConfig.port);\n } catch {\n return;\n }\n\n // Port 0 asks the OS to pick a free one, so there is nothing to preflight:\n // probing 0 binds SOME unrelated ephemeral port and \"passes\" without proving\n // anything about the port this boot will end up on. The hand-rolled coercion\n // this replaced skipped 0 by rejecting `port <= 0`; `resolveBindPort` accepts\n // 0 as a legitimate TCP value, so the skip has to be stated here instead of\n // falling out of a range check.\n if (port === 0) {\n return;\n }\n\n await assertPortIsAvailable(port, httpConfig.host || DEFAULT_BIND_HOST);\n}\n\n/**\n * Fail the boot immediately, and by name, when the http port is already taken.\n *\n * Called from the generated production entry BEFORE the early-phase connectors,\n * because the port is the cheapest thing in the boot to check and the one most\n * likely to be wrong. `HttpConnector` is a LATE-phase connector, so its own\n * preflight is not reached until the database has connected, the cache has\n * connected and every module of app code has been imported — 7-13 seconds of\n * work, all of it discarded, before anyone learns the port was busy. This check\n * costs a bind-and-release on a socket that is never served.\n *\n * It does NOT replace `HttpConnector`'s preflight: a port can be taken in the\n * seconds between the two, and only the connector's check sits immediately\n * before the real `listen()`. This one exists to make the common case fast and\n * legible.\n *\n * Reports through `console.error` and NOT through `log.fatal`: the logger has\n * no channels yet at this point in the boot (the logger connector is part of\n * the early phase this runs ahead of), so a logged message here would reach\n * nobody. stderr always reaches the terminal, and `warlock start` forwards the\n * child's stderr verbatim.\n *\n * A probe that fails for any OTHER reason is deliberately swallowed: this is a\n * fast-fail convenience, and it must never be the thing that stops an app which\n * would otherwise have booted. `HttpConnector` still runs the real check.\n */\nexport async function preflightConfiguredHttpPort(): Promise<void> {\n try {\n await assertConfiguredHttpPortIsFree();\n } catch (error) {\n if (!(error instanceof PortInUseError)) {\n return;\n }\n\n reportPortInUse(error);\n\n process.exit(BOOT_PRECONDITION_EXIT_CODE);\n }\n}\n\n/**\n * Print the collision the way the developer needs to read it: the errno they\n * grep for, the port and host by name, and the command that names the process\n * holding it — the supervisor cannot discover the owning PID for them, but it\n * can hand them the one line that will.\n */\nfunction reportPortInUse(error: PortInUseError): void {\n const ownerCommand =\n process.platform === \"win32\"\n ? `netstat -ano | findstr :${error.port}`\n : `lsof -i :${error.port}`;\n\n const lines = [\n \"\",\n ` ✖ EADDRINUSE: port ${error.port} is already in use on ${error.host}`,\n ` the application cannot start because something else is already listening there.`,\n ` find the owning process: ${ownerCommand}`,\n ` then stop it, or change http.port in src/config/http.ts and rebuild.`,\n \"\",\n ];\n\n for (const line of lines) {\n console.error(line);\n }\n}\n"],"mappings":";;;;;;;;;;;;AAWA,MAAM,8BAA8B;;;;;AAMpC,MAAM,oBAAoB;;;;;;;;;;;;;;;AAgB1B,eAAsB,iCAAgD;CACpE,MAAM,aAAa,OAAO,IAAI,MAAM;CAEpC,IAAI,CAAC,YACH;CASF,IAAI;CAEJ,IAAI;EACF,OAAO,gBAAgB,WAAW,IAAI;CACxC,QAAQ;EACN;CACF;CAQA,IAAI,SAAS,GACX;CAGF,MAAM,sBAAsB,MAAM,WAAW,QAAQ,iBAAiB;AACxE;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,eAAsB,8BAA6C;CACjE,IAAI;EACF,MAAM,+BAA+B;CACvC,SAAS,OAAO;EACd,IAAI,EAAE,iBAAiB,iBACrB;EAGF,gBAAgB,KAAK;EAErB,QAAQ,KAAK,2BAA2B;CAC1C;AACF;;;;;;;AAQA,SAAS,gBAAgB,OAA6B;CACpD,MAAM,eACJ,QAAQ,aAAa,UACjB,2BAA2B,MAAM,SACjC,YAAY,MAAM;CAExB,MAAM,QAAQ;EACZ;EACA,wBAAwB,MAAM,KAAK,wBAAwB,MAAM;EACjE;EACA,8BAA8B;EAC9B;EACA;CACF;CAEA,KAAK,MAAM,QAAQ,OACjB,QAAQ,MAAM,IAAI;AAEtB"}
@@ -0,0 +1,51 @@
1
+ import { defaultHttpConfigurations } from "./config.mjs";
2
+
3
+ //#region ../core/src/http/resolve-bind-port.ts
4
+ /**
5
+ * Fallback used only if `defaultHttpConfigurations.port` is ever unset —
6
+ * `HttpConfigurations.port` is typed optional for consumers, but the
7
+ * framework's own default configuration always declares one. Keeps
8
+ * `resolveBindPort`'s return type a plain `number` without an `as`.
9
+ */
10
+ const FALLBACK_PORT = 3e3;
11
+ /**
12
+ * Resolve a raw, possibly-stringy `http.port` value into the number the
13
+ * socket layer will actually bind.
14
+ *
15
+ * `env()` only coerces a `.env` value to a number when it round-trips
16
+ * exactly (`String(Number(v)) === v`), so values like `"03999"`, `" 3999"`,
17
+ * `"+3999"` or `"1e3"` arrive here as strings. `net.Server.listen({ port })`
18
+ * happily accepts every one of those and binds SOMETHING — `"1e3"` binds
19
+ * port 1000 with no diagnostic anywhere — so this is the one place the
20
+ * configured port is turned into a canonical integer before anything binds,
21
+ * logs, or reports it.
22
+ *
23
+ * `undefined`/`null` (no `http.port` configured at all) resolves to the
24
+ * framework default rather than throwing — that case is not misconfiguration.
25
+ *
26
+ * @throws {Error} when the raw value cannot be resolved to a usable TCP port
27
+ * (0-65535, finite integer) — naming the raw value and where to fix it.
28
+ */
29
+ function resolveBindPort(rawPort) {
30
+ if (rawPort === void 0 || rawPort === null) return defaultHttpConfigurations.port ?? FALLBACK_PORT;
31
+ const resolved = Number(String(rawPort).trim());
32
+ if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65535) throw new Error(`Invalid http port ${JSON.stringify(rawPort)}: it does not resolve to a usable TCP port (0-65535, whole number). Fix HTTP_PORT in .env, or http.port in src/config/http.ts.`);
33
+ return resolved;
34
+ }
35
+ /**
36
+ * Whether `rawPort` was already the canonical value `resolveBindPort`
37
+ * returned for it — i.e. nothing needed normalising.
38
+ *
39
+ * Lets a caller print exactly one notice when the configured port and the
40
+ * bound port differ in representation (`"03999"` -> `3999`), and stay silent
41
+ * for the ordinary case (a plain number, or a string that was already
42
+ * canonical).
43
+ */
44
+ function isCanonicalPortValue(rawPort, resolved) {
45
+ if (typeof rawPort === "number" || rawPort === void 0 || rawPort === null) return true;
46
+ return String(rawPort) === String(resolved);
47
+ }
48
+
49
+ //#endregion
50
+ export { isCanonicalPortValue, resolveBindPort };
51
+ //# sourceMappingURL=resolve-bind-port.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"resolve-bind-port.mjs","names":[],"sources":["../../../../../../../core/src/http/resolve-bind-port.ts"],"sourcesContent":["import { defaultHttpConfigurations } from \"./config\";\n\n/**\n * Fallback used only if `defaultHttpConfigurations.port` is ever unset —\n * `HttpConfigurations.port` is typed optional for consumers, but the\n * framework's own default configuration always declares one. Keeps\n * `resolveBindPort`'s return type a plain `number` without an `as`.\n */\nconst FALLBACK_PORT = 3000;\n\n/**\n * Resolve a raw, possibly-stringy `http.port` value into the number the\n * socket layer will actually bind.\n *\n * `env()` only coerces a `.env` value to a number when it round-trips\n * exactly (`String(Number(v)) === v`), so values like `\"03999\"`, `\" 3999\"`,\n * `\"+3999\"` or `\"1e3\"` arrive here as strings. `net.Server.listen({ port })`\n * happily accepts every one of those and binds SOMETHING — `\"1e3\"` binds\n * port 1000 with no diagnostic anywhere — so this is the one place the\n * configured port is turned into a canonical integer before anything binds,\n * logs, or reports it.\n *\n * `undefined`/`null` (no `http.port` configured at all) resolves to the\n * framework default rather than throwing — that case is not misconfiguration.\n *\n * @throws {Error} when the raw value cannot be resolved to a usable TCP port\n * (0-65535, finite integer) — naming the raw value and where to fix it.\n */\nexport function resolveBindPort(rawPort: unknown): number {\n if (rawPort === undefined || rawPort === null) {\n return defaultHttpConfigurations.port ?? FALLBACK_PORT;\n }\n\n const resolved = Number(String(rawPort).trim());\n\n if (!Number.isInteger(resolved) || resolved < 0 || resolved > 65_535) {\n throw new Error(\n `Invalid http port ${JSON.stringify(rawPort)}: it does not resolve to a usable TCP port ` +\n `(0-65535, whole number). Fix HTTP_PORT in .env, or http.port in src/config/http.ts.`,\n );\n }\n\n return resolved;\n}\n\n/**\n * Whether `rawPort` was already the canonical value `resolveBindPort`\n * returned for it — i.e. nothing needed normalising.\n *\n * Lets a caller print exactly one notice when the configured port and the\n * bound port differ in representation (`\"03999\"` -> `3999`), and stay silent\n * for the ordinary case (a plain number, or a string that was already\n * canonical).\n */\nexport function isCanonicalPortValue(rawPort: unknown, resolved: number): boolean {\n if (typeof rawPort === \"number\" || rawPort === undefined || rawPort === null) {\n return true;\n }\n\n return String(rawPort) === String(resolved);\n}\n"],"mappings":";;;;;;;;;AAQA,MAAM,gBAAgB;;;;;;;;;;;;;;;;;;;AAoBtB,SAAgB,gBAAgB,SAA0B;CACxD,IAAI,YAAY,UAAa,YAAY,MACvC,OAAO,0BAA0B,QAAQ;CAG3C,MAAM,WAAW,OAAO,OAAO,OAAO,CAAC,CAAC,KAAK,CAAC;CAE9C,IAAI,CAAC,OAAO,UAAU,QAAQ,KAAK,WAAW,KAAK,WAAW,OAC5D,MAAM,IAAI,MACR,qBAAqB,KAAK,UAAU,OAAO,EAAE,+HAE/C;CAGF,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,SAAkB,UAA2B;CAChF,IAAI,OAAO,YAAY,YAAY,YAAY,UAAa,YAAY,MACtE,OAAO;CAGT,OAAO,OAAO,OAAO,MAAM,OAAO,QAAQ;AAC5C"}
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.d.mts","names":[],"sources":["../../../../../../../core/src/tests/test-helpers.ts"],"mappings":";;AAYA;;;;AAAgC;AAehC;;AAfgC,iBAAhB,gBAAA;;;;;iBAeM,WAAA,CAAY,IAAA,UAAc,OAAA,GAAS,WAAA,GAAmB,OAAA,CAAQ,QAAA;;;;iBAgD9D,OAAA,CAAQ,IAAA,UAAc,OAAA,GAAS,WAAA,GAAmB,OAAA,CAAQ,QAAA;;;AAhDY;iBAuDtE,QAAA,CACpB,IAAA,UACA,IAAA,YACA,OAAA,GAAS,WAAA,GACR,OAAA,CAAQ,QAAA;;;;iBAOW,OAAA,CACpB,IAAA,UACA,IAAA,YACA,OAAA,GAAS,WAAA,GACR,OAAA,CAAQ,QAAA;;;;iBAOW,UAAA,CAAW,IAAA,UAAc,OAAA,GAAS,WAAA,GAAmB,OAAA,CAAQ,QAAA;;;;iBAO7D,SAAA,CACpB,IAAA,UACA,IAAA,YACA,OAAA,GAAS,WAAA,GACR,OAAA,CAAQ,QAAA;;AAxC6E;AAOxF;iBAwCsB,iBAAA,IAAqB,QAAA,EAAU,QAAA,GAAW,OAAA,CAAQ,CAAA;;;;iBAOlD,UAAA,IAAc,QAAA,EAAU,QAAA,EAAU,cAAA,YAAuB,OAAA,CAAQ,CAAA"}
1
+ {"version":3,"file":"test-helpers.d.mts","names":[],"sources":["../../../../../../../core/src/tests/test-helpers.ts"],"mappings":";;AAaA;;;;AAAgC;AA6BhC;;AA7BgC,iBAAhB,gBAAA;;;;;iBA6BM,WAAA,CAAY,IAAA,UAAc,OAAA,GAAS,WAAA,GAAmB,OAAA,CAAQ,QAAA;;;;iBAgD9D,OAAA,CAAQ,IAAA,UAAc,OAAA,GAAS,WAAA,GAAmB,OAAA,CAAQ,QAAA;;;AAhDY;iBAuDtE,QAAA,CACpB,IAAA,UACA,IAAA,YACA,OAAA,GAAS,WAAA,GACR,OAAA,CAAQ,QAAA;;;;iBAWW,OAAA,CACpB,IAAA,UACA,IAAA,YACA,OAAA,GAAS,WAAA,GACR,OAAA,CAAQ,QAAA;;;;iBAOW,UAAA,CAAW,IAAA,UAAc,OAAA,GAAS,WAAA,GAAmB,OAAA,CAAQ,QAAA;;;;iBAO7D,SAAA,CACpB,IAAA,UACA,IAAA,YACA,OAAA,GAAS,WAAA,GACR,OAAA,CAAQ,QAAA;;AA5C6E;AAOxF;iBAgDsB,iBAAA,IAAqB,QAAA,EAAU,QAAA,GAAW,OAAA,CAAQ,CAAA;;;;iBAOlD,UAAA,IAAc,QAAA,EAAU,QAAA,EAAU,cAAA,YAAuB,OAAA,CAAQ,CAAA"}
@@ -1,5 +1,6 @@
1
1
  import { config } from "../config/config-getter.mjs";
2
2
  import "../config/index.mjs";
3
+ import { getHttpReadyReport } from "../http/ready-report.mjs";
3
4
  import { TEST_SERVER_PORT_ENV_KEY } from "./test-server-port-channel.mjs";
4
5
 
5
6
  //#region ../core/src/tests/test-helpers.ts
@@ -12,7 +13,9 @@ import { TEST_SERVER_PORT_ENV_KEY } from "./test-server-port-channel.mjs";
12
13
  * Get the test server base URL
13
14
  */
14
15
  function getTestServerUrl() {
15
- const port = process.env["WARLOCK_TEST_SERVER_PORT"] || config.key("http.port", 2031);
16
+ const publishedPort = process.env[TEST_SERVER_PORT_ENV_KEY];
17
+ const boundPort = getHttpReadyReport()?.port;
18
+ const port = publishedPort || boundPort || config.key("http.port", 2031);
16
19
  return `http://${config.key("http.host", "localhost")}:${port}`;
17
20
  }
18
21
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"test-helpers.mjs","names":[],"sources":["../../../../../../../core/src/tests/test-helpers.ts"],"sourcesContent":["/**\n * Warlock.js Test Helpers\n *\n * Utilities for testing Warlock.js applications.\n */\n\nimport { config } from \"../config\";\nimport { TEST_SERVER_PORT_ENV_KEY } from \"./test-server-port-channel\";\n\n/**\n * Get the test server base URL\n */\nexport function getTestServerUrl(): string {\n // `startHttpTestServer` publishes the port it actually bound. Test workers are\n // separate processes whose own config resolves `http.port` from `.env`, so\n // without this a suite started on an explicit port would send every request to\n // the `.env` port instead of the one the server is listening on.\n const publishedPort = process.env[TEST_SERVER_PORT_ENV_KEY];\n const port = publishedPort || config.key(\"http.port\", 2031);\n const host = config.key(\"http.host\", \"localhost\");\n return `http://${host}:${port}`;\n}\n\n/**\n * Simple HTTP request helper for test server\n * Uses native fetch - lightweight, no extra dependencies\n */\nexport async function testRequest(path: string, options: RequestInit = {}): Promise<Response> {\n return sendRequest(path, options, false);\n}\n\n/**\n * The single place a request is actually built.\n *\n * `serializedJson` says whether THIS module turned the caller's value into a\n * JSON string. It is the only thing that justifies setting a JSON content type:\n * a `FormData` body carries a multipart boundary the runtime generates, and\n * labelling it `application/json` produces a request no server can parse.\n */\nasync function sendRequest(\n path: string,\n options: RequestInit,\n serializedJson: boolean,\n): Promise<Response> {\n const baseUrl = getTestServerUrl();\n const url = path.startsWith(\"/\") ? `${baseUrl}${path}` : `${baseUrl}/${path}`;\n\n // Normalised through `Headers` rather than object-spread: `RequestInit.headers`\n // may be a record, a `Headers` instance, or a list of `[name, value]` tuples,\n // and spreading the last two silently produces an object with numeric keys —\n // the header is simply lost, with no error anywhere.\n const headers = new Headers(options.headers);\n\n if (serializedJson && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n\n return fetch(url, { ...options, headers });\n}\n\n/**\n * Serialize a body for a JSON request.\n *\n * `undefined` means the caller omitted the argument; every other value —\n * including `false`, `0`, `\"\"` and `null` — is a legal JSON document and must\n * be sent. The old check was `body ? JSON.stringify(body) : undefined`, which\n * dropped all four.\n */\nfunction jsonBody(body: unknown): string | undefined {\n return body === undefined ? undefined : JSON.stringify(body);\n}\n\n/**\n * GET request helper\n */\nexport async function testGet(path: string, options: RequestInit = {}): Promise<Response> {\n return testRequest(path, { ...options, method: \"GET\" });\n}\n\n/**\n * POST request helper\n */\nexport async function testPost(\n path: string,\n body?: unknown,\n options: RequestInit = {},\n): Promise<Response> {\n return sendRequest(path, { ...options, method: \"POST\", body: jsonBody(body) }, body !== undefined);\n}\n\n/**\n * PUT request helper\n */\nexport async function testPut(\n path: string,\n body?: unknown,\n options: RequestInit = {},\n): Promise<Response> {\n return sendRequest(path, { ...options, method: \"PUT\", body: jsonBody(body) }, body !== undefined);\n}\n\n/**\n * DELETE request helper\n */\nexport async function testDelete(path: string, options: RequestInit = {}): Promise<Response> {\n return testRequest(path, { ...options, method: \"DELETE\" });\n}\n\n/**\n * PATCH request helper\n */\nexport async function testPatch(\n path: string,\n body?: unknown,\n options: RequestInit = {},\n): Promise<Response> {\n return sendRequest(path, { ...options, method: \"PATCH\", body: jsonBody(body) }, body !== undefined);\n}\n\n/**\n * Parse JSON response with type safety\n */\nexport async function parseJsonResponse<T>(response: Response): Promise<T> {\n return response.json() as Promise<T>;\n}\n\n/**\n * Assert response status and return parsed JSON\n */\nexport async function expectJson<T>(response: Response, expectedStatus = 200): Promise<T> {\n if (response.status !== expectedStatus) {\n const text = await response.text();\n throw new Error(`Expected status ${expectedStatus}, got ${response.status}. Body: ${text}`);\n }\n return parseJsonResponse<T>(response);\n}\n"],"mappings":";;;;;;;;;;;;;AAYA,SAAgB,mBAA2B;CAMzC,MAAM,OADgB,QAAQ,mCACA,OAAO,IAAI,aAAa,IAAI;CAE1D,OAAO,UADM,OAAO,IAAI,aAAa,WACjB,EAAE,GAAG;AAC3B;;;;;AAMA,eAAsB,YAAY,MAAc,UAAuB,CAAC,GAAsB;CAC5F,OAAO,YAAY,MAAM,SAAS,KAAK;AACzC;;;;;;;;;AAUA,eAAe,YACb,MACA,SACA,gBACmB;CACnB,MAAM,UAAU,iBAAiB;CACjC,MAAM,MAAM,KAAK,WAAW,GAAG,IAAI,GAAG,UAAU,SAAS,GAAG,QAAQ,GAAG;CAMvE,MAAM,UAAU,IAAI,QAAQ,QAAQ,OAAO;CAE3C,IAAI,kBAAkB,CAAC,QAAQ,IAAI,cAAc,GAC/C,QAAQ,IAAI,gBAAgB,kBAAkB;CAGhD,OAAO,MAAM,KAAK;EAAE,GAAG;EAAS;CAAQ,CAAC;AAC3C;;;;;;;;;AAUA,SAAS,SAAS,MAAmC;CACnD,OAAO,SAAS,SAAY,SAAY,KAAK,UAAU,IAAI;AAC7D;;;;AAKA,eAAsB,QAAQ,MAAc,UAAuB,CAAC,GAAsB;CACxF,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;CAAM,CAAC;AACxD;;;;AAKA,eAAsB,SACpB,MACA,MACA,UAAuB,CAAC,GACL;CACnB,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;EAAQ,MAAM,SAAS,IAAI;CAAE,GAAG,SAAS,MAAS;AACnG;;;;AAKA,eAAsB,QACpB,MACA,MACA,UAAuB,CAAC,GACL;CACnB,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;EAAO,MAAM,SAAS,IAAI;CAAE,GAAG,SAAS,MAAS;AAClG;;;;AAKA,eAAsB,WAAW,MAAc,UAAuB,CAAC,GAAsB;CAC3F,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;CAAS,CAAC;AAC3D;;;;AAKA,eAAsB,UACpB,MACA,MACA,UAAuB,CAAC,GACL;CACnB,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;EAAS,MAAM,SAAS,IAAI;CAAE,GAAG,SAAS,MAAS;AACpG;;;;AAKA,eAAsB,kBAAqB,UAAgC;CACzE,OAAO,SAAS,KAAK;AACvB;;;;AAKA,eAAsB,WAAc,UAAoB,iBAAiB,KAAiB;CACxF,IAAI,SAAS,WAAW,gBAAgB;EACtC,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,MAAM,IAAI,MAAM,mBAAmB,eAAe,QAAQ,SAAS,OAAO,UAAU,MAAM;CAC5F;CACA,OAAO,kBAAqB,QAAQ;AACtC"}
1
+ {"version":3,"file":"test-helpers.mjs","names":[],"sources":["../../../../../../../core/src/tests/test-helpers.ts"],"sourcesContent":["/**\n * Warlock.js Test Helpers\n *\n * Utilities for testing Warlock.js applications.\n */\n\nimport { config } from \"../config\";\nimport { getHttpReadyReport } from \"../http/ready-report\";\nimport { TEST_SERVER_PORT_ENV_KEY } from \"./test-server-port-channel\";\n\n/**\n * Get the test server base URL\n */\nexport function getTestServerUrl(): string {\n // `startHttpTestServer` publishes the port it actually bound. Test workers are\n // separate processes whose own config resolves `http.port` from `.env`, so\n // without this a suite started on an explicit port would send every request to\n // the `.env` port instead of the one the server is listening on.\n const publishedPort = process.env[TEST_SERVER_PORT_ENV_KEY];\n\n // Same-process fallback: a test that boots the HTTP connector directly\n // (rather than through `startHttpTestServer`'s cross-process channel) never\n // writes `TEST_SERVER_PORT_ENV_KEY`, so without this the next fallback —\n // `config.key(\"http.port\", 2031)` — was the only source left, and it holds\n // the CONFIGURED port, not the bound one. For `http.port: 0` that is `0`,\n // producing `http://host:0` even though `HttpConnector.start()` had already\n // recorded the real bound port right here in `ready-report.ts`.\n const boundPort = getHttpReadyReport()?.port;\n\n // `config.key`'s default only substitutes for `undefined` — `0` is a\n // defined configured value, not an absent one — so it cannot be trusted to\n // paper over an unresolved port 0 either. `boundPort` is read first for\n // that exact reason.\n const port = publishedPort || boundPort || config.key(\"http.port\", 2031);\n const host = config.key(\"http.host\", \"localhost\");\n return `http://${host}:${port}`;\n}\n\n/**\n * Simple HTTP request helper for test server\n * Uses native fetch - lightweight, no extra dependencies\n */\nexport async function testRequest(path: string, options: RequestInit = {}): Promise<Response> {\n return sendRequest(path, options, false);\n}\n\n/**\n * The single place a request is actually built.\n *\n * `serializedJson` says whether THIS module turned the caller's value into a\n * JSON string. It is the only thing that justifies setting a JSON content type:\n * a `FormData` body carries a multipart boundary the runtime generates, and\n * labelling it `application/json` produces a request no server can parse.\n */\nasync function sendRequest(\n path: string,\n options: RequestInit,\n serializedJson: boolean,\n): Promise<Response> {\n const baseUrl = getTestServerUrl();\n const url = path.startsWith(\"/\") ? `${baseUrl}${path}` : `${baseUrl}/${path}`;\n\n // Normalised through `Headers` rather than object-spread: `RequestInit.headers`\n // may be a record, a `Headers` instance, or a list of `[name, value]` tuples,\n // and spreading the last two silently produces an object with numeric keys —\n // the header is simply lost, with no error anywhere.\n const headers = new Headers(options.headers);\n\n if (serializedJson && !headers.has(\"content-type\")) {\n headers.set(\"content-type\", \"application/json\");\n }\n\n return fetch(url, { ...options, headers });\n}\n\n/**\n * Serialize a body for a JSON request.\n *\n * `undefined` means the caller omitted the argument; every other value —\n * including `false`, `0`, `\"\"` and `null` — is a legal JSON document and must\n * be sent. The old check was `body ? JSON.stringify(body) : undefined`, which\n * dropped all four.\n */\nfunction jsonBody(body: unknown): string | undefined {\n return body === undefined ? undefined : JSON.stringify(body);\n}\n\n/**\n * GET request helper\n */\nexport async function testGet(path: string, options: RequestInit = {}): Promise<Response> {\n return testRequest(path, { ...options, method: \"GET\" });\n}\n\n/**\n * POST request helper\n */\nexport async function testPost(\n path: string,\n body?: unknown,\n options: RequestInit = {},\n): Promise<Response> {\n return sendRequest(\n path,\n { ...options, method: \"POST\", body: jsonBody(body) },\n body !== undefined,\n );\n}\n\n/**\n * PUT request helper\n */\nexport async function testPut(\n path: string,\n body?: unknown,\n options: RequestInit = {},\n): Promise<Response> {\n return sendRequest(path, { ...options, method: \"PUT\", body: jsonBody(body) }, body !== undefined);\n}\n\n/**\n * DELETE request helper\n */\nexport async function testDelete(path: string, options: RequestInit = {}): Promise<Response> {\n return testRequest(path, { ...options, method: \"DELETE\" });\n}\n\n/**\n * PATCH request helper\n */\nexport async function testPatch(\n path: string,\n body?: unknown,\n options: RequestInit = {},\n): Promise<Response> {\n return sendRequest(\n path,\n { ...options, method: \"PATCH\", body: jsonBody(body) },\n body !== undefined,\n );\n}\n\n/**\n * Parse JSON response with type safety\n */\nexport async function parseJsonResponse<T>(response: Response): Promise<T> {\n return response.json() as Promise<T>;\n}\n\n/**\n * Assert response status and return parsed JSON\n */\nexport async function expectJson<T>(response: Response, expectedStatus = 200): Promise<T> {\n if (response.status !== expectedStatus) {\n const text = await response.text();\n throw new Error(`Expected status ${expectedStatus}, got ${response.status}. Body: ${text}`);\n }\n return parseJsonResponse<T>(response);\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,SAAgB,mBAA2B;CAKzC,MAAM,gBAAgB,QAAQ,IAAI;CASlC,MAAM,YAAY,mBAAmB,CAAC,EAAE;CAMxC,MAAM,OAAO,iBAAiB,aAAa,OAAO,IAAI,aAAa,IAAI;CAEvE,OAAO,UADM,OAAO,IAAI,aAAa,WACjB,EAAE,GAAG;AAC3B;;;;;AAMA,eAAsB,YAAY,MAAc,UAAuB,CAAC,GAAsB;CAC5F,OAAO,YAAY,MAAM,SAAS,KAAK;AACzC;;;;;;;;;AAUA,eAAe,YACb,MACA,SACA,gBACmB;CACnB,MAAM,UAAU,iBAAiB;CACjC,MAAM,MAAM,KAAK,WAAW,GAAG,IAAI,GAAG,UAAU,SAAS,GAAG,QAAQ,GAAG;CAMvE,MAAM,UAAU,IAAI,QAAQ,QAAQ,OAAO;CAE3C,IAAI,kBAAkB,CAAC,QAAQ,IAAI,cAAc,GAC/C,QAAQ,IAAI,gBAAgB,kBAAkB;CAGhD,OAAO,MAAM,KAAK;EAAE,GAAG;EAAS;CAAQ,CAAC;AAC3C;;;;;;;;;AAUA,SAAS,SAAS,MAAmC;CACnD,OAAO,SAAS,SAAY,SAAY,KAAK,UAAU,IAAI;AAC7D;;;;AAKA,eAAsB,QAAQ,MAAc,UAAuB,CAAC,GAAsB;CACxF,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;CAAM,CAAC;AACxD;;;;AAKA,eAAsB,SACpB,MACA,MACA,UAAuB,CAAC,GACL;CACnB,OAAO,YACL,MACA;EAAE,GAAG;EAAS,QAAQ;EAAQ,MAAM,SAAS,IAAI;CAAE,GACnD,SAAS,MACX;AACF;;;;AAKA,eAAsB,QACpB,MACA,MACA,UAAuB,CAAC,GACL;CACnB,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;EAAO,MAAM,SAAS,IAAI;CAAE,GAAG,SAAS,MAAS;AAClG;;;;AAKA,eAAsB,WAAW,MAAc,UAAuB,CAAC,GAAsB;CAC3F,OAAO,YAAY,MAAM;EAAE,GAAG;EAAS,QAAQ;CAAS,CAAC;AAC3D;;;;AAKA,eAAsB,UACpB,MACA,MACA,UAAuB,CAAC,GACL;CACnB,OAAO,YACL,MACA;EAAE,GAAG;EAAS,QAAQ;EAAS,MAAM,SAAS,IAAI;CAAE,GACpD,SAAS,MACX;AACF;;;;AAKA,eAAsB,kBAAqB,UAAgC;CACzE,OAAO,SAAS,KAAK;AACvB;;;;AAKA,eAAsB,WAAc,UAAoB,iBAAiB,KAAiB;CACxF,IAAI,SAAS,WAAW,gBAAgB;EACtC,MAAM,OAAO,MAAM,SAAS,KAAK;EACjC,MAAM,IAAI,MAAM,mBAAmB,eAAe,QAAQ,SAAS,OAAO,UAAU,MAAM;CAC5F;CACA,OAAO,kBAAqB,QAAQ;AACtC"}
@@ -0,0 +1,67 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+
3
+ //#region ../core/src/utils/detect-environment-overrides.ts
4
+ /**
5
+ * Minimal `KEY=VALUE` line parser: `#` comments, blank lines, and one layer
6
+ * of surrounding quotes.
7
+ *
8
+ * Deliberately NOT `@mongez/dotenv`'s own `parseLine` / `parseValue`: those
9
+ * live in the same module as `loadEnv`, and `tests/unit/utils/load-environment.test.ts`
10
+ * replaces that whole module with `vi.mock("@mongez/dotenv", () => ({ loadEnv:
11
+ * ... }))`. Importing any other named export from `@mongez/dotenv` here would
12
+ * resolve to `undefined` under that mock and crash detection during those
13
+ * tests. Comments, blank lines and a surrounding quote pair are the entire
14
+ * feature set this comparison needs — the actual load still goes through the
15
+ * real `@mongez/dotenv` parser via `loadEnv()`.
16
+ */
17
+ function parseEnvLine(line) {
18
+ const trimmed = line.trim();
19
+ if (!trimmed || trimmed.startsWith("#") || !trimmed.includes("=")) return;
20
+ const separatorIndex = trimmed.indexOf("=");
21
+ const key = trimmed.slice(0, separatorIndex).trim();
22
+ let value = trimmed.slice(separatorIndex + 1).trim();
23
+ const quote = value[0];
24
+ if ((quote === "\"" || quote === "'" || quote === "`") && value.length > 1 && value.endsWith(quote)) value = value.slice(1, -1);
25
+ return [key, value];
26
+ }
27
+ /**
28
+ * Which of the given `.env` files' keys lost to a value the ambient process
29
+ * environment already carried.
30
+ *
31
+ * `snapshot` must be captured BEFORE `loadEnv()` runs: the loader writes into
32
+ * `process.env` for every key the file supplies, so a snapshot taken
33
+ * afterwards can no longer tell an ambient override from a value the loader
34
+ * itself just wrote.
35
+ *
36
+ * `files` should be given in the same order `loadEnv()` actually reads them
37
+ * (`.env.shared` first, then the applicable `.env`/`.env.<NODE_ENV>`), so a
38
+ * key declared in more than one file resolves to the value the real loader
39
+ * would have used absent any ambient override.
40
+ */
41
+ function detectEnvironmentOverrides(files, snapshot) {
42
+ const fileValues = /* @__PURE__ */ new Map();
43
+ for (const file of files) {
44
+ if (!existsSync(file)) continue;
45
+ const lines = readFileSync(file, "utf8").split(/\r\n|\n/);
46
+ for (const line of lines) {
47
+ const parsed = parseEnvLine(line);
48
+ if (!parsed) continue;
49
+ fileValues.set(parsed[0], parsed[1]);
50
+ }
51
+ }
52
+ const overrides = [];
53
+ for (const [key, fileValue] of fileValues) {
54
+ if (!Object.prototype.hasOwnProperty.call(snapshot, key)) continue;
55
+ const effectiveValue = snapshot[key];
56
+ if (effectiveValue !== fileValue) overrides.push({
57
+ key,
58
+ effectiveValue,
59
+ fileValue
60
+ });
61
+ }
62
+ return overrides;
63
+ }
64
+
65
+ //#endregion
66
+ export { detectEnvironmentOverrides };
67
+ //# sourceMappingURL=detect-environment-overrides.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"detect-environment-overrides.mjs","names":[],"sources":["../../../../../../../core/src/utils/detect-environment-overrides.ts"],"sourcesContent":["import { existsSync, readFileSync } from \"node:fs\";\n\n/**\n * One `.env` key whose file value lost to an ambient `process.env` value that\n * was already set before the file was read.\n */\nexport type EnvironmentOverride = {\n key: string;\n effectiveValue: string;\n fileValue: string;\n};\n\n/**\n * Minimal `KEY=VALUE` line parser: `#` comments, blank lines, and one layer\n * of surrounding quotes.\n *\n * Deliberately NOT `@mongez/dotenv`'s own `parseLine` / `parseValue`: those\n * live in the same module as `loadEnv`, and `tests/unit/utils/load-environment.test.ts`\n * replaces that whole module with `vi.mock(\"@mongez/dotenv\", () => ({ loadEnv:\n * ... }))`. Importing any other named export from `@mongez/dotenv` here would\n * resolve to `undefined` under that mock and crash detection during those\n * tests. Comments, blank lines and a surrounding quote pair are the entire\n * feature set this comparison needs — the actual load still goes through the\n * real `@mongez/dotenv` parser via `loadEnv()`.\n */\nfunction parseEnvLine(line: string): [string, string] | undefined {\n const trimmed = line.trim();\n\n if (!trimmed || trimmed.startsWith(\"#\") || !trimmed.includes(\"=\")) {\n return undefined;\n }\n\n const separatorIndex = trimmed.indexOf(\"=\");\n const key = trimmed.slice(0, separatorIndex).trim();\n let value = trimmed.slice(separatorIndex + 1).trim();\n\n const quote = value[0];\n\n if (\n (quote === '\"' || quote === \"'\" || quote === \"`\") &&\n value.length > 1 &&\n value.endsWith(quote)\n ) {\n value = value.slice(1, -1);\n }\n\n return [key, value];\n}\n\n/**\n * Which of the given `.env` files' keys lost to a value the ambient process\n * environment already carried.\n *\n * `snapshot` must be captured BEFORE `loadEnv()` runs: the loader writes into\n * `process.env` for every key the file supplies, so a snapshot taken\n * afterwards can no longer tell an ambient override from a value the loader\n * itself just wrote.\n *\n * `files` should be given in the same order `loadEnv()` actually reads them\n * (`.env.shared` first, then the applicable `.env`/`.env.<NODE_ENV>`), so a\n * key declared in more than one file resolves to the value the real loader\n * would have used absent any ambient override.\n */\nexport function detectEnvironmentOverrides(\n files: string[],\n snapshot: NodeJS.ProcessEnv,\n): EnvironmentOverride[] {\n const fileValues = new Map<string, string>();\n\n for (const file of files) {\n if (!existsSync(file)) {\n continue;\n }\n\n const lines = readFileSync(file, \"utf8\").split(/\\r\\n|\\n/);\n\n for (const line of lines) {\n const parsed = parseEnvLine(line);\n\n if (!parsed) {\n continue;\n }\n\n fileValues.set(parsed[0], parsed[1]);\n }\n }\n\n const overrides: EnvironmentOverride[] = [];\n\n for (const [key, fileValue] of fileValues) {\n if (!Object.prototype.hasOwnProperty.call(snapshot, key)) {\n continue;\n }\n\n const effectiveValue = snapshot[key] as string;\n\n if (effectiveValue !== fileValue) {\n overrides.push({ key, effectiveValue, fileValue });\n }\n }\n\n return overrides;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAyBA,SAAS,aAAa,MAA4C;CAChE,MAAM,UAAU,KAAK,KAAK;CAE1B,IAAI,CAAC,WAAW,QAAQ,WAAW,GAAG,KAAK,CAAC,QAAQ,SAAS,GAAG,GAC9D;CAGF,MAAM,iBAAiB,QAAQ,QAAQ,GAAG;CAC1C,MAAM,MAAM,QAAQ,MAAM,GAAG,cAAc,CAAC,CAAC,KAAK;CAClD,IAAI,QAAQ,QAAQ,MAAM,iBAAiB,CAAC,CAAC,CAAC,KAAK;CAEnD,MAAM,QAAQ,MAAM;CAEpB,KACG,UAAU,QAAO,UAAU,OAAO,UAAU,QAC7C,MAAM,SAAS,KACf,MAAM,SAAS,KAAK,GAEpB,QAAQ,MAAM,MAAM,GAAG,EAAE;CAG3B,OAAO,CAAC,KAAK,KAAK;AACpB;;;;;;;;;;;;;;;AAgBA,SAAgB,2BACd,OACA,UACuB;CACvB,MAAM,6BAAa,IAAI,IAAoB;CAE3C,KAAK,MAAM,QAAQ,OAAO;EACxB,IAAI,CAAC,WAAW,IAAI,GAClB;EAGF,MAAM,QAAQ,aAAa,MAAM,MAAM,CAAC,CAAC,MAAM,SAAS;EAExD,KAAK,MAAM,QAAQ,OAAO;GACxB,MAAM,SAAS,aAAa,IAAI;GAEhC,IAAI,CAAC,QACH;GAGF,WAAW,IAAI,OAAO,IAAI,OAAO,EAAE;EACrC;CACF;CAEA,MAAM,YAAmC,CAAC;CAE1C,KAAK,MAAM,CAAC,KAAK,cAAc,YAAY;EACzC,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,UAAU,GAAG,GACrD;EAGF,MAAM,iBAAiB,SAAS;EAEhC,IAAI,mBAAmB,WACrB,UAAU,KAAK;GAAE;GAAK;GAAgB;EAAU,CAAC;CAErD;CAEA,OAAO;AACT"}
@@ -1,3 +1,5 @@
1
+ import { detectEnvironmentOverrides } from "./detect-environment-overrides.mjs";
2
+ import { reportEnvironmentOverrides } from "./report-environment-overrides.mjs";
1
3
  import { loadEnv } from "@mongez/dotenv";
2
4
  import path from "node:path";
3
5
  import { existsSync } from "node:fs";
@@ -44,6 +46,26 @@ function candidateEnvFiles(directory) {
44
46
  return files;
45
47
  }
46
48
  /**
49
+ * The files `loadEnv()` will actually read, in its own read order.
50
+ *
51
+ * Differs from {@link candidateEnvFiles}, which lists every candidate to
52
+ * answer "is there anything to load at all". `loadEnv()` itself only ever
53
+ * reads `.env.shared` (if present) plus ONE of `.env.<NODE_ENV>` /
54
+ * `.env` — the NODE_ENV-specific file when it exists, `.env` otherwise —
55
+ * never both. Override detection has to mirror that exactly, or a key
56
+ * declared only in a file `loadEnv()` would not have touched could be
57
+ * reported as having "lost" a comparison it was never actually part of.
58
+ */
59
+ function resolveLoadedEnvFiles(directory) {
60
+ const files = [];
61
+ const sharedFile = path.join(directory, ".env.shared");
62
+ if (existsSync(sharedFile)) files.push(sharedFile);
63
+ const nodeEnvFile = process.env.NODE_ENV ? path.join(directory, `.env.${process.env.NODE_ENV}`) : void 0;
64
+ const derivedFile = nodeEnvFile && existsSync(nodeEnvFile) ? nodeEnvFile : path.join(directory, ".env");
65
+ if (existsSync(derivedFile)) files.push(derivedFile);
66
+ return files;
67
+ }
68
+ /**
47
69
  * Whether this process has already loaded its env files.
48
70
  *
49
71
  * Two callers reach here on a bootstrapping command — the CLI preload phase and
@@ -79,6 +101,8 @@ async function loadEnvironmentFiles(directory = process.cwd()) {
79
101
  if (environmentLoaded) return;
80
102
  if (!candidateEnvFiles(directory).some((file) => existsSync(file))) return;
81
103
  environmentLoaded = true;
104
+ const processEnvironmentSnapshot = { ...process.env };
105
+ reportEnvironmentOverrides(detectEnvironmentOverrides(resolveLoadedEnvFiles(directory), processEnvironmentSnapshot));
82
106
  await loadEnv(void 0, {
83
107
  ...environmentLoaderOptions,
84
108
  dir: directory
@@ -1 +1 @@
1
- {"version":3,"file":"load-environment.mjs","names":[],"sources":["../../../../../../../core/src/utils/load-environment.ts"],"sourcesContent":["import { loadEnv, type EnvLoaderOptions } from \"@mongez/dotenv\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\n\n/**\n * Core's env precedence policy, in one place because two callers load env:\n * this module at boot, and the dev server when an `.env` file changes.\n *\n * `@mongez/dotenv` defaults to `precedence: \"file-wins\"`, which is backwards.\n * The `.env` file is a checked-in DEFAULT; a variable already exported into\n * `process.env` is the deliberate, situational override — a second instance on\n * another port, CI pointed at another database, a container's configuration.\n * Under the default, `PORT=6060 warlock dev` produced a server on the `.env`\n * file's 3000 and said nothing. A default that silently beats an explicit\n * instruction is the wrong way round, so core opts into `process-wins`:\n * the file supplies only the keys the environment does not already carry.\n *\n * EMPTY STRING — `FOO=` in the environment counts as SET, and wins, so the\n * file's value is discarded and `env(\"FOO\")` returns `\"\"`. Chosen over\n * \"empty means absent\" because the loader cannot tell a deliberate blanking\n * from an accident, and only one of the two readings is expressible: an\n * operator who wants the file's value can unset the variable, whereas under\n * \"empty means absent\" an operator who wants a blank value has no way to ask\n * for one. It also matches POSIX (an exported empty variable is set), dotenv,\n * dotenv-flow and Vite. Note the consequence: a blank export beats the second\n * argument too, so `env(\"FOO\", \"fallback\")` yields `\"\"`, not the fallback.\n *\n * Keys this loader itself wrote are tracked by the library and are NOT treated\n * as process-provided, so editing `.env` during a dev session still takes\n * effect on reload rather than being pinned by the previous load's own writes.\n */\nexport const environmentLoaderOptions: EnvLoaderOptions = {\n precedence: \"process-wins\",\n};\n\n/**\n * The files `loadEnv()` will look for, in the order it considers them.\n *\n * Mirrors `@mongez/dotenv`'s own resolution — `.env.shared` first, then\n * `.env.<NODE_ENV>` if it exists, else plain `.env` — because the presence\n * check has to ask the same question the loader is about to ask.\n */\nfunction candidateEnvFiles(directory: string): string[] {\n const files = [path.join(directory, \".env\"), path.join(directory, \".env.shared\")];\n\n if (process.env.NODE_ENV) {\n files.push(path.join(directory, `.env.${process.env.NODE_ENV}`));\n }\n\n return files;\n}\n\n/**\n * Whether this process has already loaded its env files.\n *\n * Two callers reach here on a bootstrapping command — the CLI preload phase and\n * `bootstrap()` — and `loadEnv()` defaults to `override: true`, so a second pass\n * re-parses the same files and re-writes `process.env`. Anything set in between\n * (a `warlock.config.ts` module body, a config file, an orchestrator) would be\n * silently clobbered by the later load. Same shape as the defect where a\n * caller's test-server port was overwritten by a re-read of `.env`.\n *\n * A deliberate reload is still possible and still used: the dev server calls\n * `loadEnv()` directly when an `.env` file changes on disk, which is a reload\n * the user asked for rather than an accidental second pass.\n */\nlet environmentLoaded = false;\n\n/**\n * Load `.env` files when there are any, and do nothing when there are not.\n *\n * Loads at most **once per process** — see {@link environmentLoaded}.\n *\n * `@mongez/dotenv`'s `loadEnvFile` **throws** when the file is absent. That was\n * survivable while only `dev` loaded env; now that every command loads it —\n * because `warlock.config.ts` calls `env()` in its module body and had been\n * evaluated against an empty store under every command — an unguarded call\n * would turn \"this project has no .env\" from a non-event into a hard failure\n * of `warlock build`. A project without a `.env` is legitimate: config files\n * carry defaults, and containers inject their own variables.\n *\n * The guard lives here rather than waiting on a fix in `@mongez/dotenv`: core\n * decides its own env policy and must not depend on another package's release\n * schedule to avoid crashing a working build.\n */\nexport async function loadEnvironmentFiles(directory: string = process.cwd()): Promise<void> {\n if (environmentLoaded) {\n return;\n }\n\n const hasEnvFile = candidateEnvFiles(directory).some((file) => existsSync(file));\n\n if (!hasEnvFile) {\n // Not latched: a project may create its `.env` between a command's preload\n // and its bootstrap, and there is nothing to protect from a second override\n // when the first pass loaded nothing.\n return;\n }\n\n environmentLoaded = true;\n\n // `dir` has to be forwarded: `loadEnv()` defaults it to `process.cwd()`, so\n // without this the existence check above asks about `directory` while the\n // load itself reads somewhere else entirely. Identical in production, where\n // `directory` IS `process.cwd()`.\n await loadEnv(undefined, { ...environmentLoaderOptions, dir: directory });\n}\n\n/**\n * Forget that env has been loaded, so the next call loads again.\n *\n * @internal For tests, which run many boots inside one process.\n */\nexport function resetLoadedEnvironment(): void {\n environmentLoaded = false;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,MAAa,2BAA6C,EACxD,YAAY,eACd;;;;;;;;AASA,SAAS,kBAAkB,WAA6B;CACtD,MAAM,QAAQ,CAAC,KAAK,KAAK,WAAW,MAAM,GAAG,KAAK,KAAK,WAAW,aAAa,CAAC;CAEhF,IAAI,QAAQ,IAAI,UACd,MAAM,KAAK,KAAK,KAAK,WAAW,QAAQ,QAAQ,IAAI,UAAU,CAAC;CAGjE,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,IAAI,oBAAoB;;;;;;;;;;;;;;;;;;AAmBxB,eAAsB,qBAAqB,YAAoB,QAAQ,IAAI,GAAkB;CAC3F,IAAI,mBACF;CAKF,IAAI,CAFe,kBAAkB,SAAS,CAAC,CAAC,MAAM,SAAS,WAAW,IAAI,CAEhE,GAIZ;CAGF,oBAAoB;CAMpB,MAAM,QAAQ,QAAW;EAAE,GAAG;EAA0B,KAAK;CAAU,CAAC;AAC1E"}
1
+ {"version":3,"file":"load-environment.mjs","names":[],"sources":["../../../../../../../core/src/utils/load-environment.ts"],"sourcesContent":["import { loadEnv, type EnvLoaderOptions } from \"@mongez/dotenv\";\nimport { existsSync } from \"node:fs\";\nimport path from \"node:path\";\nimport { detectEnvironmentOverrides } from \"./detect-environment-overrides\";\nimport { reportEnvironmentOverrides } from \"./report-environment-overrides\";\n\n/**\n * Core's env precedence policy, in one place because two callers load env:\n * this module at boot, and the dev server when an `.env` file changes.\n *\n * `@mongez/dotenv` defaults to `precedence: \"file-wins\"`, which is backwards.\n * The `.env` file is a checked-in DEFAULT; a variable already exported into\n * `process.env` is the deliberate, situational override — a second instance on\n * another port, CI pointed at another database, a container's configuration.\n * Under the default, `PORT=6060 warlock dev` produced a server on the `.env`\n * file's 3000 and said nothing. A default that silently beats an explicit\n * instruction is the wrong way round, so core opts into `process-wins`:\n * the file supplies only the keys the environment does not already carry.\n *\n * EMPTY STRING — `FOO=` in the environment counts as SET, and wins, so the\n * file's value is discarded and `env(\"FOO\")` returns `\"\"`. Chosen over\n * \"empty means absent\" because the loader cannot tell a deliberate blanking\n * from an accident, and only one of the two readings is expressible: an\n * operator who wants the file's value can unset the variable, whereas under\n * \"empty means absent\" an operator who wants a blank value has no way to ask\n * for one. It also matches POSIX (an exported empty variable is set), dotenv,\n * dotenv-flow and Vite. Note the consequence: a blank export beats the second\n * argument too, so `env(\"FOO\", \"fallback\")` yields `\"\"`, not the fallback.\n *\n * Keys this loader itself wrote are tracked by the library and are NOT treated\n * as process-provided, so editing `.env` during a dev session still takes\n * effect on reload rather than being pinned by the previous load's own writes.\n */\nexport const environmentLoaderOptions: EnvLoaderOptions = {\n precedence: \"process-wins\",\n};\n\n/**\n * The files `loadEnv()` will look for, in the order it considers them.\n *\n * Mirrors `@mongez/dotenv`'s own resolution — `.env.shared` first, then\n * `.env.<NODE_ENV>` if it exists, else plain `.env` — because the presence\n * check has to ask the same question the loader is about to ask.\n */\nfunction candidateEnvFiles(directory: string): string[] {\n const files = [path.join(directory, \".env\"), path.join(directory, \".env.shared\")];\n\n if (process.env.NODE_ENV) {\n files.push(path.join(directory, `.env.${process.env.NODE_ENV}`));\n }\n\n return files;\n}\n\n/**\n * The files `loadEnv()` will actually read, in its own read order.\n *\n * Differs from {@link candidateEnvFiles}, which lists every candidate to\n * answer \"is there anything to load at all\". `loadEnv()` itself only ever\n * reads `.env.shared` (if present) plus ONE of `.env.<NODE_ENV>` /\n * `.env` — the NODE_ENV-specific file when it exists, `.env` otherwise —\n * never both. Override detection has to mirror that exactly, or a key\n * declared only in a file `loadEnv()` would not have touched could be\n * reported as having \"lost\" a comparison it was never actually part of.\n */\nfunction resolveLoadedEnvFiles(directory: string): string[] {\n const files: string[] = [];\n const sharedFile = path.join(directory, \".env.shared\");\n\n if (existsSync(sharedFile)) {\n files.push(sharedFile);\n }\n\n const nodeEnvFile = process.env.NODE_ENV\n ? path.join(directory, `.env.${process.env.NODE_ENV}`)\n : undefined;\n const derivedFile =\n nodeEnvFile && existsSync(nodeEnvFile) ? nodeEnvFile : path.join(directory, \".env\");\n\n if (existsSync(derivedFile)) {\n files.push(derivedFile);\n }\n\n return files;\n}\n\n/**\n * Whether this process has already loaded its env files.\n *\n * Two callers reach here on a bootstrapping command — the CLI preload phase and\n * `bootstrap()` — and `loadEnv()` defaults to `override: true`, so a second pass\n * re-parses the same files and re-writes `process.env`. Anything set in between\n * (a `warlock.config.ts` module body, a config file, an orchestrator) would be\n * silently clobbered by the later load. Same shape as the defect where a\n * caller's test-server port was overwritten by a re-read of `.env`.\n *\n * A deliberate reload is still possible and still used: the dev server calls\n * `loadEnv()` directly when an `.env` file changes on disk, which is a reload\n * the user asked for rather than an accidental second pass.\n */\nlet environmentLoaded = false;\n\n/**\n * Load `.env` files when there are any, and do nothing when there are not.\n *\n * Loads at most **once per process** — see {@link environmentLoaded}.\n *\n * `@mongez/dotenv`'s `loadEnvFile` **throws** when the file is absent. That was\n * survivable while only `dev` loaded env; now that every command loads it —\n * because `warlock.config.ts` calls `env()` in its module body and had been\n * evaluated against an empty store under every command — an unguarded call\n * would turn \"this project has no .env\" from a non-event into a hard failure\n * of `warlock build`. A project without a `.env` is legitimate: config files\n * carry defaults, and containers inject their own variables.\n *\n * The guard lives here rather than waiting on a fix in `@mongez/dotenv`: core\n * decides its own env policy and must not depend on another package's release\n * schedule to avoid crashing a working build.\n */\nexport async function loadEnvironmentFiles(directory: string = process.cwd()): Promise<void> {\n if (environmentLoaded) {\n return;\n }\n\n const hasEnvFile = candidateEnvFiles(directory).some((file) => existsSync(file));\n\n if (!hasEnvFile) {\n // Not latched: a project may create its `.env` between a command's preload\n // and its bootstrap, and there is nothing to protect from a second override\n // when the first pass loaded nothing.\n return;\n }\n\n environmentLoaded = true;\n\n // Snapshot BEFORE `loadEnv()` runs: the loader writes into `process.env` for\n // every key its files supply, so a snapshot taken afterwards could no\n // longer distinguish an ambient override from a value the loader itself\n // just wrote — see `detectEnvironmentOverrides`.\n const processEnvironmentSnapshot = { ...process.env };\n\n // The precedence itself is correct (see `environmentLoaderOptions` above);\n // the defect this guards against is the SILENCE — `.env` losing to an\n // ambient value with no diagnostic at all.\n reportEnvironmentOverrides(\n detectEnvironmentOverrides(resolveLoadedEnvFiles(directory), processEnvironmentSnapshot),\n );\n\n // `dir` has to be forwarded: `loadEnv()` defaults it to `process.cwd()`, so\n // without this the existence check above asks about `directory` while the\n // load itself reads somewhere else entirely. Identical in production, where\n // `directory` IS `process.cwd()`.\n await loadEnv(undefined, { ...environmentLoaderOptions, dir: directory });\n}\n\n/**\n * Forget that env has been loaded, so the next call loads again.\n *\n * @internal For tests, which run many boots inside one process.\n */\nexport function resetLoadedEnvironment(): void {\n environmentLoaded = false;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAiCA,MAAa,2BAA6C,EACxD,YAAY,eACd;;;;;;;;AASA,SAAS,kBAAkB,WAA6B;CACtD,MAAM,QAAQ,CAAC,KAAK,KAAK,WAAW,MAAM,GAAG,KAAK,KAAK,WAAW,aAAa,CAAC;CAEhF,IAAI,QAAQ,IAAI,UACd,MAAM,KAAK,KAAK,KAAK,WAAW,QAAQ,QAAQ,IAAI,UAAU,CAAC;CAGjE,OAAO;AACT;;;;;;;;;;;;AAaA,SAAS,sBAAsB,WAA6B;CAC1D,MAAM,QAAkB,CAAC;CACzB,MAAM,aAAa,KAAK,KAAK,WAAW,aAAa;CAErD,IAAI,WAAW,UAAU,GACvB,MAAM,KAAK,UAAU;CAGvB,MAAM,cAAc,QAAQ,IAAI,WAC5B,KAAK,KAAK,WAAW,QAAQ,QAAQ,IAAI,UAAU,IACnD;CACJ,MAAM,cACJ,eAAe,WAAW,WAAW,IAAI,cAAc,KAAK,KAAK,WAAW,MAAM;CAEpF,IAAI,WAAW,WAAW,GACxB,MAAM,KAAK,WAAW;CAGxB,OAAO;AACT;;;;;;;;;;;;;;;AAgBA,IAAI,oBAAoB;;;;;;;;;;;;;;;;;;AAmBxB,eAAsB,qBAAqB,YAAoB,QAAQ,IAAI,GAAkB;CAC3F,IAAI,mBACF;CAKF,IAAI,CAFe,kBAAkB,SAAS,CAAC,CAAC,MAAM,SAAS,WAAW,IAAI,CAEhE,GAIZ;CAGF,oBAAoB;CAMpB,MAAM,6BAA6B,EAAE,GAAG,QAAQ,IAAI;CAKpD,2BACE,2BAA2B,sBAAsB,SAAS,GAAG,0BAA0B,CACzF;CAMA,MAAM,QAAQ,QAAW;EAAE,GAAG;EAA0B,KAAK;CAAU,CAAC;AAC1E"}
@@ -0,0 +1,30 @@
1
+ //#region ../core/src/utils/report-environment-overrides.ts
2
+ /**
3
+ * Key names shaped like a secret. Matched values are redacted in the printed
4
+ * line even though the key itself is still named — knowing WHICH secret was
5
+ * overridden is useful, the value never is.
6
+ */
7
+ const SECRET_KEY_PATTERN = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL|DSN|CONNECTION_STRING)/i;
8
+ const REDACTED_PLACEHOLDER = "<redacted>";
9
+ /**
10
+ * Tell the developer, one line per key, which `.env` values the ambient
11
+ * process environment silently beat.
12
+ *
13
+ * Uses `console.warn` (stderr) rather than the `log` singleton on purpose:
14
+ * this runs from `loadEnvironmentFiles()`, ahead of the logger connector in
15
+ * the boot sequence, so any channel configured through `log.*` has nowhere to
16
+ * deliver to yet. Same reasoning as `console.error` in
17
+ * `src/http/boot-port-preflight.ts`.
18
+ */
19
+ function reportEnvironmentOverrides(overrides) {
20
+ for (const override of overrides) {
21
+ const isSecretShaped = SECRET_KEY_PATTERN.test(override.key);
22
+ const effectiveValue = isSecretShaped ? REDACTED_PLACEHOLDER : override.effectiveValue;
23
+ const fileValue = isSecretShaped ? REDACTED_PLACEHOLDER : override.fileValue;
24
+ console.warn(`[env] ${override.key}=${effectiveValue} is in effect from the process environment, overriding .env's ${override.key}=${fileValue}`);
25
+ }
26
+ }
27
+
28
+ //#endregion
29
+ export { reportEnvironmentOverrides };
30
+ //# sourceMappingURL=report-environment-overrides.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report-environment-overrides.mjs","names":[],"sources":["../../../../../../../core/src/utils/report-environment-overrides.ts"],"sourcesContent":["import type { EnvironmentOverride } from \"./detect-environment-overrides\";\n\n/**\n * Key names shaped like a secret. Matched values are redacted in the printed\n * line even though the key itself is still named — knowing WHICH secret was\n * overridden is useful, the value never is.\n */\nconst SECRET_KEY_PATTERN = /(PASSWORD|SECRET|TOKEN|KEY|CREDENTIAL|DSN|CONNECTION_STRING)/i;\n\nconst REDACTED_PLACEHOLDER = \"<redacted>\";\n\n/**\n * Tell the developer, one line per key, which `.env` values the ambient\n * process environment silently beat.\n *\n * Uses `console.warn` (stderr) rather than the `log` singleton on purpose:\n * this runs from `loadEnvironmentFiles()`, ahead of the logger connector in\n * the boot sequence, so any channel configured through `log.*` has nowhere to\n * deliver to yet. Same reasoning as `console.error` in\n * `src/http/boot-port-preflight.ts`.\n */\nexport function reportEnvironmentOverrides(overrides: EnvironmentOverride[]): void {\n for (const override of overrides) {\n const isSecretShaped = SECRET_KEY_PATTERN.test(override.key);\n const effectiveValue = isSecretShaped ? REDACTED_PLACEHOLDER : override.effectiveValue;\n const fileValue = isSecretShaped ? REDACTED_PLACEHOLDER : override.fileValue;\n\n console.warn(\n `[env] ${override.key}=${effectiveValue} is in effect from the process environment, overriding .env's ${override.key}=${fileValue}`,\n );\n }\n}\n"],"mappings":";;;;;;AAOA,MAAM,qBAAqB;AAE3B,MAAM,uBAAuB;;;;;;;;;;;AAY7B,SAAgB,2BAA2B,WAAwC;CACjF,KAAK,MAAM,YAAY,WAAW;EAChC,MAAM,iBAAiB,mBAAmB,KAAK,SAAS,GAAG;EAC3D,MAAM,iBAAiB,iBAAiB,uBAAuB,SAAS;EACxE,MAAM,YAAY,iBAAiB,uBAAuB,SAAS;EAEnE,QAAQ,KACN,SAAS,SAAS,IAAI,GAAG,eAAe,gEAAgE,SAAS,IAAI,GAAG,WAC1H;CACF;AACF"}
@@ -22,7 +22,10 @@ declare module "@warlock.js/seal" {
22
22
  file: (errorMessage?: string) => FileValidator & StandardSchemaV1<UploadedFile>;
23
23
  localized: <T extends BaseValidator = ScalarValidator & StandardSchemaV1<string | number | boolean>>(valueValidator?: T, errorMessage?: string) => ArrayValidator & {
24
24
  validator: LocalizedEntryValidator<T>;
25
- };
25
+ } & StandardSchemaV1<Array<{
26
+ localeCode: string;
27
+ value: Infer<T>;
28
+ }>>;
26
29
  }
27
30
  interface ScalarValidator {
28
31
  /** Value must be unique in database except current user */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../core/src/validation/types.ts"],"mappings":";;;;;;;;;;;;KAmCK,uBAAA,WAAkC,aAAA,IAAiB,eAAA;EACtD,UAAA,EAAY,eAAA,GAAkB,gBAAA;EAC9B,KAAA,EAAO,CAAA;AAAA,KAEP,gBAAA;EACE,UAAA;EACA,KAAA,EAAO,KAAA,CAAM,CAAA;AAAA;AAAA;EAAA,UAME,UAAA;IACf,IAAA,GAAO,YAAA,cAA0B,aAAA,GAAgB,gBAAA,CAAiB,YAAA;IAClE,SAAA,aACY,aAAA,GAAgB,eAAA,GAAkB,gBAAA,6BAE5C,cAAA,GAAiB,CAAA,EACjB,YAAA,cACG,cAAA;MACH,SAAA,EAAW,uBAAA,CAAwB,CAAA;IAAA;EAAA;EAAA,UAI7B,eAAA;IAlBR;IAoBA,uBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,kCAAA;MACpB,YAAA;IAAA;IAvBU;IA4Bd,qBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,gCAAA;MACpB,YAAA;IAAA;IAxB6B;IA6BjC,uBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,kCAAA;MACpB,YAAA;IAAA;IA9BwB;IAmC5B,qBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,gCAAA;MACpB,YAAA;IAAA;EAAA;EAAA,UAMI,eAAA;IACR,uBAAA,EAAyB,eAAA;IACzB,qBAAA,EAAuB,eAAA;IACvB,uBAAA,EAAyB,eAAA;IACzB,qBAAA,EAAuB,eAAA;EAAA;AAAA;AAAA,KAYf,uBAAA;EAhCY;;;;;;EAwCtB,gBAAA;EA/BkB;;;;;;EAsClB,cAAA;EA9EiB;;;;EAmFjB,cAAA;EAlFoE;;;;EAuFpE,aAAA,GAAgB,qBAAA;EArFgC;;;EAyFhD,kBAAA,GAAqB,0BAA0B;AAAA"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../core/src/validation/types.ts"],"mappings":";;;;;;;;;;;;KAmCK,uBAAA,WAAkC,aAAA,IAAiB,eAAA;EACtD,UAAA,EAAY,eAAA,GAAkB,gBAAA;EAC9B,KAAA,EAAO,CAAA;AAAA,KAEP,gBAAA;EACE,UAAA;EACA,KAAA,EAAO,KAAA,CAAM,CAAA;AAAA;AAAA;EAAA,UAME,UAAA;IACf,IAAA,GAAO,YAAA,cAA0B,aAAA,GAAgB,gBAAA,CAAiB,YAAA;IAClE,SAAA,aACY,aAAA,GAAgB,eAAA,GAAkB,gBAAA,6BAE5C,cAAA,GAAiB,CAAA,EACjB,YAAA,cACG,cAAA;MACH,SAAA,EAAW,uBAAA,CAAwB,CAAA;IAAA,IACjC,gBAAA,CACA,KAAA;MACE,UAAA;MACA,KAAA,EAAO,KAAA,CAAM,CAAA;IAAA;EAAA;EAAA,UAKX,eAAA;IAvBM;IAyBd,uBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,kCAAA;MACpB,YAAA;IAAA;IA5BU;IAiCd,qBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,gCAAA;MACpB,YAAA;IAAA;IA3BQ;IAgCZ,uBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,kCAAA;MACpB,YAAA;IAAA;IA/BC;IAoCL,qBAAA,CACE,KAAA,EAAO,UAAA,CAAW,KAAA,YAClB,WAAA,GAAc,OAAA,CAAQ,gCAAA;MACpB,YAAA;IAAA;EAAA;EAAA,UAMI,eAAA;IACR,uBAAA,EAAyB,eAAA;IACzB,qBAAA,EAAuB,eAAA;IACvB,uBAAA,EAAyB,eAAA;IACzB,qBAAA,EAAuB,eAAA;EAAA;AAAA;AAAA,KAYf,uBAAA;EAvCQ;;;;;;EA+ClB,gBAAA;EA/B0B;;;;;;EAsC1B,cAAA;EA3BwC;;;;EAgCxC,cAAA;EAvFmD;;;;EA4FnD,aAAA,GAAgB,qBAAA;EA1Fc;;;EA8F9B,kBAAA,GAAqB,0BAA0B;AAAA"}