@warlock.js/core 5.2.2 → 5.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  > ⚠ **Versioning: `@warlock.js/*` does not follow SemVer strictly — breaking changes may ship in a minor.** This is a deliberate decision, not an oversight: the framework is pre-adoption and the cost of a major per behaviour fix currently outweighs the benefit. **Pin an exact version or a tilde range (`~4.13.0`) if you need to opt into changes rather than receive them.** Every breaking change is marked **BREAKING** in its entry and summarised in an *Upgrading* section at the top of the release. **This policy will change once the framework has consumers beyond its author.**
8
8
 
9
+ ## 5.2.3 - 2026-09-02
10
+
11
+ ### Fixed
12
+
13
+ - `warlock add` now preserves exact lockstep versions for added Warlock family packages while retaining declared ranges for third-party dependencies.
14
+ - `warlock add web` now emits a projection-safe `register()` hook and one `index` route identity for SSR and hydration, with deterministic form and favicon markup for a clean browser console.
15
+
9
16
  ## 5.2.2
10
17
 
11
18
  ### Fixed
@@ -1,7 +1,7 @@
1
1
  import { rootPath, srcPath } from "../utils/paths.mjs";
2
2
  import "../utils/index.mjs";
3
3
  import { getWarlockVersion } from "../utils/framework-vesion.mjs";
4
- import { detectPackageManager, getAddCommand } from "../updater/package-manager.mjs";
4
+ import { detectPackageManager, getAddCommand, getExactAddCommand } from "../updater/package-manager.mjs";
5
5
  import { featuresMap } from "./features/index.mjs";
6
6
  import { colors } from "@mongez/copper";
7
7
  import { fileExistsAsync, getJsonFileAsync, putFileAsync, putJsonFileAsync } from "@warlock.js/fs";
@@ -9,13 +9,16 @@ import { execSync } from "node:child_process";
9
9
 
10
10
  //#region ../core/src/generations/add-command.action.ts
11
11
  const allowedFeatures = Object.keys(featuresMap);
12
+ function isWarlockPackage(packageName) {
13
+ return packageName.startsWith("@warlock.js/");
14
+ }
12
15
  /**
13
16
  * Resolve the internal feature-map placeholder to the version of Core that is
14
17
  * actually executing the command. Every @warlock.js package is lockstep, so
15
18
  * Core is the single source of truth; non-Warlock dependencies are untouched.
16
19
  */
17
20
  function resolveWarlockDependencyVersions(dependencies, frameworkVersion) {
18
- for (const dependency of Object.keys(dependencies)) if (dependency.startsWith("@warlock.js/")) dependencies[dependency] = frameworkVersion;
21
+ for (const dependency of Object.keys(dependencies)) if (isWarlockPackage(dependency)) dependencies[dependency] = frameworkVersion;
19
22
  }
20
23
  function resolveFeatures(features, visited = /* @__PURE__ */ new Set()) {
21
24
  const resolved = [];
@@ -93,21 +96,35 @@ async function addCommandAction(options) {
93
96
  * Runs two passes (prod then dev) so each lands in the correct section.
94
97
  */
95
98
  async function installDependencies(packageManager, dependencies, devDependencies) {
96
- const packageManagerCommand = getAddCommand(packageManager ?? await detectPackageManager());
99
+ const resolvedPackageManager = packageManager ?? await detectPackageManager();
100
+ const packageManagerCommand = getAddCommand(resolvedPackageManager);
101
+ const exactPackageManagerCommand = getExactAddCommand(resolvedPackageManager);
102
+ const installDependencySet = (dependencySet, development) => {
103
+ const regularSpecs = [];
104
+ const exactWarlockSpecs = [];
105
+ for (const [name, version] of Object.entries(dependencySet)) {
106
+ const spec = `${name}@${version}`;
107
+ (isWarlockPackage(name) ? exactWarlockSpecs : regularSpecs).push(spec);
108
+ }
109
+ const developmentFlag = development ? " -D" : "";
110
+ const runInstall = (command, specs) => {
111
+ if (specs.length === 0) return;
112
+ execSync(`${command} ${specs.join(" ")}${developmentFlag}`, {
113
+ cwd: process.cwd(),
114
+ stdio: "inherit"
115
+ });
116
+ };
117
+ runInstall(packageManagerCommand, regularSpecs);
118
+ runInstall(exactPackageManagerCommand, exactWarlockSpecs);
119
+ };
97
120
  if (Object.keys(dependencies).length > 0) {
98
121
  console.log(`Installing dependencies ${colors.magenta(Object.keys(dependencies).join(", "))}`);
99
- execSync(`${packageManagerCommand} ${Object.entries(dependencies).map(([name, version]) => `${name}@${version}`).join(" ")}`, {
100
- cwd: process.cwd(),
101
- stdio: "inherit"
102
- });
122
+ installDependencySet(dependencies, false);
103
123
  console.log(`Dependencies installed successfully ${colors.green(Object.keys(dependencies).join(", "))}`);
104
124
  }
105
125
  if (Object.keys(devDependencies).length > 0) {
106
126
  console.log(`Installing dev dependencies ${colors.magenta(Object.keys(devDependencies).join(", "))}`);
107
- execSync(`${packageManagerCommand} ${Object.entries(devDependencies).map(([name, version]) => `${name}@${version}`).join(" ")} -D`, {
108
- cwd: process.cwd(),
109
- stdio: "inherit"
110
- });
127
+ installDependencySet(devDependencies, true);
111
128
  console.log(`Dev dependencies installed successfully ${colors.green(Object.keys(devDependencies).join(", "))}`);
112
129
  }
113
130
  }
@@ -1 +1 @@
1
- {"version":3,"file":"add-command.action.mjs","names":[],"sources":["../../../../../../../core/src/generations/add-command.action.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport {\r\n fileExistsAsync,\r\n getJsonFileAsync,\r\n putFileAsync,\r\n putJsonFileAsync,\r\n} from \"@warlock.js/fs\";\r\nimport { execSync } from \"node:child_process\";\r\nimport { CommandActionData } from \"../commands/types\";\r\nimport {\r\n detectPackageManager,\r\n getAddCommand,\r\n type PackageManager,\r\n} from \"../updater/package-manager\";\r\nimport { rootPath, srcPath } from \"../utils\";\r\nimport { getWarlockVersion } from \"../utils/framework-vesion\";\r\nimport { featuresMap } from \"./features\";\r\n\r\nexport { featuresMap };\r\nexport type { FeatureDefinition } from \"./features\";\r\n\r\n/**\r\n * The parts of a project `package.json` this action reads or writes. Deliberately\r\n * partial — it describes what we touch, not the whole manifest.\r\n */\r\ntype ProjectPackageJson = {\r\n dependencies?: Record<string, string>;\r\n devDependencies?: Record<string, string>;\r\n scripts?: Record<string, string>;\r\n};\r\n\r\nexport const allowedFeatures = Object.keys(featuresMap);\n\n/**\n * Resolve the internal feature-map placeholder to the version of Core that is\n * actually executing the command. Every @warlock.js package is lockstep, so\n * Core is the single source of truth; non-Warlock dependencies are untouched.\n */\nexport function resolveWarlockDependencyVersions(\n dependencies: Record<string, string>,\n frameworkVersion: string,\n): void {\n for (const dependency of Object.keys(dependencies)) {\n if (dependency.startsWith(\"@warlock.js/\")) {\n dependencies[dependency] = frameworkVersion;\n }\n }\n}\n\nfunction resolveFeatures(features: string[], visited = new Set<string>()): string[] {\n const resolved: string[] = [];\r\n\r\n for (const feature of features) {\r\n if (visited.has(feature)) continue;\r\n visited.add(feature);\r\n\r\n const def = featuresMap[feature];\r\n\r\n if (def.requires?.length) {\r\n resolved.push(...resolveFeatures(def.requires, visited));\r\n }\r\n\r\n resolved.push(feature);\r\n }\r\n\r\n return resolved;\r\n}\r\n\r\nexport async function addCommandAction(options: CommandActionData) {\r\n const features = options.args;\r\n const { packageManager, list, noInstall } = options.options;\r\n\r\n if (list) {\r\n console.log(\"Available Features:\");\r\n\r\n for (const feature of allowedFeatures) {\r\n console.log(\r\n `- ${colors.yellowBright(feature)}: ${colors.green(featuresMap[feature].description)}`,\r\n );\r\n }\r\n\r\n process.exit(0);\r\n }\r\n\r\n validateFeatures(features);\r\n\r\n const resolvedFeatures = resolveFeatures(features);\r\n\r\n const dependencies: Record<string, string> = {};\r\n const devDependencies: Record<string, string> = {};\r\n const ejectConfigs: Record<string, { content: string; name: string }> = {};\r\n const scripts: Record<string, string> = {};\r\n\r\n for (const feature of resolvedFeatures) {\r\n const featurePackages = featuresMap[feature as keyof typeof featuresMap];\r\n Object.assign(dependencies, featurePackages.dependencies);\r\n if (featurePackages.devDependencies) {\r\n Object.assign(devDependencies, featurePackages.devDependencies);\r\n }\r\n\r\n if (featurePackages.ejectConfig) {\r\n ejectConfigs[featurePackages.ejectConfig.name] = featurePackages.ejectConfig;\r\n }\r\n\r\n if (featurePackages.script) {\r\n Object.assign(scripts, featurePackages.script);\r\n }\r\n }\r\n\r\n // Pin every @warlock.js/* feature package to the INSTALLED framework version so\r\n // a scaffolded project's features match its core version instead of drifting to\n // the feature map's static range.\n const frameworkVersion = await getWarlockVersion();\n resolveWarlockDependencyVersions(dependencies, frameworkVersion);\n\r\n const currentPackageJson = await getJsonFileAsync<ProjectPackageJson>(rootPath(\"package.json\"));\r\n\r\n // Fresh templates may omit one of the maps — guard before reading.\r\n currentPackageJson.dependencies = currentPackageJson.dependencies ?? {};\r\n currentPackageJson.devDependencies = currentPackageJson.devDependencies ?? {};\r\n\r\n // Skip anything already present so we never downgrade an existing pin.\r\n for (const dependency of Object.keys(dependencies)) {\r\n if (currentPackageJson.dependencies[dependency]) {\r\n console.log(`${colors.yellowBright(dependency)} is already installed, skipping...`);\r\n delete dependencies[dependency];\r\n }\r\n }\r\n\r\n for (const devDependency of Object.keys(devDependencies)) {\r\n if (currentPackageJson.devDependencies[devDependency]) {\r\n console.log(`${colors.yellowBright(devDependency)} is already installed, skipping...`);\r\n delete devDependencies[devDependency];\r\n }\r\n }\r\n\r\n if (noInstall) {\r\n await recordDependencies(dependencies, devDependencies);\r\n } else {\r\n await installDependencies(packageManager as PackageManager | undefined, dependencies, devDependencies);\r\n }\r\n\r\n for (const [name, config] of Object.entries(ejectConfigs)) {\r\n if (await fileExistsAsync(srcPath(`config/${name}.ts`))) {\r\n console.log(`${colors.yellowBright(name)} config already exists, skipping...`);\r\n continue;\r\n }\r\n\r\n console.log(`Creating ${colors.magenta(name)} config...`);\r\n\r\n await putFileAsync(srcPath(`config/${name}.ts`), config.content);\r\n\r\n console.log(`${colors.green(name)} config created successfully`);\r\n }\r\n\r\n // now loop again over features to execute onExecuting\r\n for (const feature of resolvedFeatures) {\r\n const featurePackages = featuresMap[feature as keyof typeof featuresMap];\r\n if (featurePackages.onExecuting) {\r\n await featurePackages.onExecuting(options);\r\n }\r\n }\r\n\r\n if (Object.keys(scripts).length > 0) {\r\n console.log(`Adding scripts ${colors.magenta(Object.keys(scripts).join(\", \"))}`);\r\n const packageJsonPath = rootPath(\"package.json\");\r\n const packageJson = await getJsonFileAsync<ProjectPackageJson>(packageJsonPath);\r\n packageJson.scripts = { ...(packageJson.scripts ?? {}), ...scripts };\r\n await putJsonFileAsync(packageJsonPath, packageJson);\r\n\r\n console.log(`Scripts added successfully ${colors.green(Object.keys(scripts).join(\", \"))}`);\r\n }\r\n}\r\n\r\n/**\r\n * Install the resolved dependency sets through the project's package manager.\r\n * Runs two passes (prod then dev) so each lands in the correct section.\r\n */\r\nexport async function installDependencies(\n packageManager: PackageManager | undefined,\r\n dependencies: Record<string, string>,\r\n devDependencies: Record<string, string>,\r\n) {\r\n // `--package-manager` is optional; without it, fall back to the lockfile.\r\n const packageManagerCommand = getAddCommand(packageManager ?? (await detectPackageManager()));\r\n\r\n if (Object.keys(dependencies).length > 0) {\r\n console.log(`Installing dependencies ${colors.magenta(Object.keys(dependencies).join(\", \"))}`);\r\n\r\n const dependencySpecs = Object.entries(dependencies).map(\n ([name, version]) => `${name}@${version}`,\n );\n\n execSync(`${packageManagerCommand} ${dependencySpecs.join(\" \")}`, {\n cwd: process.cwd(),\r\n stdio: \"inherit\",\r\n });\r\n\r\n console.log(\r\n `Dependencies installed successfully ${colors.green(Object.keys(dependencies).join(\", \"))}`,\r\n );\r\n }\r\n\r\n if (Object.keys(devDependencies).length > 0) {\r\n console.log(\r\n `Installing dev dependencies ${colors.magenta(Object.keys(devDependencies).join(\", \"))}`,\r\n );\r\n\r\n const devDependencySpecs = Object.entries(devDependencies).map(\n ([name, version]) => `${name}@${version}`,\n );\n\n execSync(`${packageManagerCommand} ${devDependencySpecs.join(\" \")} -D`, {\n cwd: process.cwd(),\r\n stdio: \"inherit\",\r\n });\r\n\r\n console.log(\r\n `Dev dependencies installed successfully ${colors.green(Object.keys(devDependencies).join(\", \"))}`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Write the resolved dependency sets into package.json without installing.\r\n * Used by `--no-install` so a scaffolder can batch every feature into one\r\n * install pass after the command returns. Versions come from the feature map.\r\n */\r\nasync function recordDependencies(\r\n dependencies: Record<string, string>,\r\n devDependencies: Record<string, string>,\r\n) {\r\n if (Object.keys(dependencies).length === 0 && Object.keys(devDependencies).length === 0) {\r\n return;\r\n }\r\n\r\n const packageJsonPath = rootPath(\"package.json\");\r\n const packageJson = await getJsonFileAsync<ProjectPackageJson>(packageJsonPath);\r\n\r\n packageJson.dependencies = packageJson.dependencies ?? {};\r\n packageJson.devDependencies = packageJson.devDependencies ?? {};\r\n\r\n Object.assign(packageJson.dependencies, dependencies);\r\n Object.assign(packageJson.devDependencies, devDependencies);\r\n\r\n await putJsonFileAsync(packageJsonPath, packageJson);\r\n\r\n const recorded = [...Object.keys(dependencies), ...Object.keys(devDependencies)];\r\n\r\n console.log(\r\n `Recorded ${colors.green(recorded.join(\", \"))} in package.json (install skipped via --no-install)`,\r\n );\r\n}\r\n\r\nfunction validateFeatures(features: string[]) {\n for (const feature of features) {\r\n if (!allowedFeatures.includes(feature)) {\r\n console.log(\r\n `Feature ${colors.redBright(feature)} is not allowed, allowed features are: ${colors.green(allowedFeatures.join(\", \"))}`,\r\n );\r\n process.exit(1);\r\n }\r\n }\n}\n"],"mappings":";;;;;;;;;;AA+BA,MAAa,kBAAkB,OAAO,KAAK,WAAW;;;;;;AAOtD,SAAgB,iCACd,cACA,kBACM;CACN,KAAK,MAAM,cAAc,OAAO,KAAK,YAAY,GAC/C,IAAI,WAAW,WAAW,cAAc,GACtC,aAAa,cAAc;AAGjC;AAEA,SAAS,gBAAgB,UAAoB,0BAAU,IAAI,IAAY,GAAa;CAClF,MAAM,WAAqB,CAAC;CAE5B,KAAK,MAAM,WAAW,UAAU;EAC9B,IAAI,QAAQ,IAAI,OAAO,GAAG;EAC1B,QAAQ,IAAI,OAAO;EAEnB,MAAM,MAAM,YAAY;EAExB,IAAI,IAAI,UAAU,QAChB,SAAS,KAAK,GAAG,gBAAgB,IAAI,UAAU,OAAO,CAAC;EAGzD,SAAS,KAAK,OAAO;CACvB;CAEA,OAAO;AACT;AAEA,eAAsB,iBAAiB,SAA4B;CACjE,MAAM,WAAW,QAAQ;CACzB,MAAM,EAAE,gBAAgB,MAAM,cAAc,QAAQ;CAEpD,IAAI,MAAM;EACR,QAAQ,IAAI,qBAAqB;EAEjC,KAAK,MAAM,WAAW,iBACpB,QAAQ,IACN,KAAK,OAAO,aAAa,OAAO,EAAE,IAAI,OAAO,MAAM,YAAY,SAAS,WAAW,GACrF;EAGF,QAAQ,KAAK,CAAC;CAChB;CAEA,iBAAiB,QAAQ;CAEzB,MAAM,mBAAmB,gBAAgB,QAAQ;CAEjD,MAAM,eAAuC,CAAC;CAC9C,MAAM,kBAA0C,CAAC;CACjD,MAAM,eAAkE,CAAC;CACzE,MAAM,UAAkC,CAAC;CAEzC,KAAK,MAAM,WAAW,kBAAkB;EACtC,MAAM,kBAAkB,YAAY;EACpC,OAAO,OAAO,cAAc,gBAAgB,YAAY;EACxD,IAAI,gBAAgB,iBAClB,OAAO,OAAO,iBAAiB,gBAAgB,eAAe;EAGhE,IAAI,gBAAgB,aAClB,aAAa,gBAAgB,YAAY,QAAQ,gBAAgB;EAGnE,IAAI,gBAAgB,QAClB,OAAO,OAAO,SAAS,gBAAgB,MAAM;CAEjD;CAMA,iCAAiC,cAAc,MADhB,kBAAkB,CACc;CAE/D,MAAM,qBAAqB,MAAM,iBAAqC,SAAS,cAAc,CAAC;CAG9F,mBAAmB,eAAe,mBAAmB,gBAAgB,CAAC;CACtE,mBAAmB,kBAAkB,mBAAmB,mBAAmB,CAAC;CAG5E,KAAK,MAAM,cAAc,OAAO,KAAK,YAAY,GAC/C,IAAI,mBAAmB,aAAa,aAAa;EAC/C,QAAQ,IAAI,GAAG,OAAO,aAAa,UAAU,EAAE,mCAAmC;EAClF,OAAO,aAAa;CACtB;CAGF,KAAK,MAAM,iBAAiB,OAAO,KAAK,eAAe,GACrD,IAAI,mBAAmB,gBAAgB,gBAAgB;EACrD,QAAQ,IAAI,GAAG,OAAO,aAAa,aAAa,EAAE,mCAAmC;EACrF,OAAO,gBAAgB;CACzB;CAGF,IAAI,WACF,MAAM,mBAAmB,cAAc,eAAe;MAEtD,MAAM,oBAAoB,gBAA8C,cAAc,eAAe;CAGvG,KAAK,MAAM,CAAC,MAAM,WAAW,OAAO,QAAQ,YAAY,GAAG;EACzD,IAAI,MAAM,gBAAgB,QAAQ,UAAU,KAAK,IAAI,CAAC,GAAG;GACvD,QAAQ,IAAI,GAAG,OAAO,aAAa,IAAI,EAAE,oCAAoC;GAC7E;EACF;EAEA,QAAQ,IAAI,YAAY,OAAO,QAAQ,IAAI,EAAE,WAAW;EAExD,MAAM,aAAa,QAAQ,UAAU,KAAK,IAAI,GAAG,OAAO,OAAO;EAE/D,QAAQ,IAAI,GAAG,OAAO,MAAM,IAAI,EAAE,6BAA6B;CACjE;CAGA,KAAK,MAAM,WAAW,kBAAkB;EACtC,MAAM,kBAAkB,YAAY;EACpC,IAAI,gBAAgB,aAClB,MAAM,gBAAgB,YAAY,OAAO;CAE7C;CAEA,IAAI,OAAO,KAAK,OAAO,EAAE,SAAS,GAAG;EACnC,QAAQ,IAAI,kBAAkB,OAAO,QAAQ,OAAO,KAAK,OAAO,EAAE,KAAK,IAAI,CAAC,GAAG;EAC/E,MAAM,kBAAkB,SAAS,cAAc;EAC/C,MAAM,cAAc,MAAM,iBAAqC,eAAe;EAC9E,YAAY,UAAU;GAAE,GAAI,YAAY,WAAW,CAAC;GAAI,GAAG;EAAQ;EACnE,MAAM,iBAAiB,iBAAiB,WAAW;EAEnD,QAAQ,IAAI,8BAA8B,OAAO,MAAM,OAAO,KAAK,OAAO,EAAE,KAAK,IAAI,CAAC,GAAG;CAC3F;AACF;;;;;AAMA,eAAsB,oBACpB,gBACA,cACA,iBACA;CAEA,MAAM,wBAAwB,cAAc,kBAAmB,MAAM,qBAAqB,CAAE;CAE5F,IAAI,OAAO,KAAK,YAAY,EAAE,SAAS,GAAG;EACxC,QAAQ,IAAI,2BAA2B,OAAO,QAAQ,OAAO,KAAK,YAAY,EAAE,KAAK,IAAI,CAAC,GAAG;EAM7F,SAAS,GAAG,sBAAsB,GAJV,OAAO,QAAQ,YAAY,EAAE,KAClD,CAAC,MAAM,aAAa,GAAG,KAAK,GAAG,SAGiB,EAAE,KAAK,GAAG,KAAK;GAChE,KAAK,QAAQ,IAAI;GACjB,OAAO;EACT,CAAC;EAED,QAAQ,IACN,uCAAuC,OAAO,MAAM,OAAO,KAAK,YAAY,EAAE,KAAK,IAAI,CAAC,GAC1F;CACF;CAEA,IAAI,OAAO,KAAK,eAAe,EAAE,SAAS,GAAG;EAC3C,QAAQ,IACN,+BAA+B,OAAO,QAAQ,OAAO,KAAK,eAAe,EAAE,KAAK,IAAI,CAAC,GACvF;EAMA,SAAS,GAAG,sBAAsB,GAJP,OAAO,QAAQ,eAAe,EAAE,KACxD,CAAC,MAAM,aAAa,GAAG,KAAK,GAAG,SAGoB,EAAE,KAAK,GAAG,EAAE,MAAM;GACtE,KAAK,QAAQ,IAAI;GACjB,OAAO;EACT,CAAC;EAED,QAAQ,IACN,2CAA2C,OAAO,MAAM,OAAO,KAAK,eAAe,EAAE,KAAK,IAAI,CAAC,GACjG;CACF;AACF;;;;;;AAOA,eAAe,mBACb,cACA,iBACA;CACA,IAAI,OAAO,KAAK,YAAY,EAAE,WAAW,KAAK,OAAO,KAAK,eAAe,EAAE,WAAW,GACpF;CAGF,MAAM,kBAAkB,SAAS,cAAc;CAC/C,MAAM,cAAc,MAAM,iBAAqC,eAAe;CAE9E,YAAY,eAAe,YAAY,gBAAgB,CAAC;CACxD,YAAY,kBAAkB,YAAY,mBAAmB,CAAC;CAE9D,OAAO,OAAO,YAAY,cAAc,YAAY;CACpD,OAAO,OAAO,YAAY,iBAAiB,eAAe;CAE1D,MAAM,iBAAiB,iBAAiB,WAAW;CAEnD,MAAM,WAAW,CAAC,GAAG,OAAO,KAAK,YAAY,GAAG,GAAG,OAAO,KAAK,eAAe,CAAC;CAE/E,QAAQ,IACN,YAAY,OAAO,MAAM,SAAS,KAAK,IAAI,CAAC,EAAE,oDAChD;AACF;AAEA,SAAS,iBAAiB,UAAoB;CAC5C,KAAK,MAAM,WAAW,UACpB,IAAI,CAAC,gBAAgB,SAAS,OAAO,GAAG;EACtC,QAAQ,IACN,WAAW,OAAO,UAAU,OAAO,EAAE,yCAAyC,OAAO,MAAM,gBAAgB,KAAK,IAAI,CAAC,GACvH;EACA,QAAQ,KAAK,CAAC;CAChB;AAEJ"}
1
+ {"version":3,"file":"add-command.action.mjs","names":[],"sources":["../../../../../../../core/src/generations/add-command.action.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\r\nimport {\r\n fileExistsAsync,\r\n getJsonFileAsync,\r\n putFileAsync,\r\n putJsonFileAsync,\r\n} from \"@warlock.js/fs\";\r\nimport { execSync } from \"node:child_process\";\r\nimport { CommandActionData } from \"../commands/types\";\r\nimport {\r\n detectPackageManager,\n getAddCommand,\n getExactAddCommand,\n type PackageManager,\n} from \"../updater/package-manager\";\nimport { rootPath, srcPath } from \"../utils\";\r\nimport { getWarlockVersion } from \"../utils/framework-vesion\";\r\nimport { featuresMap } from \"./features\";\r\n\r\nexport { featuresMap };\r\nexport type { FeatureDefinition } from \"./features\";\r\n\r\n/**\r\n * The parts of a project `package.json` this action reads or writes. Deliberately\r\n * partial — it describes what we touch, not the whole manifest.\r\n */\r\ntype ProjectPackageJson = {\r\n dependencies?: Record<string, string>;\r\n devDependencies?: Record<string, string>;\r\n scripts?: Record<string, string>;\r\n};\r\n\r\nexport const allowedFeatures = Object.keys(featuresMap);\n\nfunction isWarlockPackage(packageName: string): boolean {\n return packageName.startsWith(\"@warlock.js/\");\n}\n\n/**\n * Resolve the internal feature-map placeholder to the version of Core that is\n * actually executing the command. Every @warlock.js package is lockstep, so\n * Core is the single source of truth; non-Warlock dependencies are untouched.\n */\nexport function resolveWarlockDependencyVersions(\n dependencies: Record<string, string>,\n frameworkVersion: string,\n): void {\n for (const dependency of Object.keys(dependencies)) {\n if (isWarlockPackage(dependency)) {\n dependencies[dependency] = frameworkVersion;\n }\n }\n}\n\nfunction resolveFeatures(features: string[], visited = new Set<string>()): string[] {\n const resolved: string[] = [];\r\n\r\n for (const feature of features) {\r\n if (visited.has(feature)) continue;\r\n visited.add(feature);\r\n\r\n const def = featuresMap[feature];\r\n\r\n if (def.requires?.length) {\r\n resolved.push(...resolveFeatures(def.requires, visited));\r\n }\r\n\r\n resolved.push(feature);\r\n }\r\n\r\n return resolved;\r\n}\r\n\r\nexport async function addCommandAction(options: CommandActionData) {\r\n const features = options.args;\r\n const { packageManager, list, noInstall } = options.options;\r\n\r\n if (list) {\r\n console.log(\"Available Features:\");\r\n\r\n for (const feature of allowedFeatures) {\r\n console.log(\r\n `- ${colors.yellowBright(feature)}: ${colors.green(featuresMap[feature].description)}`,\r\n );\r\n }\r\n\r\n process.exit(0);\r\n }\r\n\r\n validateFeatures(features);\r\n\r\n const resolvedFeatures = resolveFeatures(features);\r\n\r\n const dependencies: Record<string, string> = {};\r\n const devDependencies: Record<string, string> = {};\r\n const ejectConfigs: Record<string, { content: string; name: string }> = {};\r\n const scripts: Record<string, string> = {};\r\n\r\n for (const feature of resolvedFeatures) {\r\n const featurePackages = featuresMap[feature as keyof typeof featuresMap];\r\n Object.assign(dependencies, featurePackages.dependencies);\r\n if (featurePackages.devDependencies) {\r\n Object.assign(devDependencies, featurePackages.devDependencies);\r\n }\r\n\r\n if (featurePackages.ejectConfig) {\r\n ejectConfigs[featurePackages.ejectConfig.name] = featurePackages.ejectConfig;\r\n }\r\n\r\n if (featurePackages.script) {\r\n Object.assign(scripts, featurePackages.script);\r\n }\r\n }\r\n\r\n // Pin every @warlock.js/* feature package to the INSTALLED framework version so\r\n // a scaffolded project's features match its core version instead of drifting to\n // the feature map's static range.\n const frameworkVersion = await getWarlockVersion();\n resolveWarlockDependencyVersions(dependencies, frameworkVersion);\n\r\n const currentPackageJson = await getJsonFileAsync<ProjectPackageJson>(rootPath(\"package.json\"));\r\n\r\n // Fresh templates may omit one of the maps — guard before reading.\r\n currentPackageJson.dependencies = currentPackageJson.dependencies ?? {};\r\n currentPackageJson.devDependencies = currentPackageJson.devDependencies ?? {};\r\n\r\n // Skip anything already present so we never downgrade an existing pin.\r\n for (const dependency of Object.keys(dependencies)) {\r\n if (currentPackageJson.dependencies[dependency]) {\r\n console.log(`${colors.yellowBright(dependency)} is already installed, skipping...`);\r\n delete dependencies[dependency];\r\n }\r\n }\r\n\r\n for (const devDependency of Object.keys(devDependencies)) {\r\n if (currentPackageJson.devDependencies[devDependency]) {\r\n console.log(`${colors.yellowBright(devDependency)} is already installed, skipping...`);\r\n delete devDependencies[devDependency];\r\n }\r\n }\r\n\r\n if (noInstall) {\r\n await recordDependencies(dependencies, devDependencies);\r\n } else {\r\n await installDependencies(packageManager as PackageManager | undefined, dependencies, devDependencies);\r\n }\r\n\r\n for (const [name, config] of Object.entries(ejectConfigs)) {\r\n if (await fileExistsAsync(srcPath(`config/${name}.ts`))) {\r\n console.log(`${colors.yellowBright(name)} config already exists, skipping...`);\r\n continue;\r\n }\r\n\r\n console.log(`Creating ${colors.magenta(name)} config...`);\r\n\r\n await putFileAsync(srcPath(`config/${name}.ts`), config.content);\r\n\r\n console.log(`${colors.green(name)} config created successfully`);\r\n }\r\n\r\n // now loop again over features to execute onExecuting\r\n for (const feature of resolvedFeatures) {\r\n const featurePackages = featuresMap[feature as keyof typeof featuresMap];\r\n if (featurePackages.onExecuting) {\r\n await featurePackages.onExecuting(options);\r\n }\r\n }\r\n\r\n if (Object.keys(scripts).length > 0) {\r\n console.log(`Adding scripts ${colors.magenta(Object.keys(scripts).join(\", \"))}`);\r\n const packageJsonPath = rootPath(\"package.json\");\r\n const packageJson = await getJsonFileAsync<ProjectPackageJson>(packageJsonPath);\r\n packageJson.scripts = { ...(packageJson.scripts ?? {}), ...scripts };\r\n await putJsonFileAsync(packageJsonPath, packageJson);\r\n\r\n console.log(`Scripts added successfully ${colors.green(Object.keys(scripts).join(\", \"))}`);\r\n }\r\n}\r\n\r\n/**\r\n * Install the resolved dependency sets through the project's package manager.\r\n * Runs two passes (prod then dev) so each lands in the correct section.\r\n */\r\nexport async function installDependencies(\n packageManager: PackageManager | undefined,\r\n dependencies: Record<string, string>,\r\n devDependencies: Record<string, string>,\r\n) {\r\n // `--package-manager` is optional; without it, fall back to the lockfile.\r\n const resolvedPackageManager = packageManager ?? (await detectPackageManager());\n const packageManagerCommand = getAddCommand(resolvedPackageManager);\n const exactPackageManagerCommand = getExactAddCommand(resolvedPackageManager);\n\n const installDependencySet = (\n dependencySet: Record<string, string>,\n development: boolean,\n ) => {\n const regularSpecs: string[] = [];\n const exactWarlockSpecs: string[] = [];\n\n for (const [name, version] of Object.entries(dependencySet)) {\n const spec = `${name}@${version}`;\n (isWarlockPackage(name) ? exactWarlockSpecs : regularSpecs).push(spec);\n }\n\n const developmentFlag = development ? \" -D\" : \"\";\n const runInstall = (command: string, specs: string[]) => {\n if (specs.length === 0) return;\n\n execSync(`${command} ${specs.join(\" \")}${developmentFlag}`, {\n cwd: process.cwd(),\n stdio: \"inherit\",\n });\n };\n\n runInstall(packageManagerCommand, regularSpecs);\n runInstall(exactPackageManagerCommand, exactWarlockSpecs);\n };\n\r\n if (Object.keys(dependencies).length > 0) {\r\n console.log(`Installing dependencies ${colors.magenta(Object.keys(dependencies).join(\", \"))}`);\r\n\r\n installDependencySet(dependencies, false);\n\r\n console.log(\r\n `Dependencies installed successfully ${colors.green(Object.keys(dependencies).join(\", \"))}`,\r\n );\r\n }\r\n\r\n if (Object.keys(devDependencies).length > 0) {\r\n console.log(\r\n `Installing dev dependencies ${colors.magenta(Object.keys(devDependencies).join(\", \"))}`,\r\n );\r\n\r\n installDependencySet(devDependencies, true);\n\r\n console.log(\r\n `Dev dependencies installed successfully ${colors.green(Object.keys(devDependencies).join(\", \"))}`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Write the resolved dependency sets into package.json without installing.\r\n * Used by `--no-install` so a scaffolder can batch every feature into one\r\n * install pass after the command returns. Versions come from the feature map.\r\n */\r\nasync function recordDependencies(\r\n dependencies: Record<string, string>,\r\n devDependencies: Record<string, string>,\r\n) {\r\n if (Object.keys(dependencies).length === 0 && Object.keys(devDependencies).length === 0) {\r\n return;\r\n }\r\n\r\n const packageJsonPath = rootPath(\"package.json\");\r\n const packageJson = await getJsonFileAsync<ProjectPackageJson>(packageJsonPath);\r\n\r\n packageJson.dependencies = packageJson.dependencies ?? {};\r\n packageJson.devDependencies = packageJson.devDependencies ?? {};\r\n\r\n Object.assign(packageJson.dependencies, dependencies);\r\n Object.assign(packageJson.devDependencies, devDependencies);\r\n\r\n await putJsonFileAsync(packageJsonPath, packageJson);\r\n\r\n const recorded = [...Object.keys(dependencies), ...Object.keys(devDependencies)];\r\n\r\n console.log(\r\n `Recorded ${colors.green(recorded.join(\", \"))} in package.json (install skipped via --no-install)`,\r\n );\r\n}\r\n\r\nfunction validateFeatures(features: string[]) {\n for (const feature of features) {\r\n if (!allowedFeatures.includes(feature)) {\r\n console.log(\r\n `Feature ${colors.redBright(feature)} is not allowed, allowed features are: ${colors.green(allowedFeatures.join(\", \"))}`,\r\n );\r\n process.exit(1);\r\n }\r\n }\n}\n"],"mappings":"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"}
@@ -72,10 +72,10 @@ const TOP_LEVEL_ROOT_GET = /^router\s*\.\s*get\(\s*(["'`])\/\1/gm;
72
72
  */
73
73
  const TOP_LEVEL_WELCOME_GET = /^router\s*\.\s*get\(\s*(["'`])\/welcome\1/m;
74
74
  /**
75
- * Make room for a page that declares `route = "/"`.
75
+ * Make room for a page that declares `route.path = "/"`.
76
76
  *
77
77
  * The project template registers `router.get("/", homePageController)` and the
78
- * page stub declares `route = "/"`. Fastify rejects the second registration
78
+ * page stub declares `route.path = "/"`. Fastify rejects the second registration
79
79
  * (`Method 'GET' already declared for route '/'`) and the homepage 500s at
80
80
  * request time — so `warlock add web` cannot just write the page and hope.
81
81
  *
@@ -149,15 +149,15 @@ async function completeWebInstallation(_options) {
149
149
  if (collision.outcome === "relocated") console.log(`${colors.green("✓")} Moved the existing ${colors.yellowBright("GET \"/\"")} route to ${colors.yellowBright("\"/welcome\"")} in ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} — the new page owns \`/\` now, and the JSON welcome route still answers at /welcome.`);
150
150
  if (collision.outcome === "conflict" || collision.outcome === "failed") {
151
151
  const verb = collision.outcome === "failed" ? colors.redBright("✗") : colors.yellowBright("!");
152
- console.log(`${verb} Did not create src/web/home.page.tsx: ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} ${collision.reason}.\n The page stub declares ${colors.yellowBright("route = \"/\"")}, and two handlers on one path is a 500 at request time, not a startup error.
153
- Free up ${colors.yellowBright("GET \"/\"")} in that file — move it to a path of its own, or remove it — then create src/web/home.page.tsx yourself. Giving the page a \`route\` other than \`/\` works too.`);
152
+ console.log(`${verb} Did not create src/web/index.page.tsx: ${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} ${collision.reason}.\n The page stub declares ${colors.yellowBright("route.path = \"/\"")}, and two handlers on one path is a 500 at request time, not a startup error.
153
+ Free up ${colors.yellowBright("GET \"/\"")} in that file — move it to a path of its own, or remove it — then create src/web/index.page.tsx yourself. Giving the page a \`route\` other than \`/\` works too.`);
154
154
  process.exitCode = 1;
155
155
  } else {
156
- await putFileAsync(srcPath("web/home.page.tsx"), webHomePageStub);
156
+ await putFileAsync(srcPath("web/index.page.tsx"), webHomePageStub);
157
157
  await ensureDirectoryAsync(srcPath("app/contact/controllers"));
158
158
  await putFileAsync(srcPath("app/contact/controllers/contact.controller.ts"), webContactControllerStub);
159
159
  await putFileAsync(srcPath("app/contact/routes.ts"), webContactRoutesStub);
160
- console.log(`${colors.green("✓")} Created src/web/home.page.tsx`);
160
+ console.log(`${colors.green("✓")} Created src/web/index.page.tsx`);
161
161
  console.log(`${colors.green("✓")} Created POST /api/contact starter route`);
162
162
  }
163
163
  }
@@ -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 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 = \"/\"`.\r\n *\r\n * The project template registers `router.get(\"/\", homePageController)` and the\r\n * page stub declares `route = \"/\"`. Fastify rejects the second registration\r\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/home.page.tsx: ` +\r\n `${colors.yellowBright(`src/${APP_ROUTES_FILE}`)} ${collision.reason}.\\n` +\r\n ` The page stub declares ${colors.yellowBright('route = \"/\"')}, and two handlers on one ` +\r\n \"path is a 500 at request time, not a startup error.\\n\" +\r\n ` Free up ${colors.yellowBright('GET \"/\"')} in that file — move it to a path of its own, ` +\r\n \"or remove it — then create src/web/home.page.tsx yourself. Giving the page a `route` other \" +\r\n \"than `/` works too.\",\r\n );\r\n\r\n // Non-zero on BOTH branches. The page layer this command exists to\r\n // scaffold was not scaffolded, and a 0 here is the exact \"looked like it\r\n // worked\" signal that put `/` in this state to begin with — a conflict we\r\n // declined to guess at is still an incomplete install, not a success.\r\n //\r\n // `exitCode` rather than `exit(1)`: the connector below still has to be\r\n // registered, and any other feature in the same `warlock add` invocation\r\n // still has to install, or the project is left half-wired on top of this.\r\n process.exitCode = 1;\r\n } else {\n await putFileAsync(srcPath(\"web/home.page.tsx\"), webHomePageStub);\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/home.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 { CommandActionData } from \"../../commands/types\";\r\nimport { rootPath, srcPath } from \"../../utils\";\r\nimport {\n webContactControllerStub,\n webContactRoutesStub,\n webHomePageStub,\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 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":";;;;;;;;;;;;;;;;;;;;;;;;;;AAmCA,eAAe,uBAAsC;CACnD,MAAM,aAAa,SAAS,mBAAmB;CAE/C,IAAI,CAAE,MAAM,gBAAgB,UAAU,GAAI;EACxC,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,+JAG9C;EAEA;CACF;CAEA,MAAM,UAAU,MAAM,aAAa,UAAU;CAE7C,IAAI,QAAQ,SAAS,cAAc,GAAG;EACpC,QAAQ,IAAI,GAAG,OAAO,aAAa,cAAc,EAAE,iCAAiC;EAEpF;CACF;CAEA,MAAM,aAAa;CACnB,IAAI,OAAO,QAAQ,SAAS,UAAU,IAAI,UAAU,GAAG,WAAW,IAAI;CAItE,IAAI,mBAAmB,KAAK,IAAI,GAC9B,OAAO,KAAK,QAAQ,oBAAoB,8BAA8B;MACjE,IAAI,KAAK,SAAS,gBAAgB,GACvC,OAAO,KAAK,QAAQ,kBAAkB,iDAAiD;MAClF;EACL,QAAQ,IACN,GAAG,OAAO,aAAa,mBAAmB,EAAE,0FAE9C;EAEA;CACF;CAEA,MAAM,aAAa,YAAY,IAAI;CACnC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,8CAA8C;AACjF;;;;;;;AAQA,MAAM,kBAAkB;;;;;;;;;;;;;AAcxB,MAAM,qBAAqB;;;;;AAM3B,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;AAoC9B,eAAe,+BAA4D;CACzE,MAAM,aAAa,QAAQ,eAAe;CAG1C,IAAI,CAAE,MAAM,gBAAgB,UAAU,GACpC,OAAO,EAAE,SAAS,SAAS;CAG7B,IAAI;CAEJ,IAAI;EACF,UAAU,MAAM,aAAa,UAAU;CACzC,SAAS,OAAO;EACd,OAAO;GACL,SAAS;GACT,QAAQ,sBAAuB,MAAgB,QAAQ;EACzD;CACF;CAEA,MAAM,UAAU,QAAQ,MAAM,kBAAkB,KAAK,CAAC;CAEtD,IAAI,QAAQ,WAAW,GACrB,OAAO,EAAE,SAAS,SAAS;CAG7B,IAAI,QAAQ,SAAS,GACnB,OAAO;EACL,SAAS;EACT,QAAQ,YAAY,QAAQ,OAAO;CACrC;CAGF,IAAI,sBAAsB,KAAK,OAAO,GACpC,OAAO;EACL,SAAS;EACT,QAAQ;CACV;CAGF,MAAM,OAAO,QAAQ,QAAQ,qBAAqB,OAAO,UACvD,MAAM,QAAQ,GAAG,MAAM,GAAG,SAAS,GAAG,MAAM,UAAU,OAAO,CAC/D;CAEA,IAAI,SAAS,SACX,OAAO;EAAE,SAAS;EAAY,QAAQ;CAA2C;CAGnF,IAAI;EACF,MAAM,aAAa,YAAY,IAAI;CACrC,SAAS,OAAO;EACd,OAAO;GACL,SAAS;GACT,QAAQ,yBAA0B,MAAgB,QAAQ;EAC5D;CACF;CAEA,OAAO,EAAE,SAAS,YAAY;AAChC;;;;;;;AAQA,eAAe,wBAAwB,UAA6B;CAClE,MAAM,WAAW,QAAQ,cAAc;CAEvC,IAAI,MAAM,gBAAgB,QAAQ,GAChC,QAAQ,IAAI,GAAG,OAAO,aAAa,SAAS,EAAE,iCAAiC;MAC1E;EACL,MAAM,qBAAqB,QAAQ,KAAK,CAAC;EACzC,MAAM,aAAa,UAAU,WAAW;EACxC,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,0BAA0B;EAE3D,MAAM,YAAY,MAAM,6BAA6B;EAErD,IAAI,UAAU,YAAY,aACxB,QAAQ,IACN,GAAG,OAAO,MAAM,GAAG,EAAE,sBAAsB,OAAO,aAAa,WAAS,EAAE,YACrE,OAAO,aAAa,cAAY,EAAE,MAAM,OAAO,aAAa,OAAO,iBAAiB,EAAE,sFAE7F;EAMF,IAAI,UAAU,YAAY,cAAc,UAAU,YAAY,UAAU;GACtE,MAAM,OAAO,UAAU,YAAY,WAAW,OAAO,UAAU,GAAG,IAAI,OAAO,aAAa,GAAG;GAE7F,QAAQ,IACN,GAAG,KAAK,0CACH,OAAO,aAAa,OAAO,iBAAiB,EAAE,GAAG,UAAU,OAAO,8BACzC,OAAO,aAAa,oBAAkB,EAAE;YAEvD,OAAO,aAAa,WAAS,EAAE,kKAGhD;GAUA,QAAQ,WAAW;EACrB,OAAO;GACL,MAAM,aAAa,QAAQ,oBAAoB,GAAG,eAAe;GACjE,MAAM,qBAAqB,QAAQ,yBAAyB,CAAC;GAC7D,MAAM,aACJ,QAAQ,+CAA+C,GACvD,wBACF;GACA,MAAM,aAAa,QAAQ,uBAAuB,GAAG,oBAAoB;GACzE,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,gCAAgC;GACjE,QAAQ,IAAI,GAAG,OAAO,MAAM,GAAG,EAAE,yCAAyC;EAC5E;CACF;CAEA,MAAM,qBAAqB;AAC7B;AAEA,MAAa,aAAgC;CAC3C,aACE;CACF,cAAc;EACZ,mBAAmB;EACnB,gBAAgB;EAChB,sBAAsB;EACtB,8BAA8B;EAC9B,OAAO;EACP,aAAa;CACf;CACA,iBAAiB;EACf,gBAAgB;EAChB,oBAAoB;EAGpB,MAAM;EACN,wBAAwB;CAC1B;CACA,aAAa;AACf"}
@@ -604,6 +604,7 @@ export default function App({ children }: AppProps) {
604
604
  that emits one too produces two.
605
605
  */}
606
606
  <Head />
607
+ <link rel="icon" href="data:," />
607
608
  </head>
608
609
  <body>
609
610
  {/*
@@ -666,7 +667,7 @@ import { contactController } from "./controllers/contact.controller";
666
667
  router.post("/api/contact", contactController);
667
668
  `;
668
669
  /**
669
- * `src/web/home.page.tsx` — one page, so \`warlock dev\` has something to serve
670
+ * `src/web/index.page.tsx` — one page, so \`warlock dev\` has something to serve
670
671
  * the moment this finishes.
671
672
  */
672
673
  const webHomePageStub = `import { http } from "@mongez/http";
@@ -681,11 +682,12 @@ import { Link, type PageProps } from "@warlock.js/web";
681
682
  * A page route is an ordinary Warlock route whose handler renders React
682
683
  * instead of returning JSON.
683
684
  *
684
- * The URL is the one this file DECLARES below. This page answers \`GET "/"\`
685
- * because \`route = "/"\`, not because of where the file lives. A page file with
685
+ * The URL and stable hydration name are the ones this file DECLARES below.
686
+ * This page answers \`GET "/"\` because \`route.path = "/"\`, not because of
687
+ * where the file lives. A page file with
686
688
  * no \`route\` export is REFUSED by both the dev server and the build.
687
689
  */
688
- export const route = "/";
690
+ export const route = { path: "/", name: "index" } as const;
689
691
 
690
692
  export const metadata = { title: "Home" };
691
693
 
@@ -695,32 +697,34 @@ const contactSchema = v.object({
695
697
  message: v.string().min(10).required(),
696
698
  });
697
699
 
698
- extend("en", {
699
- starter: {
700
- title: "Your Warlock app is running.",
701
- introduction: "This page is rendered on the server and hydrated in the browser.",
702
- language: "العربية",
703
- contact: "Send a message",
704
- name: "Name",
705
- email: "Email",
706
- message: "Message",
707
- submit: "Send message",
708
- sent: "Thanks — your message has been received.",
709
- },
710
- });
711
- extend("ar", {
712
- starter: {
713
- title: "تطبيق Warlock يعمل الآن.",
714
- introduction: "تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.",
715
- language: "English",
716
- contact: "أرسل رسالة",
717
- name: "الاسم",
718
- email: "البريد الإلكتروني",
719
- message: "الرسالة",
720
- submit: "إرسال الرسالة",
721
- sent: "شكرًا — تم استلام رسالتك.",
722
- },
723
- });
700
+ export function register() {
701
+ extend("en", {
702
+ starter: {
703
+ title: "Your Warlock app is running.",
704
+ introduction: "This page is rendered on the server and hydrated in the browser.",
705
+ language: "العربية",
706
+ contact: "Send a message",
707
+ name: "Name",
708
+ email: "Email",
709
+ message: "Message",
710
+ submit: "Send message",
711
+ sent: "Thanks — your message has been received.",
712
+ },
713
+ });
714
+ extend("ar", {
715
+ starter: {
716
+ title: "تطبيق Warlock يعمل الآن.",
717
+ introduction: "تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.",
718
+ language: "English",
719
+ contact: "أرسل رسالة",
720
+ name: "الاسم",
721
+ email: "البريد الإلكتروني",
722
+ message: "الرسالة",
723
+ submit: "إرسال الرسالة",
724
+ sent: "شكرًا — تم استلام رسالتك.",
725
+ },
726
+ });
727
+ }
724
728
 
725
729
  function TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {
726
730
  const { error, getErrorProps, getInputProps } = useFormControl(controlProps);
@@ -841,6 +845,7 @@ export default function HomePage(_props: PageProps) {
841
845
  <section className="wk-contact" aria-labelledby="contact-heading">
842
846
  <h2 id="contact-heading">{transX("starter.contact")}</h2>
843
847
  <Form<typeof contactSchema>
848
+ id="contact-form"
844
849
  schema={contactSchema}
845
850
  onSubmit={async ({ form, values }) => {
846
851
  setSubmitted(false);
@@ -1 +1 @@
1
- {"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\r\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\r\n\r\n/**\r\n * Authorization configuration — read by @warlock.js/access on boot.\r\n *\r\n * The resolver is the one required piece: it tells the engine how to read a\r\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\r\n * from the user_roles table and maps them through the roles catalog table (so\r\n * roles + their permissions are managed at runtime, in the DB).\r\n *\r\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\r\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\r\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\r\n *\r\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\r\n * tenant from the request; checks then scope to it automatically.\r\n */\r\nconst access: AccessConfigurations = {\r\n resolver: new DatabaseAccessResolver(),\r\n\r\n // Cache resolved permission sets (default \"10m\").\r\n // cache: { ttl: \"10m\" },\r\n};\r\n\r\nexport default access;\r\n`;\r\n\r\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\r\n\r\n// >>> warlock:ai-packages (auto-managed) >>>\r\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\r\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\r\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\r\n// side-effect import below; keep them so the augmentation + runtime registration\r\n// load before the ai connector applies this config.\r\n// <<< warlock:ai-packages <<<\r\n\r\n/**\r\n * AI configuration — applied on boot by the ai connector, which calls\r\n * ai.config(...) with the object below. Cross-cutting defaults live here\r\n * (shared cache / snapshot stores, observability); per-call options always win.\r\n *\r\n * Wire a default model from a provider you installed, e.g.:\r\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\r\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\r\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\r\n */\r\nconst ai: Partial<AIConfig> = {\r\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\r\n // defaultStore: cache.driver(\"redis\", { client }),\r\n\r\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\r\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\r\n};\r\n\r\nexport default ai;\r\n`;\r\n\r\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the roles catalog — mirrors the migration columns\r\n * (snake_case). Each row is a role name plus the permission strings it grants;\r\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\r\n * assigned role names through this table to their effective permissions.\r\n */\r\nexport const roleSchema = v.object({\r\n name: v.string(),\r\n permissions: v.array(v.string()).default([]),\r\n});\r\n\r\nexport type RoleSchema = Infer<typeof roleSchema>;\r\n\r\n/**\r\n * The roles catalog — role name → the permissions it grants. Managed at runtime\r\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\r\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\r\n */\r\n@RegisterModel()\r\nexport class Role extends Model<RoleSchema> {\r\n public static table = \"roles\";\r\n\r\n public static schema = roleSchema;\r\n\r\n /** The permission strings this role grants. */\r\n public get permissions(): string[] {\r\n return this.get<string[]>(\"permissions\", []);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\r\n`;\r\n\r\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\r\nimport { Role } from \"../role.model\";\r\n\r\n/**\r\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\r\n * text array of the permission strings the role grants.\r\n */\r\nexport default Migration.create(Role, {\r\n name: text().notNullable().unique(),\r\n permissions: arrayText().nullable(),\r\n});\r\n`;\r\n\r\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for a role assignment — mirrors the migration columns\r\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\r\n */\r\nexport const userRoleSchema = v.object({\r\n user_id: v.string(),\r\n user_type: v.string(),\r\n role: v.string(),\r\n tenant: v.string().optional(),\r\n});\r\n\r\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\r\n\r\n/**\r\n * The role-assignment table — which roles a user holds, optionally per tenant.\r\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\r\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\r\n * never need to call \\`access.flush(user, tenant)\\` themselves.\r\n */\r\n@RegisterModel()\r\nexport class UserRole extends Model<UserRoleSchema> {\r\n public static table = \"user_roles\";\r\n\r\n public static schema = userRoleSchema;\r\n\r\n /**\r\n * Role names assigned to the user in the given tenant.\r\n *\r\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\r\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\r\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\r\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\r\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\r\n */\r\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\r\n const rows = await this.query()\r\n .where({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n tenant: tenant ?? null,\r\n })\r\n .get();\r\n\r\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\r\n // existence check) can't distort the resolved set.\r\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\r\n }\r\n\r\n /**\r\n * Assign a role to the user. No-op if the assignment already exists.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\r\n const existing = await this.first({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n if (existing) return;\r\n\r\n await this.create({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n\r\n /**\r\n * Remove a role assignment from the user.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\r\n await this.delete({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\r\n`;\r\n\r\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\r\nimport { UserRole } from \"../user-role.model\";\r\n\r\n/**\r\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\r\n * user ids are integers. The composite index powers the per-user (per-tenant)\r\n * lookup the resolver runs on every check.\r\n */\r\nexport default Migration.create(\r\n UserRole,\r\n {\r\n user_id: uuid().notNullable().index(),\r\n user_type: text().notNullable(),\r\n role: text().notNullable().index(),\r\n tenant: text().nullable().index(),\r\n },\r\n {\r\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\r\n },\r\n);\r\n`;\r\n\r\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Role } from \"app/access/models/role\";\r\nimport { UserRole } from \"app/access/models/user-role\";\r\n\r\n/**\r\n * The app's access adapter — connects @warlock.js/access to the ejected role\r\n * tables. Roles come from the user_roles assignment table; permissions are\r\n * expanded by mapping those role names through the roles catalog table. Both\r\n * are managed at runtime (in the DB), so admins can add roles + edit their\r\n * permissions without a deploy.\r\n *\r\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\r\n * resolver only fetches — keep it dumb, never cache inside it.\r\n */\r\nexport class DatabaseAccessResolver implements AccessResolver {\r\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\r\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\r\n return UserRole.rolesFor(user, tenant);\r\n }\r\n\r\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\r\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\r\n const names = await this.resolveRoles(user, tenant);\r\n\r\n if (names.length === 0) return [];\r\n\r\n const roles = await Role.query().whereIn(\"name\", names).get();\r\n\r\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\r\n return [...new Set(roles.flatMap((role) => role.permissions))];\r\n }\r\n\r\n /**\r\n * Optional. Resolve the ambient tenant when a check doesn't pass one\r\n * explicitly — derive it from the authenticated user (safer than reading\r\n * client request input, which a caller could spoof). Uncomment + adapt for a\r\n * multi-tenant app (single-tenant apps leave this off and return undefined).\r\n */\r\n // public resolveTenant(user: Auth): string | undefined {\r\n // return user.get(\"organization_id\");\r\n // }\r\n}\r\n`;\r\n\r\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\r\n\r\n/**\r\n * Socket.IO configuration — read by the framework's socket connector\r\n * on boot. When the HTTP server is running the socket server attaches\r\n * to it; otherwise it listens on its own configured port.\r\n *\r\n * Remove this file to disable the socket server entirely.\r\n */\r\nexport default {\r\n options: {\r\n cors: {\r\n origin: \"*\",\r\n },\r\n },\r\n} as SocketOptions;\r\n`;\r\n\r\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\r\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\r\n\r\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\r\n driver: \"rabbitmq\",\r\n name: \"default\",\r\n isDefault: true,\r\n\r\n // ============================================================================\r\n // Connection Settings\r\n // ============================================================================\r\n\r\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\r\n port: env(\"RABBITMQ_PORT\", 5672),\r\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\r\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\r\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\r\n\r\n // Or use connection URI (takes precedence over host/port)\r\n // uri: env(\"RABBITMQ_URL\"),\r\n\r\n // ============================================================================\r\n // Connection Options\r\n // ============================================================================\r\n\r\n /** Heartbeat interval in seconds */\r\n heartbeat: 60,\r\n\r\n /** Connection timeout in milliseconds */\r\n connectionTimeout: 10000,\r\n\r\n /** Enable automatic reconnection on disconnect */\r\n reconnect: true,\r\n\r\n /** Delay between reconnection attempts in milliseconds */\r\n reconnectDelay: 5_000,\r\n\r\n // ============================================================================\r\n // Consumer Options\r\n // ============================================================================\r\n\r\n /** Default prefetch count (number of unacknowledged messages per consumer) */\r\n prefetch: 10,\r\n\r\n // ============================================================================\r\n // Client Options (Native amqplib options)\r\n // ============================================================================\r\n // These options are passed directly to amqplib.connect()\r\n // for low-level configuration like frame size, TLS, socket options, etc.\r\n // ============================================================================\r\n clientOptions: {\r\n // Frame max size in bytes (0 = no limit)\r\n // frameMax: 0,\r\n\r\n // Channel max (0 = unlimited)\r\n // channelMax: 0,\r\n\r\n // Socket options\r\n socket: {\r\n // Enable TCP keep-alive\r\n keepAlive: true,\r\n\r\n // Disable Nagle's algorithm for lower latency\r\n noDelay: true,\r\n\r\n // Socket timeout (in addition to heartbeat)\r\n // timeout: 30000,\r\n },\r\n\r\n // TLS/SSL options (uncomment for secure connections)\r\n // socket: {\r\n // ca: fs.readFileSync('/path/to/ca.pem'),\r\n // cert: fs.readFileSync('/path/to/cert.pem'),\r\n // key: fs.readFileSync('/path/to/key.pem'),\r\n // rejectUnauthorized: true,\r\n // },\r\n },\r\n};\r\n\r\nexport default heraldConfigurations;\r\n`;\r\n\r\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"app/notifications/notification.model\";\r\n\r\n/**\r\n * Notifications configuration. Auto-loaded from src/config on boot — the\r\n * framework's notifications connector reads this default export and hands it to\r\n * setNotificationConfig, so this file stays declarative (no side-effect call).\r\n *\r\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\r\n * and defineNotification are type-checked against the registry.\r\n *\r\n * Channels enabled here:\r\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\r\n * The \"from\" address defaults to config/mail.ts; override per\r\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\r\n * - database in-app store backed by the Notification model. The \"inApp\"\r\n * facade exposes the recipient-scoped read API: listUnread,\r\n * countUnread, markAsRead, dismiss, ...\r\n *\r\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\r\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\r\n * queue line below.\r\n */\r\nconst config: NotificationConfig = {\r\n channels: {\r\n mail: mailChannel(),\r\n database: inApp.configure({ model: Notification }),\r\n },\r\n\r\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\r\n // queue: heraldQueue(),\r\n};\r\n\r\nexport default config;\r\n`;\r\n\r\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\r\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\r\nimport { v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the notifications table — mirrors the migration\r\n * columns (snake_case). Cascade validates + casts every write against it:\r\n * nullable columns use .nullish() (may be absent or null), and payload is\r\n * free-form JSON. Keep this in sync with the migration + columnMap when you\r\n * add or rename columns.\r\n */\r\nconst notificationSchema = v.object({\r\n user_id: v.string(),\r\n type: v.string(),\r\n title: v.string(),\r\n body: v.string().nullish(),\r\n payload: v.record(v.any()).nullish(),\r\n read_at: v.date().nullish(),\r\n idempotency_key: v.string().nullish(),\r\n});\r\n\r\n/**\r\n * In-app notification model.\r\n *\r\n * Extends the package's DatabaseNotification base, which provides the stable\r\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\r\n * from the columnMap below. The read/write API lives on the inApp facade\r\n * (configured in config/notifications.ts); you rarely touch this class directly.\r\n */\r\n@RegisterModel()\r\nexport class Notification extends DatabaseNotification {\r\n public static table = \"notifications\";\r\n public static schema = notificationSchema;\r\n\r\n /**\r\n * Maps the in-app store's roles to your columns. This default is\r\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\r\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\r\n * track a boolean read flag. The migration + accessors all follow this map.\r\n */\r\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\r\n}\r\n`;\r\n\r\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\r\nimport { notificationColumns } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"../notification.model\";\r\n\r\n/**\r\n * Notifications table.\r\n *\r\n * Columns come from notificationColumns(Notification) — the recipient / tenant\r\n * / read-state names follow the model's columnMap; type / title / body /\r\n * payload / idempotency_key are fixed. Spread it to add your own columns\r\n * (remember to mirror them in the model schema):\r\n *\r\n * import { uuid } from \"@warlock.js/cascade\";\r\n *\r\n * export default Migration.create(Notification, {\r\n * ...notificationColumns(Notification),\r\n * // category_id: uuid().index().nullable(),\r\n * });\r\n */\r\nexport default Migration.create(Notification, notificationColumns(Notification));\r\n`;\r\n\r\nexport const notificationControllersStub = `import { type Request, type RequestHandler, type Response } from \"@warlock.js/core\";\r\nimport { inApp } from \"@warlock.js/notifications\";\r\n\r\n/**\r\n * The authenticated user's notification HTTP surface — thin wrappers over the\r\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\r\n * rows). Notifications are produced by domain events, never over HTTP, so there\r\n * is no create. Trim or split these as your app grows.\r\n */\r\n\r\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\r\nexport const listNotificationsController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const { data, pagination } = await inApp.list(request.user!, request.all());\r\n\r\n return response.success({ notifications: data, pagination });\r\n};\r\n\r\nlistNotificationsController.description = \"List notifications\";\r\n\r\n/** GET /notifications/unread-count — drives the bell badge. */\r\nexport const unreadNotificationsCountController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const count = await inApp.countUnread(request.user!);\r\n\r\n return response.success({ count });\r\n};\r\n\r\nunreadNotificationsCountController.description = \"Unread notifications count\";\r\n\r\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\r\nexport const markNotificationReadController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const id = request.input(\"id\");\r\n\r\n await inApp.markAsRead(request.user!, id);\r\n const notification = await inApp.find(request.user!, id);\r\n\r\n return response.success({ notification });\r\n};\r\n\r\nmarkNotificationReadController.description = \"Mark notification read\";\r\n\r\n/** PATCH /notifications/read-all — mark every unread one read. */\r\nexport const markAllNotificationsReadController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const count = await inApp.markAsRead(request.user!);\r\n\r\n return response.success({ count });\r\n};\r\n\r\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\r\n\r\n/** DELETE /notifications — dismiss all for the user. */\r\nexport const clearNotificationsController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n await inApp.dismiss(request.user!);\r\n\r\n return response.noContent();\r\n};\r\n\r\nclearNotificationsController.description = \"Clear notifications\";\r\n\r\n/** DELETE /notifications/:id — dismiss one. */\r\nexport const deleteNotificationController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n await inApp.dismiss(request.user!, request.input(\"id\"));\r\n\r\n return response.noContent();\r\n};\r\n\r\ndeleteNotificationController.description = \"Delete notification\";\r\n`;\r\n\r\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\r\nimport { router } from \"@warlock.js/core\";\r\nimport {\r\n clearNotificationsController,\r\n deleteNotificationController,\r\n listNotificationsController,\r\n markAllNotificationsReadController,\r\n markNotificationReadController,\r\n unreadNotificationsCountController,\r\n} from \"./controllers/notifications.controller\";\r\n\r\n/**\r\n * Notification routes — the authenticated user's read + dismiss surface.\r\n *\r\n * Notifications are produced by domain events (never created over HTTP), so\r\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\r\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\r\n * don't need; if your app reads notifications over sockets/GraphQL instead,\r\n * delete this file + the controllers entirely.\r\n */\r\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\r\n router.get(\"/\", listNotificationsController);\r\n router.get(\"/unread-count\", unreadNotificationsCountController);\r\n router.patch(\"/read-all\", markAllNotificationsReadController);\r\n router.patch(\"/:id/read\", markNotificationReadController);\r\n router.delete(\"/\", clearNotificationsController);\r\n router.delete(\"/:id\", deleteNotificationController);\r\n});\r\n`;\r\n\r\n/**\r\n * `src/web/root.tsx` — the application root for the SSR page layer.\r\n *\r\n * Deliberately minimal. The framework ships a default root, so this exists to\r\n * give you a place to start rather than because anything requires it. The\r\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\r\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\r\n */\r\nexport const webRootStub = `import { Head, Scripts } from \"@warlock.js/web\";\r\nimport type { AppProps } from \"@warlock.js/web\";\r\n\r\n/**\r\n * The application root.\r\n *\r\n * NOT async, and it receives no request/response: it renders on the server and\r\n * again in the browser during hydration, where neither exists.\r\n */\r\nexport default function App({ children }: AppProps) {\r\n return (\r\n <html lang=\"en\">\r\n <head>\r\n {/*\r\n Placement only. The framework injects the page's \\`metadata\\`, the\r\n stylesheet and preload tags for this route, and the canonical links\r\n into <head> by default — <Head /> just says WHERE they land.\r\n\r\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\r\n that emits one too produces two.\r\n */}\r\n <Head />\r\n </head>\r\n <body>\r\n {/*\r\n REQUIRED — this is the hydration mount point, not a styling wrapper.\r\n\r\n The browser runtime looks up \\`#root\\` and hydrates that element only.\r\n Remove this div, or rename the id, and the page still renders from the\r\n server but never becomes interactive: the runtime throws in the console\r\n and nothing on screen changes.\r\n\r\n Wrap it in your own markup freely, and put anything that must live\r\n outside the hydrated tree (a static footer, a portal target) outside\r\n it — just keep an element with \\`id=\"root\"\\` around {children}.\r\n */}\r\n <div id=\"root\">{children}</div>\r\n {/*\r\n The hydration payload and module tags. Written explicitly because\r\n placement occasionally matters — a CSP nonce, or ordering against\r\n your own scripts.\r\n */}\r\n <Scripts />\r\n </body>\r\n </html>\r\n );\r\n}\r\n`;\r\n\r\n/**\r\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\r\n * for the Web starter's contact form. It intentionally has no persistence\r\n * dependency: replace the acknowledgement with a mail/job/database action.\r\n */\r\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\nexport const contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\r\n\r\n/** POST /api/contact — validates the starter contact form. */\r\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({ request, response }) => {\r\n const contact = request.validated();\r\n\r\n // Replace this with delivery/persistence for your app. Keeping the accepted\r\n // payload visible makes the endpoint useful while remaining side-effect free.\r\n return response.success({\r\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\r\n });\r\n};\r\n\r\ncontactController.validation = { schema: contactSchema };\r\n`;\r\n\r\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\r\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\r\nimport { contactController } from \"./controllers/contact.controller\";\r\n\r\nrouter.post(\"/api/contact\", contactController);\r\n`;\r\n\r\n/**\r\n * `src/web/home.page.tsx` — one page, so \\`warlock dev\\` has something to serve\r\n * the moment this finishes.\r\n */\r\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\r\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\r\nimport { extend, setCurrentLocaleCode } from \"@mongez/localization\";\r\nimport { transX } from \"@mongez/react-localization\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { useState } from \"react\";\r\nimport { Link, type PageProps } from \"@warlock.js/web\";\r\n\r\n/**\r\n * A page route is an ordinary Warlock route whose handler renders React\r\n * instead of returning JSON.\r\n *\r\n * The URL is the one this file DECLARES below. This page answers \\`GET \"/\"\\`\r\n * because \\`route = \"/\"\\`, not because of where the file lives. A page file with\r\n * no \\`route\\` export is REFUSED by both the dev server and the build.\r\n */\r\nexport const route = \"/\";\r\n\r\nexport const metadata = { title: \"Home\" };\r\n\r\nconst contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nextend(\"en\", {\r\n starter: {\r\n title: \"Your Warlock app is running.\",\r\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\r\n language: \"العربية\",\r\n contact: \"Send a message\",\r\n name: \"Name\",\r\n email: \"Email\",\r\n message: \"Message\",\r\n submit: \"Send message\",\r\n sent: \"Thanks — your message has been received.\",\r\n },\r\n});\r\nextend(\"ar\", {\r\n starter: {\r\n title: \"تطبيق Warlock يعمل الآن.\",\r\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\r\n language: \"English\",\r\n contact: \"أرسل رسالة\",\r\n name: \"الاسم\",\r\n email: \"البريد الإلكتروني\",\r\n message: \"الرسالة\",\r\n submit: \"إرسال الرسالة\",\r\n sent: \"شكرًا — تم استلام رسالتك.\",\r\n },\r\n});\r\n\r\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\r\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor={controlProps.name}>{label}</label>\r\n <input {...getInputProps()} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n\r\n/**\r\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\r\n * \\`data\\`, typed:\r\n *\r\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\r\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\r\n */\r\nexport default function HomePage(_props: PageProps) {\r\n // Live state. If the button below does nothing, the page rendered on the\r\n // server but never hydrated — the runtime never mounted at \\`#root\\`. This is\r\n // deliberately here so that failure is impossible to miss.\r\n const [count, setCount] = useState(0);\r\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\r\n const [submitted, setSubmitted] = useState(false);\r\n const [submitError, setSubmitError] = useState<string | null>(null);\r\n\r\n const toggleLocale = () => {\r\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\r\n setCurrentLocaleCode(nextLocale);\r\n setLocale(nextLocale);\r\n };\r\n\r\n return (\r\n <>\r\n {/*\r\n Self-contained, dependency-free styling: plain CSS, system fonts, and\r\n CSS custom properties, scoped to this page. No CSS framework, no utility\r\n classes, no external stylesheet — this page looks the same whether or\r\n not \\`warlock add tailwind\\` has ever been run.\r\n */}\r\n <style>{\\`\r\n .wk-home {\r\n --wk-fg: #0f172a;\r\n --wk-muted: #64748b;\r\n --wk-accent: #4f46e5;\r\n --wk-border: #e2e8f0;\r\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\r\n color: var(--wk-fg);\r\n max-width: 42rem;\r\n margin: 4rem auto;\r\n padding: 0 1.5rem;\r\n line-height: 1.6;\r\n }\r\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\r\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\r\n .wk-home code {\r\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\r\n background: #f1f5f9;\r\n padding: 0.1rem 0.35rem;\r\n border-radius: 0.25rem;\r\n }\r\n .wk-check {\r\n border: 1px solid var(--wk-border);\r\n border-radius: 0.75rem;\r\n padding: 1.25rem 1.5rem;\r\n margin: 2rem 0;\r\n }\r\n .wk-check strong { display: block; font-size: 1.5rem; }\r\n .wk-check button {\r\n font: inherit;\r\n cursor: pointer;\r\n background: var(--wk-accent);\r\n color: #fff;\r\n border: 0;\r\n border-radius: 0.5rem;\r\n padding: 0.5rem 1rem;\r\n margin-top: 0.75rem;\r\n }\r\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\r\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\r\n .wk-links a:hover { text-decoration: underline; }\r\n .wk-language { margin-left: auto; }\r\n .wk-contact { margin-top: 2rem; }\r\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\r\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\r\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\r\n .wk-success { color: #047857; }\r\n \\`}</style>\r\n\r\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\r\n <nav className=\"wk-links\" aria-label=\"Starter links\">\r\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">Docs</a>\r\n <Link href=\"/\" aria-current=\"page\">Home</Link>\r\n <button\r\n className=\"wk-language\"\r\n type=\"button\"\r\n aria-pressed={locale === \"ar\"}\r\n onClick={toggleLocale}\r\n >\r\n {transX(\"starter.language\")}\r\n </button>\r\n </nav>\r\n\r\n <h1>{transX(\"starter.title\")}</h1>\r\n <p>{transX(\"starter.introduction\")}</p>\r\n\r\n <section className=\"wk-check\">\r\n <label>If this number goes up when you click, React is hydrated:</label>\r\n <strong>{count}</strong>\r\n <button type=\"button\" onClick={() => setCount(c => c + 1)}>\r\n Count up\r\n </button>\r\n </section>\r\n\r\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\r\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\r\n <Form<typeof contactSchema>\r\n schema={contactSchema}\r\n onSubmit={async ({ form, values }) => {\r\n setSubmitted(false);\r\n setSubmitError(null);\r\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\r\n\r\n if (result.error) {\r\n if (result.error.isValidationError) {\r\n const body = result.error.body as {\r\n errors?: Array<{ input: string; error: string }>;\r\n message?: string;\r\n };\r\n form.setErrors(\r\n Object.fromEntries(\r\n (body.errors ?? []).map(({ input, error }) => [input, error]),\r\n ),\r\n );\r\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\r\n } else {\r\n setSubmitError(\"Your message could not be sent. Please try again.\");\r\n }\r\n return;\r\n }\r\n\r\n setSubmitted(true);\r\n form.reset();\r\n }}\r\n >\r\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\r\n <TextInput name=\"email\" label={transX(\"starter.email\")} type=\"email\" autoComplete=\"email\" />\r\n <ContactMessage />\r\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\r\n {submitError && <p className=\"wk-submit-error\" role=\"alert\">{submitError}</p>}\r\n {submitted && <p className=\"wk-success\" role=\"status\">{transX(\"starter.sent\")}</p>}\r\n </Form>\r\n </section>\r\n </main>\r\n </>\r\n );\r\n}\r\n\r\nfunction ContactMessage() {\r\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\r\n <textarea {...getInputProps()} rows={5} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n`;\r\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsF3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;AA0BxC,MAAa,uBAAuB;;;;;;;;;AAUpC,MAAa,kBAAkB"}
1
+ {"version":3,"file":"stubs.mjs","names":[],"sources":["../../../../../../../core/src/generations/stubs.ts"],"sourcesContent":["export const accessConfigStub = `import { type AccessConfigurations } from \"@warlock.js/access\";\r\nimport { DatabaseAccessResolver } from \"app/access/services/access-resolver\";\r\n\r\n/**\r\n * Authorization configuration — read by @warlock.js/access on boot.\r\n *\r\n * The resolver is the one required piece: it tells the engine how to read a\r\n * user's roles + permissions. The ejected DatabaseAccessResolver reads roles\r\n * from the user_roles table and maps them through the roles catalog table (so\r\n * roles + their permissions are managed at runtime, in the DB).\r\n *\r\n * For a fixed, code-defined catalog with no tables, swap in DefaultAccessResolver:\r\n * import { DefaultAccessResolver } from \"@warlock.js/access\";\r\n * resolver: new DefaultAccessResolver({ admin: [\"*\"], editor: [\"orders.*\"] }),\r\n *\r\n * Multi-tenant? Add a \\`resolveTenant()\\` to the resolver to read the active\r\n * tenant from the request; checks then scope to it automatically.\r\n */\r\nconst access: AccessConfigurations = {\r\n resolver: new DatabaseAccessResolver(),\r\n\r\n // Cache resolved permission sets (default \"10m\").\r\n // cache: { ttl: \"10m\" },\r\n};\r\n\r\nexport default access;\r\n`;\r\n\r\nexport const aiConfigStub = `import type { AIConfig } from \"@warlock.js/ai\";\r\n\r\n// >>> warlock:ai-packages (auto-managed) >>>\r\n// Satellite packages augment the \"ai\" object on import — e.g. ai.workspace,\r\n// ai.tools / ai.mcp, and panoptic's ai.config({ panoptic }) wiring. The command\r\n// \"warlock add ai-workspace | ai-tools | ai-panoptic\" adds the matching\r\n// side-effect import below; keep them so the augmentation + runtime registration\r\n// load before the ai connector applies this config.\r\n// <<< warlock:ai-packages <<<\r\n\r\n/**\r\n * AI configuration — applied on boot by the ai connector, which calls\r\n * ai.config(...) with the object below. Cross-cutting defaults live here\r\n * (shared cache / snapshot stores, observability); per-call options always win.\r\n *\r\n * Wire a default model from a provider you installed, e.g.:\r\n * import { OpenAISDK } from \"@warlock.js/ai-openai\";\r\n * const openai = OpenAISDK({ apiKey: env(\"OPENAI_API_KEY\") });\r\n * // then pass openai.model({ name: \"gpt-4o-mini\" }) into your agents.\r\n */\r\nconst ai: Partial<AIConfig> = {\r\n // Default cache driver for cache-backed AI features (semantic cache, rag / memory vector stores).\r\n // defaultStore: cache.driver(\"redis\", { client }),\r\n\r\n // Observability — requires \"warlock add ai-panoptic\". Exporters + the local dashboard.\r\n // panoptic: { exporters: [], dashboard: false, observeAll: false },\r\n};\r\n\r\nexport default ai;\r\n`;\r\n\r\nexport const accessRoleModelStub = `import { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the roles catalog — mirrors the migration columns\r\n * (snake_case). Each row is a role name plus the permission strings it grants;\r\n * wildcards work (\"orders.*\", \"*\"). The DatabaseAccessResolver maps a user's\r\n * assigned role names through this table to their effective permissions.\r\n */\r\nexport const roleSchema = v.object({\r\n name: v.string(),\r\n permissions: v.array(v.string()).default([]),\r\n});\r\n\r\nexport type RoleSchema = Infer<typeof roleSchema>;\r\n\r\n/**\r\n * The roles catalog — role name → the permissions it grants. Managed at runtime\r\n * (admins add roles + edit their permissions), unlike a fixed code map. Read by\r\n * DatabaseAccessResolver.resolvePermissions to expand a user's roles to permissions.\r\n */\r\n@RegisterModel()\r\nexport class Role extends Model<RoleSchema> {\r\n public static table = \"roles\";\r\n\r\n public static schema = roleSchema;\r\n\r\n /** The permission strings this role grants. */\r\n public get permissions(): string[] {\r\n return this.get<string[]>(\"permissions\", []);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessRoleModelIndexStub = `export * from \"./role.model\";\r\n`;\r\n\r\nexport const accessRoleMigrationStub = `import { arrayText, Migration, text } from \"@warlock.js/cascade\";\r\nimport { Role } from \"../role.model\";\r\n\r\n/**\r\n * Roles catalog table. \\`name\\` is unique (one row per role); \\`permissions\\` is a\r\n * text array of the permission strings the role grants.\r\n */\r\nexport default Migration.create(Role, {\r\n name: text().notNullable().unique(),\r\n permissions: arrayText().nullable(),\r\n});\r\n`;\r\n\r\nexport const accessUserRoleModelStub = `import { access } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Model, RegisterModel } from \"@warlock.js/cascade\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for a role assignment — mirrors the migration columns\r\n * (snake_case). \\`tenant\\` is nullable: a null tenant is a GLOBAL assignment.\r\n */\r\nexport const userRoleSchema = v.object({\r\n user_id: v.string(),\r\n user_type: v.string(),\r\n role: v.string(),\r\n tenant: v.string().optional(),\r\n});\r\n\r\nexport type UserRoleSchema = Infer<typeof userRoleSchema>;\r\n\r\n/**\r\n * The role-assignment table — which roles a user holds, optionally per tenant.\r\n * Read by DatabaseAccessResolver.resolveRoles; mutated via the statics below.\r\n * \\`assign\\` / \\`revoke\\` flush the cached permission set automatically, so callers\r\n * never need to call \\`access.flush(user, tenant)\\` themselves.\r\n */\r\n@RegisterModel()\r\nexport class UserRole extends Model<UserRoleSchema> {\r\n public static table = \"user_roles\";\r\n\r\n public static schema = userRoleSchema;\r\n\r\n /**\r\n * Role names assigned to the user in the given tenant.\r\n *\r\n * An unresolved tenant (\\`undefined\\`) scopes to GLOBAL roles only — the rows\r\n * stored with no tenant (\\`null\\`) — never the union across every tenant. The\r\n * union would be a privilege-escalation: a user who is \\`owner\\` in one tenant\r\n * must not be treated as \\`owner\\` everywhere just because a check didn't carry\r\n * a tenant. This mirrors how \\`assign(user, role)\\` stores a global row.\r\n */\r\n public static async rolesFor(user: Auth, tenant?: string): Promise<string[]> {\r\n const rows = await this.query()\r\n .where({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n tenant: tenant ?? null,\r\n })\r\n .get();\r\n\r\n // De-dupe so a duplicate row (a concurrent assign that slipped past the\r\n // existence check) can't distort the resolved set.\r\n return [...new Set(rows.map((row) => row.get(\"role\") as string))];\r\n }\r\n\r\n /**\r\n * Assign a role to the user. No-op if the assignment already exists.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async assign(user: Auth, role: string, tenant?: string): Promise<void> {\r\n const existing = await this.first({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n if (existing) return;\r\n\r\n await this.create({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n\r\n /**\r\n * Remove a role assignment from the user.\r\n * Flushes the user's cached permission set automatically.\r\n */\r\n public static async revoke(user: Auth, role: string, tenant?: string): Promise<void> {\r\n await this.delete({\r\n user_id: user.id,\r\n user_type: user.userType,\r\n role,\r\n tenant: tenant ?? null,\r\n });\r\n\r\n await access.flush(user, tenant);\r\n }\r\n}\r\n`;\r\n\r\nexport const accessUserRoleModelIndexStub = `export * from \"./user-role.model\";\r\n`;\r\n\r\nexport const accessUserRoleMigrationStub = `import { Migration, text, uuid } from \"@warlock.js/cascade\";\r\nimport { UserRole } from \"../user-role.model\";\r\n\r\n/**\r\n * Role-assignment table. \\`user_id\\` is a UUID — override this migration if your\r\n * user ids are integers. The composite index powers the per-user (per-tenant)\r\n * lookup the resolver runs on every check.\r\n */\r\nexport default Migration.create(\r\n UserRole,\r\n {\r\n user_id: uuid().notNullable().index(),\r\n user_type: text().notNullable(),\r\n role: text().notNullable().index(),\r\n tenant: text().nullable().index(),\r\n },\r\n {\r\n index: [{ columns: [\"user_id\", \"user_type\", \"tenant\"] }],\r\n },\r\n);\r\n`;\r\n\r\nexport const accessResolverStub = `import type { AccessResolver } from \"@warlock.js/access\";\r\nimport type { Auth } from \"@warlock.js/auth\";\r\nimport { Role } from \"app/access/models/role\";\r\nimport { UserRole } from \"app/access/models/user-role\";\r\n\r\n/**\r\n * The app's access adapter — connects @warlock.js/access to the ejected role\r\n * tables. Roles come from the user_roles assignment table; permissions are\r\n * expanded by mapping those role names through the roles catalog table. Both\r\n * are managed at runtime (in the DB), so admins can add roles + edit their\r\n * permissions without a deploy.\r\n *\r\n * The engine owns the hard parts (wildcard matching, caching, fail-closed); this\r\n * resolver only fetches — keep it dumb, never cache inside it.\r\n */\r\nexport class DatabaseAccessResolver implements AccessResolver {\r\n /** The role names this user holds (powers \\`hasRole\\` / \\`hasAnyRole\\`). */\r\n public async resolveRoles(user: Auth, tenant?: string): Promise<string[]> {\r\n return UserRole.rolesFor(user, tenant);\r\n }\r\n\r\n /** The effective permission strings this user has (powers \\`can\\` / \\`authorize\\`). */\r\n public async resolvePermissions(user: Auth, tenant?: string): Promise<string[]> {\r\n const names = await this.resolveRoles(user, tenant);\r\n\r\n if (names.length === 0) return [];\r\n\r\n const roles = await Role.query().whereIn(\"name\", names).get();\r\n\r\n // Flatten + de-dupe so two roles granting the same permission yield one entry.\r\n return [...new Set(roles.flatMap((role) => role.permissions))];\r\n }\r\n\r\n /**\r\n * Optional. Resolve the ambient tenant when a check doesn't pass one\r\n * explicitly — derive it from the authenticated user (safer than reading\r\n * client request input, which a caller could spoof). Uncomment + adapt for a\r\n * multi-tenant app (single-tenant apps leave this off and return undefined).\r\n */\r\n // public resolveTenant(user: Auth): string | undefined {\r\n // return user.get(\"organization_id\");\r\n // }\r\n}\r\n`;\r\n\r\nexport const socketConfigStub = `import type { SocketOptions } from \"@warlock.js/core\";\r\n\r\n/**\r\n * Socket.IO configuration — read by the framework's socket connector\r\n * on boot. When the HTTP server is running the socket server attaches\r\n * to it; otherwise it listens on its own configured port.\r\n *\r\n * Remove this file to disable the socket server entirely.\r\n */\r\nexport default {\r\n options: {\r\n cors: {\r\n origin: \"*\",\r\n },\r\n },\r\n} as SocketOptions;\r\n`;\r\n\r\nexport const communicatorsConfigStub = `import { env } from \"@warlock.js/core\";\r\nimport type { BrokerConfigurations, RabbitMQClientOptions } from \"@warlock.js/herald\";\r\n\r\nconst heraldConfigurations: BrokerConfigurations<RabbitMQClientOptions> = {\r\n driver: \"rabbitmq\",\r\n name: \"default\",\r\n isDefault: true,\r\n\r\n // ============================================================================\r\n // Connection Settings\r\n // ============================================================================\r\n\r\n host: env(\"RABBITMQ_HOST\", \"localhost\"),\r\n port: env(\"RABBITMQ_PORT\", 5672),\r\n username: env(\"RABBITMQ_USERNAME\", \"guest\"),\r\n password: env(\"RABBITMQ_PASSWORD\", \"guest\"),\r\n vhost: env(\"RABBITMQ_VHOST\", \"/\"),\r\n\r\n // Or use connection URI (takes precedence over host/port)\r\n // uri: env(\"RABBITMQ_URL\"),\r\n\r\n // ============================================================================\r\n // Connection Options\r\n // ============================================================================\r\n\r\n /** Heartbeat interval in seconds */\r\n heartbeat: 60,\r\n\r\n /** Connection timeout in milliseconds */\r\n connectionTimeout: 10000,\r\n\r\n /** Enable automatic reconnection on disconnect */\r\n reconnect: true,\r\n\r\n /** Delay between reconnection attempts in milliseconds */\r\n reconnectDelay: 5_000,\r\n\r\n // ============================================================================\r\n // Consumer Options\r\n // ============================================================================\r\n\r\n /** Default prefetch count (number of unacknowledged messages per consumer) */\r\n prefetch: 10,\r\n\r\n // ============================================================================\r\n // Client Options (Native amqplib options)\r\n // ============================================================================\r\n // These options are passed directly to amqplib.connect()\r\n // for low-level configuration like frame size, TLS, socket options, etc.\r\n // ============================================================================\r\n clientOptions: {\r\n // Frame max size in bytes (0 = no limit)\r\n // frameMax: 0,\r\n\r\n // Channel max (0 = unlimited)\r\n // channelMax: 0,\r\n\r\n // Socket options\r\n socket: {\r\n // Enable TCP keep-alive\r\n keepAlive: true,\r\n\r\n // Disable Nagle's algorithm for lower latency\r\n noDelay: true,\r\n\r\n // Socket timeout (in addition to heartbeat)\r\n // timeout: 30000,\r\n },\r\n\r\n // TLS/SSL options (uncomment for secure connections)\r\n // socket: {\r\n // ca: fs.readFileSync('/path/to/ca.pem'),\r\n // cert: fs.readFileSync('/path/to/cert.pem'),\r\n // key: fs.readFileSync('/path/to/key.pem'),\r\n // rejectUnauthorized: true,\r\n // },\r\n },\r\n};\r\n\r\nexport default heraldConfigurations;\r\n`;\r\n\r\nexport const notificationsConfigStub = `import { type NotificationConfig, inApp, mailChannel } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"app/notifications/notification.model\";\r\n\r\n/**\r\n * Notifications configuration. Auto-loaded from src/config on boot — the\r\n * framework's notifications connector reads this default export and hands it to\r\n * setNotificationConfig, so this file stays declarative (no side-effect call).\r\n *\r\n * Each channel is payload-typed, so notify.mail(...) / notify.database(...)\r\n * and defineNotification are type-checked against the registry.\r\n *\r\n * Channels enabled here:\r\n * - mail wraps @warlock.js/core sendMail; route is notifiable.email.\r\n * The \"from\" address defaults to config/mail.ts; override per\r\n * channel with mailChannel({ from: \"no-reply@yourapp.com\" }).\r\n * - database in-app store backed by the Notification model. The \"inApp\"\r\n * facade exposes the recipient-scoped read API: listUnread,\r\n * countUnread, markAsRead, dismiss, ...\r\n *\r\n * Async delivery (.queue()) is OPTIONAL: run \"npx warlock add herald\",\r\n * import { heraldQueue } from \"@warlock.js/notifications\", and uncomment the\r\n * queue line below.\r\n */\r\nconst config: NotificationConfig = {\r\n channels: {\r\n mail: mailChannel(),\r\n database: inApp.configure({ model: Notification }),\r\n },\r\n\r\n // Async queue — requires @warlock.js/herald (npx warlock add herald):\r\n // queue: heraldQueue(),\r\n};\r\n\r\nexport default config;\r\n`;\r\n\r\nexport const notificationModelStub = `import { RegisterModel } from \"@warlock.js/cascade\";\r\nimport { DatabaseNotification, type NotificationColumnMap } from \"@warlock.js/notifications\";\r\nimport { v } from \"@warlock.js/seal\";\r\n\r\n/**\r\n * Validation schema for the notifications table — mirrors the migration\r\n * columns (snake_case). Cascade validates + casts every write against it:\r\n * nullable columns use .nullish() (may be absent or null), and payload is\r\n * free-form JSON. Keep this in sync with the migration + columnMap when you\r\n * add or rename columns.\r\n */\r\nconst notificationSchema = v.object({\r\n user_id: v.string(),\r\n type: v.string(),\r\n title: v.string(),\r\n body: v.string().nullish(),\r\n payload: v.record(v.any()).nullish(),\r\n read_at: v.date().nullish(),\r\n idempotency_key: v.string().nullish(),\r\n});\r\n\r\n/**\r\n * In-app notification model.\r\n *\r\n * Extends the package's DatabaseNotification base, which provides the stable\r\n * accessors (recipientId, tenantId, isRead, readAt, markRead) — all derived\r\n * from the columnMap below. The read/write API lives on the inApp facade\r\n * (configured in config/notifications.ts); you rarely touch this class directly.\r\n */\r\n@RegisterModel()\r\nexport class Notification extends DatabaseNotification {\r\n public static table = \"notifications\";\r\n public static schema = notificationSchema;\r\n\r\n /**\r\n * Maps the in-app store's roles to your columns. This default is\r\n * single-tenant + read_at-only. Add tenant: \"organization_id\" for\r\n * multi-tenant; use isRead: \"is_read\" (instead of, or alongside, readAt) to\r\n * track a boolean read flag. The migration + accessors all follow this map.\r\n */\r\n public static columnMap: NotificationColumnMap = { readAt: \"read_at\" };\r\n}\r\n`;\r\n\r\nexport const notificationMigrationStub = `import { Migration } from \"@warlock.js/cascade\";\r\nimport { notificationColumns } from \"@warlock.js/notifications\";\r\nimport { Notification } from \"../notification.model\";\r\n\r\n/**\r\n * Notifications table.\r\n *\r\n * Columns come from notificationColumns(Notification) — the recipient / tenant\r\n * / read-state names follow the model's columnMap; type / title / body /\r\n * payload / idempotency_key are fixed. Spread it to add your own columns\r\n * (remember to mirror them in the model schema):\r\n *\r\n * import { uuid } from \"@warlock.js/cascade\";\r\n *\r\n * export default Migration.create(Notification, {\r\n * ...notificationColumns(Notification),\r\n * // category_id: uuid().index().nullable(),\r\n * });\r\n */\r\nexport default Migration.create(Notification, notificationColumns(Notification));\r\n`;\r\n\r\nexport const notificationControllersStub = `import { type Request, type RequestHandler, type Response } from \"@warlock.js/core\";\r\nimport { inApp } from \"@warlock.js/notifications\";\r\n\r\n/**\r\n * The authenticated user's notification HTTP surface — thin wrappers over the\r\n * recipient-scoped \\`inApp\\` facade (a foreign id can never touch another user's\r\n * rows). Notifications are produced by domain events, never over HTTP, so there\r\n * is no create. Trim or split these as your app grows.\r\n */\r\n\r\n/** GET /notifications — list, most recent first (page / limit / type / unread via query). */\r\nexport const listNotificationsController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const { data, pagination } = await inApp.list(request.user!, request.all());\r\n\r\n return response.success({ notifications: data, pagination });\r\n};\r\n\r\nlistNotificationsController.description = \"List notifications\";\r\n\r\n/** GET /notifications/unread-count — drives the bell badge. */\r\nexport const unreadNotificationsCountController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const count = await inApp.countUnread(request.user!);\r\n\r\n return response.success({ count });\r\n};\r\n\r\nunreadNotificationsCountController.description = \"Unread notifications count\";\r\n\r\n/** PATCH /notifications/:id/read — mark one read, return the updated row. */\r\nexport const markNotificationReadController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const id = request.input(\"id\");\r\n\r\n await inApp.markAsRead(request.user!, id);\r\n const notification = await inApp.find(request.user!, id);\r\n\r\n return response.success({ notification });\r\n};\r\n\r\nmarkNotificationReadController.description = \"Mark notification read\";\r\n\r\n/** PATCH /notifications/read-all — mark every unread one read. */\r\nexport const markAllNotificationsReadController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n const count = await inApp.markAsRead(request.user!);\r\n\r\n return response.success({ count });\r\n};\r\n\r\nmarkAllNotificationsReadController.description = \"Mark all notifications read\";\r\n\r\n/** DELETE /notifications — dismiss all for the user. */\r\nexport const clearNotificationsController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n await inApp.dismiss(request.user!);\r\n\r\n return response.noContent();\r\n};\r\n\r\nclearNotificationsController.description = \"Clear notifications\";\r\n\r\n/** DELETE /notifications/:id — dismiss one. */\r\nexport const deleteNotificationController: RequestHandler = async (\r\n request: Request,\r\n response: Response,\r\n) => {\r\n await inApp.dismiss(request.user!, request.input(\"id\"));\r\n\r\n return response.noContent();\r\n};\r\n\r\ndeleteNotificationController.description = \"Delete notification\";\r\n`;\r\n\r\nexport const notificationRoutesStub = `import { authMiddleware } from \"@warlock.js/auth\";\r\nimport { router } from \"@warlock.js/core\";\r\nimport {\r\n clearNotificationsController,\r\n deleteNotificationController,\r\n listNotificationsController,\r\n markAllNotificationsReadController,\r\n markNotificationReadController,\r\n unreadNotificationsCountController,\r\n} from \"./controllers/notifications.controller\";\r\n\r\n/**\r\n * Notification routes — the authenticated user's read + dismiss surface.\r\n *\r\n * Notifications are produced by domain events (never created over HTTP), so\r\n * there is no POST. Every route is gated by \\`authMiddleware\\` and recipient-\r\n * scoped by \\`inApp\\` (a foreign id touches zero rows). Delete any endpoint you\r\n * don't need; if your app reads notifications over sockets/GraphQL instead,\r\n * delete this file + the controllers entirely.\r\n */\r\nrouter.group({ prefix: \"/notifications\", middleware: [authMiddleware([])] }, () => {\r\n router.get(\"/\", listNotificationsController);\r\n router.get(\"/unread-count\", unreadNotificationsCountController);\r\n router.patch(\"/read-all\", markAllNotificationsReadController);\r\n router.patch(\"/:id/read\", markNotificationReadController);\r\n router.delete(\"/\", clearNotificationsController);\r\n router.delete(\"/:id\", deleteNotificationController);\r\n});\r\n`;\r\n\r\n/**\r\n * `src/web/root.tsx` — the application root for the SSR page layer.\r\n *\r\n * Deliberately minimal. The framework ships a default root, so this exists to\r\n * give you a place to start rather than because anything requires it. The\r\n * reference app (`v5/app/src/web/root.tsx`) is where to look for the fuller\r\n * shape: middleware, an app-level loader, locales, an ErrorBoundary.\r\n */\r\nexport const webRootStub = `import { Head, Scripts } from \"@warlock.js/web\";\r\nimport type { AppProps } from \"@warlock.js/web\";\r\n\r\n/**\r\n * The application root.\r\n *\r\n * NOT async, and it receives no request/response: it renders on the server and\r\n * again in the browser during hydration, where neither exists.\r\n */\r\nexport default function App({ children }: AppProps) {\r\n return (\r\n <html lang=\"en\">\r\n <head>\r\n {/*\r\n Placement only. The framework injects the page's \\`metadata\\`, the\r\n stylesheet and preload tags for this route, and the canonical links\r\n into <head> by default — <Head /> just says WHERE they land.\r\n\r\n Do not add a <title> here: the page's \\`metadata\\` owns it, and a root\r\n that emits one too produces two.\r\n */}\n <Head />\n <link rel=\"icon\" href=\"data:,\" />\n </head>\r\n <body>\r\n {/*\r\n REQUIRED — this is the hydration mount point, not a styling wrapper.\r\n\r\n The browser runtime looks up \\`#root\\` and hydrates that element only.\r\n Remove this div, or rename the id, and the page still renders from the\r\n server but never becomes interactive: the runtime throws in the console\r\n and nothing on screen changes.\r\n\r\n Wrap it in your own markup freely, and put anything that must live\r\n outside the hydrated tree (a static footer, a portal target) outside\r\n it — just keep an element with \\`id=\"root\"\\` around {children}.\r\n */}\r\n <div id=\"root\">{children}</div>\r\n {/*\r\n The hydration payload and module tags. Written explicitly because\r\n placement occasionally matters — a CSP nonce, or ordering against\r\n your own scripts.\r\n */}\r\n <Scripts />\r\n </body>\r\n </html>\r\n );\r\n}\r\n`;\r\n\r\n/**\r\n * `src/app/contact/controllers/contact.controller.ts` — a real API endpoint\r\n * for the Web starter's contact form. It intentionally has no persistence\r\n * dependency: replace the acknowledgement with a mail/job/database action.\r\n */\r\nexport const webContactControllerStub = `import { type Request, type RequestHandler } from \"@warlock.js/core\";\r\nimport { type Infer, v } from \"@warlock.js/seal\";\r\n\r\nexport const contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nexport type ContactSchema = Infer.Output<typeof contactSchema>;\r\n\r\n/** POST /api/contact — validates the starter contact form. */\r\nexport const contactController: RequestHandler<Request<ContactSchema>> = async ({ request, response }) => {\r\n const contact = request.validated();\r\n\r\n // Replace this with delivery/persistence for your app. Keeping the accepted\r\n // payload visible makes the endpoint useful while remaining side-effect free.\r\n return response.success({\r\n message: \"Thanks, \" + contact.name + \". Your message has been received.\",\r\n });\r\n};\r\n\r\ncontactController.validation = { schema: contactSchema };\r\n`;\r\n\r\n/** `src/app/contact/routes.ts` — discovered by the standard app route loader. */\r\nexport const webContactRoutesStub = `import { router } from \"@warlock.js/core\";\r\nimport { contactController } from \"./controllers/contact.controller\";\r\n\r\nrouter.post(\"/api/contact\", contactController);\r\n`;\r\n\r\n/**\r\n * `src/web/index.page.tsx` — one page, so \\`warlock dev\\` has something to serve\n * the moment this finishes.\r\n */\r\nexport const webHomePageStub = `import { http } from \"@mongez/http\";\r\nimport { Form, useFormControl, type FormControlProps } from \"@mongez/react-form\";\r\nimport { extend, setCurrentLocaleCode } from \"@mongez/localization\";\r\nimport { transX } from \"@mongez/react-localization\";\r\nimport { v } from \"@warlock.js/seal\";\r\nimport { useState } from \"react\";\r\nimport { Link, type PageProps } from \"@warlock.js/web\";\r\n\r\n/**\r\n * A page route is an ordinary Warlock route whose handler renders React\r\n * instead of returning JSON.\r\n *\r\n * The URL and stable hydration name are the ones this file DECLARES below.\n * This page answers \\`GET \"/\"\\` because \\`route.path = \"/\"\\`, not because of\n * where the file lives. A page file with\n * no \\`route\\` export is REFUSED by both the dev server and the build.\r\n */\r\nexport const route = { path: \"/\", name: \"index\" } as const;\n\r\nexport const metadata = { title: \"Home\" };\r\n\r\nconst contactSchema = v.object({\r\n name: v.string().min(2).required(),\r\n email: v.email().required(),\r\n message: v.string().min(10).required(),\r\n});\r\n\r\nexport function register() {\n extend(\"en\", {\n starter: {\n title: \"Your Warlock app is running.\",\n introduction: \"This page is rendered on the server and hydrated in the browser.\",\n language: \"العربية\",\n contact: \"Send a message\",\n name: \"Name\",\n email: \"Email\",\n message: \"Message\",\n submit: \"Send message\",\n sent: \"Thanks — your message has been received.\",\n },\n });\n extend(\"ar\", {\n starter: {\n title: \"تطبيق Warlock يعمل الآن.\",\n introduction: \"تُعرض هذه الصفحة على الخادم ثم تُفعَّل في المتصفح.\",\n language: \"English\",\n contact: \"أرسل رسالة\",\n name: \"الاسم\",\n email: \"البريد الإلكتروني\",\n message: \"الرسالة\",\n submit: \"إرسال الرسالة\",\n sent: \"شكرًا — تم استلام رسالتك.\",\n },\n });\n}\n\nfunction TextInput({ label, ...controlProps }: FormControlProps & { label: string }) {\n const { error, getErrorProps, getInputProps } = useFormControl(controlProps);\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor={controlProps.name}>{label}</label>\r\n <input {...getInputProps()} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n\r\n/**\r\n * Add a \\`loader\\` export to fetch data on the server, and it arrives here as\r\n * \\`data\\`, typed:\r\n *\r\n * export const loader = (async () => ({ items: await itemsRepository.all() }));\r\n * export default function HomePage({ data }: PageProps<typeof loader>) { ... }\r\n */\r\nexport default function HomePage(_props: PageProps) {\r\n // Live state. If the button below does nothing, the page rendered on the\r\n // server but never hydrated — the runtime never mounted at \\`#root\\`. This is\r\n // deliberately here so that failure is impossible to miss.\r\n const [count, setCount] = useState(0);\r\n const [locale, setLocale] = useState<\"en\" | \"ar\">(\"en\");\r\n const [submitted, setSubmitted] = useState(false);\r\n const [submitError, setSubmitError] = useState<string | null>(null);\r\n\r\n const toggleLocale = () => {\r\n const nextLocale = locale === \"en\" ? \"ar\" : \"en\";\r\n setCurrentLocaleCode(nextLocale);\r\n setLocale(nextLocale);\r\n };\r\n\r\n return (\r\n <>\r\n {/*\r\n Self-contained, dependency-free styling: plain CSS, system fonts, and\r\n CSS custom properties, scoped to this page. No CSS framework, no utility\r\n classes, no external stylesheet — this page looks the same whether or\r\n not \\`warlock add tailwind\\` has ever been run.\r\n */}\r\n <style>{\\`\r\n .wk-home {\r\n --wk-fg: #0f172a;\r\n --wk-muted: #64748b;\r\n --wk-accent: #4f46e5;\r\n --wk-border: #e2e8f0;\r\n font-family: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;\r\n color: var(--wk-fg);\r\n max-width: 42rem;\r\n margin: 4rem auto;\r\n padding: 0 1.5rem;\r\n line-height: 1.6;\r\n }\r\n .wk-home h1 { font-size: 2.25rem; margin: 0 0 0.5rem; }\r\n .wk-home p { color: var(--wk-muted); margin: 0 0 1.5rem; }\r\n .wk-home code {\r\n font-family: ui-monospace, \"SFMono-Regular\", Menlo, monospace;\r\n background: #f1f5f9;\r\n padding: 0.1rem 0.35rem;\r\n border-radius: 0.25rem;\r\n }\r\n .wk-check {\r\n border: 1px solid var(--wk-border);\r\n border-radius: 0.75rem;\r\n padding: 1.25rem 1.5rem;\r\n margin: 2rem 0;\r\n }\r\n .wk-check strong { display: block; font-size: 1.5rem; }\r\n .wk-check button {\r\n font: inherit;\r\n cursor: pointer;\r\n background: var(--wk-accent);\r\n color: #fff;\r\n border: 0;\r\n border-radius: 0.5rem;\r\n padding: 0.5rem 1rem;\r\n margin-top: 0.75rem;\r\n }\r\n .wk-links { display: flex; gap: 1.25rem; font-size: 0.95rem; }\r\n .wk-links a { color: var(--wk-accent); text-decoration: none; }\r\n .wk-links a:hover { text-decoration: underline; }\r\n .wk-language { margin-left: auto; }\r\n .wk-contact { margin-top: 2rem; }\r\n .wk-field { display: grid; gap: 0.35rem; margin: 0.8rem 0; }\r\n .wk-field input, .wk-field textarea { font: inherit; padding: 0.55rem; }\r\n .wk-field p, .wk-submit-error { color: #b91c1c; margin: 0; }\r\n .wk-success { color: #047857; }\r\n \\`}</style>\r\n\r\n <main className=\"wk-home\" dir={locale === \"ar\" ? \"rtl\" : \"ltr\"}>\r\n <nav className=\"wk-links\" aria-label=\"Starter links\">\r\n <a href=\"https://warlock.js.org\" target=\"_blank\" rel=\"noreferrer\">Docs</a>\r\n <Link href=\"/\" aria-current=\"page\">Home</Link>\r\n <button\r\n className=\"wk-language\"\r\n type=\"button\"\r\n aria-pressed={locale === \"ar\"}\r\n onClick={toggleLocale}\r\n >\r\n {transX(\"starter.language\")}\r\n </button>\r\n </nav>\r\n\r\n <h1>{transX(\"starter.title\")}</h1>\r\n <p>{transX(\"starter.introduction\")}</p>\r\n\r\n <section className=\"wk-check\">\r\n <label>If this number goes up when you click, React is hydrated:</label>\r\n <strong>{count}</strong>\r\n <button type=\"button\" onClick={() => setCount(c => c + 1)}>\r\n Count up\r\n </button>\r\n </section>\r\n\r\n <section className=\"wk-contact\" aria-labelledby=\"contact-heading\">\r\n <h2 id=\"contact-heading\">{transX(\"starter.contact\")}</h2>\r\n <Form<typeof contactSchema>\n id=\"contact-form\"\n schema={contactSchema}\n onSubmit={async ({ form, values }) => {\r\n setSubmitted(false);\r\n setSubmitError(null);\r\n const result = await http.post<{ message: string }>(\"/api/contact\", values);\r\n\r\n if (result.error) {\r\n if (result.error.isValidationError) {\r\n const body = result.error.body as {\r\n errors?: Array<{ input: string; error: string }>;\r\n message?: string;\r\n };\r\n form.setErrors(\r\n Object.fromEntries(\r\n (body.errors ?? []).map(({ input, error }) => [input, error]),\r\n ),\r\n );\r\n setSubmitError(body.message ?? \"Please correct the highlighted fields.\");\r\n } else {\r\n setSubmitError(\"Your message could not be sent. Please try again.\");\r\n }\r\n return;\r\n }\r\n\r\n setSubmitted(true);\r\n form.reset();\r\n }}\r\n >\r\n <TextInput name=\"name\" label={transX(\"starter.name\")} autoComplete=\"name\" />\r\n <TextInput name=\"email\" label={transX(\"starter.email\")} type=\"email\" autoComplete=\"email\" />\r\n <ContactMessage />\r\n <button type=\"submit\">{transX(\"starter.submit\")}</button>\r\n {submitError && <p className=\"wk-submit-error\" role=\"alert\">{submitError}</p>}\r\n {submitted && <p className=\"wk-success\" role=\"status\">{transX(\"starter.sent\")}</p>}\r\n </Form>\r\n </section>\r\n </main>\r\n </>\r\n );\r\n}\r\n\r\nfunction ContactMessage() {\r\n const { error, getErrorProps, getInputProps } = useFormControl({ name: \"message\" });\r\n\r\n return (\r\n <div className=\"wk-field\">\r\n <label htmlFor=\"message\">{transX(\"starter.message\")}</label>\r\n <textarea {...getInputProps()} rows={5} />\r\n {error && <p {...getErrorProps()}>{error}</p>}\r\n </div>\r\n );\r\n}\r\n`;\r\n"],"mappings":";AAAA,MAAa,mBAAmB;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BhC,MAAa,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+B5B,MAAa,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkCnC,MAAa,2BAA2B;;AAGxC,MAAa,0BAA0B;;;;;;;;;;;;AAavC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8FvC,MAAa,+BAA+B;;AAG5C,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;AAsB3C,MAAa,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6ClC,MAAa,mBAAmB;;;;;;;;;;;;;;;;;AAkBhC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAkFvC,MAAa,0BAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCvC,MAAa,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4CrC,MAAa,4BAA4B;;;;;;;;;;;;;;;;;;;;;AAsBzC,MAAa,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsF3C,MAAa,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAsCtC,MAAa,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuD3B,MAAa,2BAA2B;;;;;;;;;;;;;;;;;;;;;;;;;AA0BxC,MAAa,uBAAuB;;;;;;;;;AAUpC,MAAa,kBAAkB"}
@@ -66,7 +66,21 @@ function getAddCommand(packageManager) {
66
66
  default: return "npm install";
67
67
  }
68
68
  }
69
+ /**
70
+ * The package-manager command that saves an exact version. Warlock family
71
+ * dependencies use this path so the version selected by the executing Core is
72
+ * preserved verbatim in package.json instead of being widened by a manager's
73
+ * default save prefix.
74
+ */
75
+ function getExactAddCommand(packageManager) {
76
+ switch (packageManager) {
77
+ case "yarn": return "yarn add --exact";
78
+ case "pnpm": return "pnpm add --save-exact";
79
+ case "bun": return "bun add --exact";
80
+ default: return "npm install --save-exact";
81
+ }
82
+ }
69
83
 
70
84
  //#endregion
71
- export { detectPackageManager, getAddCommand, getInstallCommand };
85
+ export { detectPackageManager, getAddCommand, getExactAddCommand, getInstallCommand };
72
86
  //# sourceMappingURL=package-manager.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"package-manager.mjs","names":[],"sources":["../../../../../../../core/src/updater/package-manager.ts"],"sourcesContent":["import { fileExistsAsync } from \"@warlock.js/fs\";\nimport { rootPath } from \"../utils\";\n\n/** Package managers the framework knows how to drive. */\nexport type PackageManager = \"npm\" | \"yarn\" | \"pnpm\" | \"bun\";\n\n/**\n * The lockfile that identifies each package manager, in detection order.\n * Bun is checked first because a Bun project may also carry a `yarn.lock`\n * (Bun writes one for tooling compatibility) — matching yarn there would run\n * the wrong installer against the wrong lockfile.\n */\nconst LOCKFILES: ReadonlyArray<{ file: string; packageManager: PackageManager }> = [\n { file: \"bun.lock\", packageManager: \"bun\" },\n { file: \"bun.lockb\", packageManager: \"bun\" },\n { file: \"package-lock.json\", packageManager: \"npm\" },\n { file: \"yarn.lock\", packageManager: \"yarn\" },\n { file: \"pnpm-lock.yaml\", packageManager: \"pnpm\" },\n];\n\n/**\n * Detect the project's package manager from its lockfile, falling back to\n * npm when none is present. Shared by `warlock update` and `warlock add` so\n * both agree on a project that happens to carry more than one lockfile.\n */\nexport async function detectPackageManager(): Promise<PackageManager> {\n for (const { file, packageManager } of LOCKFILES) {\n if (await fileExistsAsync(rootPath(file))) {\n return packageManager;\n }\n }\n\n return \"npm\";\n}\n\n/**\n * The lockfile-syncing install command for the given manager. No package\n * arguments — `warlock update` rewrites the versions in package.json first,\n * then a plain install reconciles `node_modules` to match.\n */\nexport function getInstallCommand(packageManager: PackageManager): string {\n switch (packageManager) {\n case \"yarn\":\n return \"yarn install\";\n\n case \"pnpm\":\n return \"pnpm install\";\n\n case \"bun\":\n return \"bun install\";\n\n default:\n return \"npm install\";\n }\n}\n\n/**\n * The command that installs *specific* packages — what `warlock add` needs.\n * Distinct from {@link getInstallCommand}, which only reconciles what is\n * already written into package.json.\n */\nexport function getAddCommand(packageManager: PackageManager): string {\n switch (packageManager) {\n case \"yarn\":\n return \"yarn add\";\n\n case \"pnpm\":\n return \"pnpm add\";\n\n case \"bun\":\n return \"bun add\";\n\n default:\n return \"npm install\";\n }\n}\n"],"mappings":";;;;;;;;;;;AAYA,MAAM,YAA6E;CACjF;EAAE,MAAM;EAAY,gBAAgB;CAAM;CAC1C;EAAE,MAAM;EAAa,gBAAgB;CAAM;CAC3C;EAAE,MAAM;EAAqB,gBAAgB;CAAM;CACnD;EAAE,MAAM;EAAa,gBAAgB;CAAO;CAC5C;EAAE,MAAM;EAAkB,gBAAgB;CAAO;AACnD;;;;;;AAOA,eAAsB,uBAAgD;CACpE,KAAK,MAAM,EAAE,MAAM,oBAAoB,WACrC,IAAI,MAAM,gBAAgB,SAAS,IAAI,CAAC,GACtC,OAAO;CAIX,OAAO;AACT;;;;;;AAOA,SAAgB,kBAAkB,gBAAwC;CACxE,QAAQ,gBAAR;EACE,KAAK,QACH,OAAO;EAET,KAAK,QACH,OAAO;EAET,KAAK,OACH,OAAO;EAET,SACE,OAAO;CACX;AACF;;;;;;AAOA,SAAgB,cAAc,gBAAwC;CACpE,QAAQ,gBAAR;EACE,KAAK,QACH,OAAO;EAET,KAAK,QACH,OAAO;EAET,KAAK,OACH,OAAO;EAET,SACE,OAAO;CACX;AACF"}
1
+ {"version":3,"file":"package-manager.mjs","names":[],"sources":["../../../../../../../core/src/updater/package-manager.ts"],"sourcesContent":["import { fileExistsAsync } from \"@warlock.js/fs\";\nimport { rootPath } from \"../utils\";\n\n/** Package managers the framework knows how to drive. */\nexport type PackageManager = \"npm\" | \"yarn\" | \"pnpm\" | \"bun\";\n\n/**\n * The lockfile that identifies each package manager, in detection order.\n * Bun is checked first because a Bun project may also carry a `yarn.lock`\n * (Bun writes one for tooling compatibility) — matching yarn there would run\n * the wrong installer against the wrong lockfile.\n */\nconst LOCKFILES: ReadonlyArray<{ file: string; packageManager: PackageManager }> = [\n { file: \"bun.lock\", packageManager: \"bun\" },\n { file: \"bun.lockb\", packageManager: \"bun\" },\n { file: \"package-lock.json\", packageManager: \"npm\" },\n { file: \"yarn.lock\", packageManager: \"yarn\" },\n { file: \"pnpm-lock.yaml\", packageManager: \"pnpm\" },\n];\n\n/**\n * Detect the project's package manager from its lockfile, falling back to\n * npm when none is present. Shared by `warlock update` and `warlock add` so\n * both agree on a project that happens to carry more than one lockfile.\n */\nexport async function detectPackageManager(): Promise<PackageManager> {\n for (const { file, packageManager } of LOCKFILES) {\n if (await fileExistsAsync(rootPath(file))) {\n return packageManager;\n }\n }\n\n return \"npm\";\n}\n\n/**\n * The lockfile-syncing install command for the given manager. No package\n * arguments — `warlock update` rewrites the versions in package.json first,\n * then a plain install reconciles `node_modules` to match.\n */\nexport function getInstallCommand(packageManager: PackageManager): string {\n switch (packageManager) {\n case \"yarn\":\n return \"yarn install\";\n\n case \"pnpm\":\n return \"pnpm install\";\n\n case \"bun\":\n return \"bun install\";\n\n default:\n return \"npm install\";\n }\n}\n\n/**\n * The command that installs *specific* packages — what `warlock add` needs.\n * Distinct from {@link getInstallCommand}, which only reconciles what is\n * already written into package.json.\n */\nexport function getAddCommand(packageManager: PackageManager): string {\n switch (packageManager) {\n case \"yarn\":\n return \"yarn add\";\n\n case \"pnpm\":\n return \"pnpm add\";\n\n case \"bun\":\n return \"bun add\";\n\n default:\n return \"npm install\";\n }\n}\n\n/**\n * The package-manager command that saves an exact version. Warlock family\n * dependencies use this path so the version selected by the executing Core is\n * preserved verbatim in package.json instead of being widened by a manager's\n * default save prefix.\n */\nexport function getExactAddCommand(packageManager: PackageManager): string {\n switch (packageManager) {\n case \"yarn\":\n return \"yarn add --exact\";\n\n case \"pnpm\":\n return \"pnpm add --save-exact\";\n\n case \"bun\":\n return \"bun add --exact\";\n\n default:\n return \"npm install --save-exact\";\n }\n}\n"],"mappings":";;;;;;;;;;;AAYA,MAAM,YAA6E;CACjF;EAAE,MAAM;EAAY,gBAAgB;CAAM;CAC1C;EAAE,MAAM;EAAa,gBAAgB;CAAM;CAC3C;EAAE,MAAM;EAAqB,gBAAgB;CAAM;CACnD;EAAE,MAAM;EAAa,gBAAgB;CAAO;CAC5C;EAAE,MAAM;EAAkB,gBAAgB;CAAO;AACnD;;;;;;AAOA,eAAsB,uBAAgD;CACpE,KAAK,MAAM,EAAE,MAAM,oBAAoB,WACrC,IAAI,MAAM,gBAAgB,SAAS,IAAI,CAAC,GACtC,OAAO;CAIX,OAAO;AACT;;;;;;AAOA,SAAgB,kBAAkB,gBAAwC;CACxE,QAAQ,gBAAR;EACE,KAAK,QACH,OAAO;EAET,KAAK,QACH,OAAO;EAET,KAAK,OACH,OAAO;EAET,SACE,OAAO;CACX;AACF;;;;;;AAOA,SAAgB,cAAc,gBAAwC;CACpE,QAAQ,gBAAR;EACE,KAAK,QACH,OAAO;EAET,KAAK,QACH,OAAO;EAET,KAAK,OACH,OAAO;EAET,SACE,OAAO;CACX;AACF;;;;;;;AAQA,SAAgB,mBAAmB,gBAAwC;CACzE,QAAQ,gBAAR;EACE,KAAK,QACH,OAAO;EAET,KAAK,QACH,OAAO;EAET,KAAK,OACH,OAAO;EAET,SACE,OAAO;CACX;AACF"}
package/package.json CHANGED
@@ -25,13 +25,13 @@
25
25
  "@mongez/slug": "^1.0.7",
26
26
  "@mongez/supportive-is": "^2.1.4",
27
27
  "@mongez/time-wizard": "^1.0.6",
28
- "@warlock.js/auth": "5.2.2",
29
- "@warlock.js/cache": "5.2.2",
30
- "@warlock.js/cascade": "5.2.2",
31
- "@warlock.js/context": "5.2.2",
32
- "@warlock.js/logger": "5.2.2",
33
- "@warlock.js/seal": "5.2.2",
34
- "@warlock.js/fs": "5.2.2",
28
+ "@warlock.js/auth": "5.2.3",
29
+ "@warlock.js/cache": "5.2.3",
30
+ "@warlock.js/cascade": "5.2.3",
31
+ "@warlock.js/context": "5.2.3",
32
+ "@warlock.js/logger": "5.2.3",
33
+ "@warlock.js/seal": "5.2.3",
34
+ "@warlock.js/fs": "5.2.3",
35
35
  "chokidar": "^5.0.0",
36
36
  "dayjs": "^1.11.19",
37
37
  "es-module-lexer": "^2.0.0",
@@ -57,10 +57,10 @@
57
57
  "react": "^19.2.3",
58
58
  "react-dom": "^19.2.3",
59
59
  "@react-email/render": "^2.0.5",
60
- "@warlock.js/herald": "5.2.2",
61
- "@warlock.js/ai": "5.2.2",
62
- "@warlock.js/access": "5.2.2",
63
- "@warlock.js/notifications": "5.2.2"
60
+ "@warlock.js/herald": "5.2.3",
61
+ "@warlock.js/ai": "5.2.3",
62
+ "@warlock.js/access": "5.2.3",
63
+ "@warlock.js/notifications": "5.2.3"
64
64
  },
65
65
  "peerDependenciesMeta": {
66
66
  "sharp": {
@@ -123,7 +123,7 @@
123
123
  ],
124
124
  "author": "hassanzohdy",
125
125
  "license": "MIT",
126
- "version": "5.2.2",
126
+ "version": "5.2.3",
127
127
  "type": "module",
128
128
  "main": "./esm/index.mjs",
129
129
  "module": "./esm/index.mjs",