create-warlock 5.16.0 → 5.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/esm/commands/create-new-app/index.mjs +55 -50
  3. package/esm/commands/create-new-app/index.mjs.map +1 -1
  4. package/esm/commands/create-new-app/types.d.mts +1 -1
  5. package/esm/commands/create-warlock-app/index.mjs +2 -2
  6. package/esm/commands/create-warlock-app/index.mjs.map +1 -1
  7. package/esm/features/agent-kit-targets.mjs +37 -6
  8. package/esm/features/agent-kit-targets.mjs.map +1 -1
  9. package/esm/flags/assert-flag-combinations.mjs +31 -0
  10. package/esm/flags/assert-flag-combinations.mjs.map +1 -0
  11. package/esm/flags/assert-package-manager.mjs +44 -0
  12. package/esm/flags/assert-package-manager.mjs.map +1 -0
  13. package/esm/flags/definitions.mjs +7 -4
  14. package/esm/flags/definitions.mjs.map +1 -1
  15. package/esm/flags/seed-from-flags.mjs +28 -0
  16. package/esm/flags/seed-from-flags.mjs.map +1 -0
  17. package/esm/helpers/app.mjs +5 -2
  18. package/esm/helpers/app.mjs.map +1 -1
  19. package/esm/helpers/insert-import.mjs.map +1 -1
  20. package/esm/helpers/package-manager.mjs +29 -19
  21. package/esm/helpers/package-manager.mjs.map +1 -1
  22. package/esm/helpers/project-builder-helpers.mjs +1 -1
  23. package/esm/index.d.mts +1 -1
  24. package/esm/index.mjs +2 -2
  25. package/esm/index.mjs.map +1 -1
  26. package/esm/prompts/ask-agent-targets.mjs +87 -0
  27. package/esm/prompts/ask-agent-targets.mjs.map +1 -0
  28. package/esm/prompts/ask-project-name.mjs +25 -0
  29. package/esm/prompts/ask-project-name.mjs.map +1 -0
  30. package/esm/prompts/ask-stack.mjs +22 -9
  31. package/esm/prompts/ask-stack.mjs.map +1 -1
  32. package/esm/summary.mjs.map +1 -1
  33. package/llms-full.txt +1 -1
  34. package/package.json +2 -2
  35. package/skills/create-a-warlock-project/SKILL.md +1 -1
  36. package/templates/warlock/AGENTS.md +1 -0
  37. package/templates/warlock/package.json +3 -3
  38. package/templates/warlock/skills/web-code-standards/SKILL.md +266 -0
  39. package/templates/warlock/src/app/uploads/routes.ts +7 -3
  40. package/templates/warlock/src/config/cache.ts +25 -27
  41. package/templates/warlock/src/config/http.ts +17 -0
  42. package/templates/warlock/src/web/404.page.tsx +5 -3
  43. package/templates/warlock/src/web/home/index.page.tsx +10 -7
  44. package/templates/warlock/src/web/root.tsx +6 -3
  45. package/templates/warlock/src/app/uploads/controllers/fetch-uploaded-file.controller.ts +0 -33
@@ -0,0 +1,87 @@
1
+ import { BUILTIN_AGENT_KIT_TARGETS, DEFAULT_AGENT_KIT_TARGET, getValidAgentKitTargets } from "../features/agent-kit-targets.mjs";
2
+ import { cancel, isCancel, multiselect } from "@clack/prompts";
3
+
4
+ //#region ../create-warlock/src/prompts/ask-agent-targets.ts
5
+ /**
6
+ * Which `agent-kit` targets to derive per-agent docs and skills for.
7
+ *
8
+ * The valid target list is not a literal we own — it comes from the installed
9
+ * `@mongez/agent-kit`, or a cached network fetch, and
10
+ * {@link getValidAgentKitTargets} THROWS when neither is available. That is
11
+ * the right behaviour for `--agents=something-unusual`, where guessing would
12
+ * be worse than failing. It is the wrong behaviour here: this prompt sits
13
+ * five questions into the wizard, and an offline laptop must not lose four
14
+ * already-given answers to a failed HTTP request.
15
+ *
16
+ * So an unreachable list is not fatal — it narrows the menu to the built-in
17
+ * targets and SAYS SO, rather than either crashing or silently pretending
18
+ * that is the whole list.
19
+ */
20
+ async function askAgentTargets(requested) {
21
+ const { targets: known, complete } = await availableTargets();
22
+ const targets = reconcileRequested(requested, known, complete);
23
+ const answer = await multiselect({
24
+ message: complete ? "Which coding agents should this project be set up for?" : "Which coding agents should this project be set up for? (offline — showing built-in targets only)",
25
+ options: targets.map((target) => ({
26
+ value: target,
27
+ label: target
28
+ })),
29
+ initialValues: initialSelection(requested, targets),
30
+ required: false
31
+ });
32
+ if (isCancel(answer)) {
33
+ cancel("Agent selection cancelled");
34
+ process.exit(0);
35
+ }
36
+ const selected = answer;
37
+ return selected.length > 0 ? selected : [DEFAULT_AGENT_KIT_TARGET];
38
+ }
39
+ /**
40
+ * The target list, plus whether it is the REAL one. `complete: false` means
41
+ * the lookup failed and these are only the offline built-ins.
42
+ */
43
+ async function availableTargets() {
44
+ try {
45
+ return {
46
+ targets: await getValidAgentKitTargets(),
47
+ complete: true
48
+ };
49
+ } catch {
50
+ return {
51
+ targets: [...BUILTIN_AGENT_KIT_TARGETS],
52
+ complete: false
53
+ };
54
+ }
55
+ }
56
+ /**
57
+ * What `--agents` asked for has to end up among the OPTIONS, or it would be
58
+ * pre-ticked invisibly and then silently dropped — the same defect this
59
+ * wizard's other flags already had.
60
+ *
61
+ * With a complete list, an unrecognised target is refused, naming the valid
62
+ * ones, exactly as `resolveAgentTargets` does for the non-interactive path.
63
+ * With an incomplete one, we have no authority to call anything invalid, so
64
+ * the requested targets are offered alongside the built-ins instead of being
65
+ * judged.
66
+ */
67
+ function reconcileRequested(requested, known, complete) {
68
+ if (!requested || requested.length === 0) return known;
69
+ const unknown = requested.filter((target) => !known.includes(target));
70
+ if (unknown.length === 0) return known;
71
+ if (complete) {
72
+ cancel(`Unknown agent-kit target(s): ${unknown.join(", ")} — valid targets: ${known.slice().sort().join(", ")}`);
73
+ process.exit(1);
74
+ }
75
+ return [...known, ...unknown];
76
+ }
77
+ /**
78
+ * Pre-tick what `--agents` asked for. Everything it names is among the
79
+ * options by now, so nothing is filtered away here.
80
+ */
81
+ function initialSelection(requested, targets) {
82
+ return (requested && requested.length > 0 ? requested : [DEFAULT_AGENT_KIT_TARGET]).filter((target) => targets.includes(target));
83
+ }
84
+
85
+ //#endregion
86
+ export { askAgentTargets };
87
+ //# sourceMappingURL=ask-agent-targets.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ask-agent-targets.mjs","names":[],"sources":["../../../../../../create-warlock/src/prompts/ask-agent-targets.ts"],"sourcesContent":["import { cancel, isCancel, multiselect } from \"@clack/prompts\";\nimport {\n BUILTIN_AGENT_KIT_TARGETS,\n DEFAULT_AGENT_KIT_TARGET,\n getValidAgentKitTargets,\n} from \"../features/agent-kit-targets\";\n\n/**\n * Which `agent-kit` targets to derive per-agent docs and skills for.\n *\n * The valid target list is not a literal we own — it comes from the installed\n * `@mongez/agent-kit`, or a cached network fetch, and\n * {@link getValidAgentKitTargets} THROWS when neither is available. That is\n * the right behaviour for `--agents=something-unusual`, where guessing would\n * be worse than failing. It is the wrong behaviour here: this prompt sits\n * five questions into the wizard, and an offline laptop must not lose four\n * already-given answers to a failed HTTP request.\n *\n * So an unreachable list is not fatal — it narrows the menu to the built-in\n * targets and SAYS SO, rather than either crashing or silently pretending\n * that is the whole list.\n */\nexport async function askAgentTargets(\n requested: string[] | undefined,\n): Promise<string[]> {\n const { targets: known, complete } = await availableTargets();\n const targets = reconcileRequested(requested, known, complete);\n\n const answer = await multiselect({\n message: complete\n ? \"Which coding agents should this project be set up for?\"\n : \"Which coding agents should this project be set up for? (offline — showing built-in targets only)\",\n options: targets.map(target => ({ value: target, label: target })),\n initialValues: initialSelection(requested, targets),\n required: false,\n });\n\n if (isCancel(answer)) {\n cancel(\"Agent selection cancelled\");\n process.exit(0);\n }\n\n const selected = answer as string[];\n\n return selected.length > 0 ? selected : [DEFAULT_AGENT_KIT_TARGET];\n}\n\n/**\n * The target list, plus whether it is the REAL one. `complete: false` means\n * the lookup failed and these are only the offline built-ins.\n */\nasync function availableTargets(): Promise<{\n targets: string[];\n complete: boolean;\n}> {\n try {\n return { targets: await getValidAgentKitTargets(), complete: true };\n } catch {\n return { targets: [...BUILTIN_AGENT_KIT_TARGETS], complete: false };\n }\n}\n\n/**\n * What `--agents` asked for has to end up among the OPTIONS, or it would be\n * pre-ticked invisibly and then silently dropped — the same defect this\n * wizard's other flags already had.\n *\n * With a complete list, an unrecognised target is refused, naming the valid\n * ones, exactly as `resolveAgentTargets` does for the non-interactive path.\n * With an incomplete one, we have no authority to call anything invalid, so\n * the requested targets are offered alongside the built-ins instead of being\n * judged.\n */\nfunction reconcileRequested(\n requested: string[] | undefined,\n known: string[],\n complete: boolean,\n): string[] {\n if (!requested || requested.length === 0) return known;\n\n const unknown = requested.filter(target => !known.includes(target));\n\n if (unknown.length === 0) return known;\n\n if (complete) {\n cancel(\n `Unknown agent-kit target(s): ${unknown.join(\", \")} — valid targets: ${known\n .slice()\n .sort()\n .join(\", \")}`,\n );\n process.exit(1);\n }\n\n return [...known, ...unknown];\n}\n\n/**\n * Pre-tick what `--agents` asked for. Everything it names is among the\n * options by now, so nothing is filtered away here.\n */\nfunction initialSelection(\n requested: string[] | undefined,\n targets: string[],\n): string[] {\n const wanted =\n requested && requested.length > 0 ? requested : [DEFAULT_AGENT_KIT_TARGET];\n\n return wanted.filter(target => targets.includes(target));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAsBA,eAAsB,gBACpB,WACmB;CACnB,MAAM,EAAE,SAAS,OAAO,aAAa,MAAM,iBAAiB;CAC5D,MAAM,UAAU,mBAAmB,WAAW,OAAO,QAAQ;CAE7D,MAAM,SAAS,MAAM,YAAY;EAC/B,SAAS,WACL,2DACA;EACJ,SAAS,QAAQ,KAAI,YAAW;GAAE,OAAO;GAAQ,OAAO;EAAO,EAAE;EACjE,eAAe,iBAAiB,WAAW,OAAO;EAClD,UAAU;CACZ,CAAC;CAED,IAAI,SAAS,MAAM,GAAG;EACpB,OAAO,2BAA2B;EAClC,QAAQ,KAAK,CAAC;CAChB;CAEA,MAAM,WAAW;CAEjB,OAAO,SAAS,SAAS,IAAI,WAAW,CAAC,wBAAwB;AACnE;;;;;AAMA,eAAe,mBAGZ;CACD,IAAI;EACF,OAAO;GAAE,SAAS,MAAM,wBAAwB;GAAG,UAAU;EAAK;CACpE,QAAQ;EACN,OAAO;GAAE,SAAS,CAAC,GAAG,yBAAyB;GAAG,UAAU;EAAM;CACpE;AACF;;;;;;;;;;;;AAaA,SAAS,mBACP,WACA,OACA,UACU;CACV,IAAI,CAAC,aAAa,UAAU,WAAW,GAAG,OAAO;CAEjD,MAAM,UAAU,UAAU,QAAO,WAAU,CAAC,MAAM,SAAS,MAAM,CAAC;CAElE,IAAI,QAAQ,WAAW,GAAG,OAAO;CAEjC,IAAI,UAAU;EACZ,OACE,gCAAgC,QAAQ,KAAK,IAAI,EAAE,oBAAoB,MACpE,MAAM,CAAC,CACP,KAAK,CAAC,CACN,KAAK,IAAI,GACd;EACA,QAAQ,KAAK,CAAC;CAChB;CAEA,OAAO,CAAC,GAAG,OAAO,GAAG,OAAO;AAC9B;;;;;AAMA,SAAS,iBACP,WACA,SACU;CAIV,QAFE,aAAa,UAAU,SAAS,IAAI,YAAY,CAAC,wBAAwB,EAE9D,CAAC,QAAO,WAAU,QAAQ,SAAS,MAAM,CAAC;AACzD"}
@@ -0,0 +1,25 @@
1
+ import { cancel, isCancel, text } from "@clack/prompts";
2
+
3
+ //#region ../create-warlock/src/prompts/ask-project-name.ts
4
+ /**
5
+ * The project name is the one answer with no sane default, so every
6
+ * interactive path has to ask it. It lives here so the default path and the
7
+ * full wizard ask it with the SAME wording and the same cancel handling —
8
+ * and so a name already collected by one path can simply be handed to the
9
+ * other instead of being asked a second time.
10
+ */
11
+ async function askProjectName() {
12
+ const answer = await text({
13
+ message: "What shall we call your project?",
14
+ placeholder: "my-warlock-app"
15
+ });
16
+ if (isCancel(answer) || !answer.trim()) {
17
+ cancel("A project name is required to continue");
18
+ process.exit(0);
19
+ }
20
+ return answer;
21
+ }
22
+
23
+ //#endregion
24
+ export { askProjectName };
25
+ //# sourceMappingURL=ask-project-name.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ask-project-name.mjs","names":[],"sources":["../../../../../../create-warlock/src/prompts/ask-project-name.ts"],"sourcesContent":["import { cancel, isCancel, text } from \"@clack/prompts\";\n\n/**\n * The project name is the one answer with no sane default, so every\n * interactive path has to ask it. It lives here so the default path and the\n * full wizard ask it with the SAME wording and the same cancel handling —\n * and so a name already collected by one path can simply be handed to the\n * other instead of being asked a second time.\n */\nexport async function askProjectName(): Promise<string> {\n const answer = await text({\n message: \"What shall we call your project?\",\n placeholder: \"my-warlock-app\",\n });\n\n if (isCancel(answer) || !answer.trim()) {\n cancel(\"A project name is required to continue\");\n process.exit(0);\n }\n\n return answer;\n}\n"],"mappings":";;;;;;;;;;AASA,eAAsB,iBAAkC;CACtD,MAAM,SAAS,MAAM,KAAK;EACxB,SAAS;EACT,aAAa;CACf,CAAC;CAED,IAAI,SAAS,MAAM,KAAK,CAAC,OAAO,KAAK,GAAG;EACtC,OAAO,wCAAwC;EAC/C,QAAQ,KAAK,CAAC;CAChB;CAEA,OAAO;AACT"}
@@ -8,19 +8,32 @@ import { cancel, isCancel, select } from "@clack/prompts";
8
8
  * of the generated project — every other choice (database, features,
9
9
  * package manager, agent-kit targets, …) is reversible within minutes and
10
10
  * ships as a flag with a default instead.
11
+ *
12
+ * The third entry is not a third stack: `customize` selects the FLOW, handing
13
+ * the run to the full wizard (`--customize`) so every one of those reversible
14
+ * choices gets asked instead of defaulted. It sits in this menu because a flag
15
+ * nobody is told about is a feature nobody has.
11
16
  */
12
17
  async function askStack() {
13
18
  const answer = await select({
14
19
  message: "What are we building?",
15
- options: [{
16
- value: "api",
17
- label: "API only",
18
- hint: "A REST/GraphQL API, no server-rendered pages"
19
- }, {
20
- value: "web",
21
- label: "Full-stack web",
22
- hint: "API + server-rendered React pages (SSR)"
23
- }],
20
+ options: [
21
+ {
22
+ value: "api",
23
+ label: "API only",
24
+ hint: "A REST API, no server-rendered pages"
25
+ },
26
+ {
27
+ value: "web",
28
+ label: "Full-stack web",
29
+ hint: "API + server-rendered React pages (SSR)"
30
+ },
31
+ {
32
+ value: "customize",
33
+ label: "Customize",
34
+ hint: "Pick package manager, database, features, AI providers and coding agents one by one"
35
+ }
36
+ ],
24
37
  initialValue: "api"
25
38
  });
26
39
  if (isCancel(answer)) {
@@ -1 +1 @@
1
- {"version":3,"file":"ask-stack.mjs","names":[],"sources":["../../../../../../create-warlock/src/prompts/ask-stack.ts"],"sourcesContent":["import { cancel, isCancel, select } from \"@clack/prompts\";\nimport type { Stack } from \"../commands/create-new-app/types\";\n\n/**\n * The ONE structural question the default (non-`--interactive`) interactive\n * path is allowed to ask: does the generated app need server-rendered web\n * pages, or is it an API only? This is the sole fork that changes the shape\n * of the generated project — every other choice (database, features,\n * package manager, agent-kit targets, …) is reversible within minutes and\n * ships as a flag with a default instead.\n */\nexport async function askStack(): Promise<Stack> {\n const answer = await select({\n message: \"What are we building?\",\n options: [\n {\n value: \"api\",\n label: \"API only\",\n hint: \"A REST/GraphQL API, no server-rendered pages\",\n },\n {\n value: \"web\",\n label: \"Full-stack web\",\n hint: \"API + server-rendered React pages (SSR)\",\n },\n ],\n initialValue: \"api\",\n });\n\n if (isCancel(answer)) {\n cancel(\"Setup cancelled\");\n process.exit(0);\n }\n\n return answer as Stack;\n}\n"],"mappings":";;;;;;;;;;;AAWA,eAAsB,WAA2B;CAC/C,MAAM,SAAS,MAAM,OAAO;EAC1B,SAAS;EACT,SAAS,CACP;GACE,OAAO;GACP,OAAO;GACP,MAAM;EACR,GACA;GACE,OAAO;GACP,OAAO;GACP,MAAM;EACR,CACF;EACA,cAAc;CAChB,CAAC;CAED,IAAI,SAAS,MAAM,GAAG;EACpB,OAAO,iBAAiB;EACxB,QAAQ,KAAK,CAAC;CAChB;CAEA,OAAO;AACT"}
1
+ {"version":3,"file":"ask-stack.mjs","names":[],"sources":["../../../../../../create-warlock/src/prompts/ask-stack.ts"],"sourcesContent":["import { cancel, isCancel, select } from \"@clack/prompts\";\nimport type { SetupChoice } from \"../commands/create-new-app/types\";\n\n/**\n * The ONE structural question the default (non-`--interactive`) interactive\n * path is allowed to ask: does the generated app need server-rendered web\n * pages, or is it an API only? This is the sole fork that changes the shape\n * of the generated project — every other choice (database, features,\n * package manager, agent-kit targets, …) is reversible within minutes and\n * ships as a flag with a default instead.\n *\n * The third entry is not a third stack: `customize` selects the FLOW, handing\n * the run to the full wizard (`--customize`) so every one of those reversible\n * choices gets asked instead of defaulted. It sits in this menu because a flag\n * nobody is told about is a feature nobody has.\n */\nexport async function askStack(): Promise<SetupChoice> {\n const answer = await select({\n message: \"What are we building?\",\n options: [\n {\n value: \"api\",\n label: \"API only\",\n hint: \"A REST API, no server-rendered pages\",\n },\n {\n value: \"web\",\n label: \"Full-stack web\",\n hint: \"API + server-rendered React pages (SSR)\",\n },\n {\n value: \"customize\",\n label: \"Customize\",\n hint: \"Pick package manager, database, features, AI providers and coding agents one by one\",\n },\n ],\n initialValue: \"api\",\n });\n\n if (isCancel(answer)) {\n cancel(\"Setup cancelled\");\n process.exit(0);\n }\n\n return answer as SetupChoice;\n}\n"],"mappings":";;;;;;;;;;;;;;;;AAgBA,eAAsB,WAAiC;CACrD,MAAM,SAAS,MAAM,OAAO;EAC1B,SAAS;EACT,SAAS;GACP;IACE,OAAO;IACP,OAAO;IACP,MAAM;GACR;GACA;IACE,OAAO;IACP,OAAO;IACP,MAAM;GACR;GACA;IACE,OAAO;IACP,OAAO;IACP,MAAM;GACR;EACF;EACA,cAAc;CAChB,CAAC;CAED,IAAI,SAAS,MAAM,GAAG;EACpB,OAAO,iBAAiB;EACxB,QAAQ,KAAK,CAAC;CAChB;CAEA,OAAO;AACT"}
@@ -1 +1 @@
1
- {"version":3,"file":"summary.mjs","names":[],"sources":["../../../../../create-warlock/src/summary.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport { getDatabaseLabel } from \"./features/database-drivers\";\nimport { AppOptions } from \"./commands/create-new-app/types\";\n\n/** What decisions.spec.ts / the terminal actually needs to render the summary. */\nexport type Decisions = {\n packageManager: string;\n options: AppOptions;\n};\n\n/**\n * Print what the scaffolder decided on the user's behalf, and how to change\n * each decision — required because almost every choice is now a default\n * rather than a prompt, so this is the only place a user sees them.\n */\nexport function printDecisionsSummary({ packageManager, options }: Decisions): void {\n const databaseLabel = getDatabaseLabel(options.databaseDriver);\n\n const headline = [\n `Using ${packageManager}`,\n `database: ${databaseLabel}`,\n `agent-kit targets: ${options.agents.join(\", \")}`,\n ].join(\" · \");\n\n console.log();\n console.log(colors.cyan(headline));\n console.log(\n colors.dim(\n \" change these — package manager: --pm, database: --db/--no-db, agents: --agents, \" +\n \"features: --features, ai: --ai, git: --git/--no-git, jwt: --jwt/--no-jwt — \" +\n \"or re-run with --interactive for the full wizard. Package-manager-level choices \" +\n \"(husky, lint config, etc.) can also be edited directly in the generated project.\",\n ),\n );\n console.log();\n}\n"],"mappings":";;;;;;;;;AAeA,SAAgB,sBAAsB,EAAE,gBAAgB,WAA4B;CAClF,MAAM,gBAAgB,iBAAiB,QAAQ,cAAc;CAE7D,MAAM,WAAW;EACf,SAAS;EACT,aAAa;EACb,sBAAsB,QAAQ,OAAO,KAAK,IAAI;CAChD,CAAC,CAAC,KAAK,KAAK;CAEZ,QAAQ,IAAI;CACZ,QAAQ,IAAI,OAAO,KAAK,QAAQ,CAAC;CACjC,QAAQ,IACN,OAAO,IACL,+TAIF,CACF;CACA,QAAQ,IAAI;AACd"}
1
+ {"version":3,"file":"summary.mjs","names":[],"sources":["../../../../../create-warlock/src/summary.ts"],"sourcesContent":["import { colors } from \"@mongez/copper\";\nimport { AppOptions } from \"./commands/create-new-app/types\";\nimport { getDatabaseLabel } from \"./features/database-drivers\";\n\n/** What decisions.spec.ts / the terminal actually needs to render the summary. */\nexport type Decisions = {\n packageManager: string;\n options: AppOptions;\n};\n\n/**\n * Print what the scaffolder decided on the user's behalf, and how to change\n * each decision — required because almost every choice is now a default\n * rather than a prompt, so this is the only place a user sees them.\n */\nexport function printDecisionsSummary({\n packageManager,\n options,\n}: Decisions): void {\n const databaseLabel = getDatabaseLabel(options.databaseDriver);\n\n const headline = [\n `Using ${packageManager}`,\n `database: ${databaseLabel}`,\n `agent-kit targets: ${options.agents.join(\", \")}`,\n ].join(\" · \");\n\n console.log();\n console.log(colors.cyan(headline));\n console.log(\n colors.dim(\n \" change these — package manager: --pm, database: --db/--no-db, agents: --agents, \" +\n \"features: --features, ai: --ai, git: --git/--no-git, jwt: --jwt/--no-jwt — \" +\n \"or re-run with --interactive for the full wizard. Package-manager-level choices \" +\n \"(husky, lint config, etc.) can also be edited directly in the generated project.\",\n ),\n );\n console.log();\n}\n"],"mappings":";;;;;;;;;AAeA,SAAgB,sBAAsB,EACpC,gBACA,WACkB;CAClB,MAAM,gBAAgB,iBAAiB,QAAQ,cAAc;CAE7D,MAAM,WAAW;EACf,SAAS;EACT,aAAa;EACb,sBAAsB,QAAQ,OAAO,KAAK,IAAI;CAChD,CAAC,CAAC,KAAK,KAAK;CAEZ,QAAQ,IAAI;CACZ,QAAQ,IAAI,OAAO,KAAK,QAAQ,CAAC;CACjC,QAAQ,IACN,OAAO,IACL,+TAIF,CACF;CACA,QAAQ,IAAI;AACd"}
package/llms-full.txt CHANGED
@@ -136,7 +136,7 @@ my-app/
136
136
  ├─ auth/ # controllers, schema, services, requests, utils, OTP model + migration
137
137
  ├─ users/ # model, migration, resource, repository, create + list controllers, schema, events, seeds, commands
138
138
  ├─ posts/ # sample module — model, migration, resource, schema, create + update controllers
139
- ├─ uploads/ # file-serving controller (fetch-uploaded-file)
139
+ ├─ uploads/ # routes.ts mounts core's uploadedFileController; named image variants go in src/config/uploads.ts
140
140
  └─ shared/ # router/locale utils, home page, scheduler
141
141
  ```
142
142
 
package/package.json CHANGED
@@ -12,7 +12,7 @@
12
12
  "dependencies": {
13
13
  "@clack/prompts": "^0.7.0",
14
14
  "@mongez/copper": "^2.1.2",
15
- "@warlock.js/fs": "5.16.0",
15
+ "@warlock.js/fs": "5.17.0",
16
16
  "@mongez/reinforcements": "^4.0.1",
17
17
  "cross-spawn": "^7.0.3",
18
18
  "rimraf": "^6.0.1",
@@ -21,7 +21,7 @@
21
21
  "engines": {
22
22
  "node": ">=22.13"
23
23
  },
24
- "version": "5.16.0",
24
+ "version": "5.17.0",
25
25
  "type": "module",
26
26
  "main": "./esm/index.mjs",
27
27
  "module": "./esm/index.mjs",
@@ -128,7 +128,7 @@ my-app/
128
128
  ├─ auth/ # controllers, schema, services, requests, utils, OTP model + migration
129
129
  ├─ users/ # model, migration, resource, repository, create + list controllers, schema, events, seeds, commands
130
130
  ├─ posts/ # sample module — model, migration, resource, schema, create + update controllers
131
- ├─ uploads/ # file-serving controller (fetch-uploaded-file)
131
+ ├─ uploads/ # routes.ts mounts core's uploadedFileController; named image variants go in src/config/uploads.ts
132
132
  └─ shared/ # router/locale utils, home page, scheduler
133
133
  ```
134
134
 
@@ -59,6 +59,7 @@ automatically on `postinstall`).
59
59
  - **code-standards** — TypeScript style: `interface` (contracts) vs `type` (data),
60
60
  access modifiers on every class member, no `any`, single-responsibility files
61
61
  with the documented naming suffixes, JSDoc on the public surface.
62
+ - **web-code-standards** — Thin web pages, service-owned data, feature hooks, and browser-safe rendering.
62
63
  - **data-and-persistence** — modeling, storing, and migrating data with
63
64
  `@warlock.js/cascade` (money as integer minor units, migrations, indexes).
64
65
  - **module-boundaries** — how modules under `src/app/**` relate; one domain noun
@@ -5,7 +5,7 @@
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "dev": "warlock dev",
8
- "build": "tsc && warlock build",
8
+ "build": "warlock generate.typings && tsc && warlock build",
9
9
  "start": "warlock start",
10
10
  "seed": "warlock seed",
11
11
  "migrate": "warlock migrate",
@@ -24,7 +24,7 @@
24
24
  "serve": "yarn build && nohup warlock start > /dev/null 2>&1",
25
25
  "lint": "npx eslint --fix ./src --max-warnings=0",
26
26
  "format": "npx prettier --write ./src/**/*.{js,jsx,ts,tsx,css,md,json} --config ./.prettierrc.json",
27
- "tsc": "npx tsc --noEmit",
27
+ "tsc": "warlock generate.typings && npx tsc --noEmit",
28
28
  "postinstall": "agent-kit init && agent-kit sync || echo skills-sync-skipped",
29
29
  "skills:sync": "agent-kit sync",
30
30
  "skills:watch": "agent-kit watch",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@mongez/reinforcements": "^4.0.1",
35
- "@mongez/localization": "^3.4.7",
35
+ "@mongez/localization": "^3.5.0",
36
36
  "@mongez/supportive-is": "^2.1.4",
37
37
  "@warlock.js/auth": "0.0.0-set-by-scaffolder",
38
38
  "@warlock.js/cache": "0.0.0-set-by-scaffolder",
@@ -0,0 +1,266 @@
1
+ ---
2
+ name: web-code-standards
3
+ description: >-
4
+ Strict separation-of-concerns standards for Warlock.js src/web pages and
5
+ components: loaders delegate data and business rules to app services;
6
+ components delegate all behavior to separate custom-hook files.
7
+ Use alongside code-standards for web work; skip for backend-only changes.
8
+ ---
9
+
10
+ # Web code standards
11
+
12
+ **Applies to:** `src/web/**` in this project. Read
13
+ [`code-standards`](../code-standards/SKILL.md) for the shared TypeScript rules,
14
+ and the relevant `@warlock.js/web` skill before using a framework feature.
15
+ This skill adds the web-specific rules; it does not override security or
16
+ module-boundary requirements. When reviewing existing code, identify debt
17
+ without expanding a requested edit into a wholesale refactor.
18
+
19
+ The bar is a page whose data flow and ownership a new teammate can explain
20
+ without tracing a large component, several implicit casts, and unrelated
21
+ network handlers. Good UI polish is not a substitute for that clarity.
22
+
23
+ ## Feature directories
24
+
25
+ Keep files beside the web feature that owns them. For example, `src/web/posts/`
26
+ can own an archive, article route, reader controls, comments, and their hooks
27
+ and copy. `src/web/auth/` and `account/` own their flows; `src/web/admin/`
28
+ can own a guarded dashboard and its domain features. Root document, root
29
+ layout, error pages, home page, and shared site chrome stay at their common
30
+ ancestor.
31
+
32
+ When moving a page, check whether its URL is filesystem-derived. If it is,
33
+ moving the file changes the URL unless you first give `config` an explicit
34
+ `route`. Preserve any explicit `config.route.path` and `config.route.name`,
35
+ and keep protected pages under the layout that owns their server auth guard.
36
+ Place `locales.json` dictionaries with the feature they describe; site copy
37
+ belongs at the common physical source ancestor and admin-only copy belongs
38
+ under the admin layout. Warlock discovers these dictionaries automatically
39
+ and supplies the selected locale through request translation helpers and
40
+ `useTrans()`. An optional nonempty `$group` replaces the inferred static
41
+ directory namespace. Every flattened translation key must have one owner;
42
+ ancestor and child dictionaries cannot redefine the same key. Keep legacy
43
+ `register()` dictionaries only where global translations are still needed.
44
+
45
+ ## 1. Keep the page contract, make the implementation thin
46
+
47
+ Warlock's file-routed modules have one policy export: `config`. A page's
48
+ `route`, `cache`, `middleware`, `validation`, `metadata`, and `sitemap` all
49
+ belong in that object; do not use legacy top-level policy exports. `loader`,
50
+ `register`, `ErrorBoundary`, and the default component remain top-level
51
+ exports. Layouts, registration files, `root.tsx`, `404.page.tsx`, and
52
+ `error.page.tsx` also have framework-defined roles. These are intentional
53
+ exceptions to the generic "one primary export per file" rule; do not split
54
+ them merely to satisfy it.
55
+
56
+ Use the config type that matches the module. A page can preserve loader data
57
+ inference for metadata by satisfying `PageConfig<typeof loader>`:
58
+
59
+ ```tsx
60
+ import type { PageConfig } from "@warlock.js/web";
61
+
62
+ export async function loader() {
63
+ return { title: "Posts" };
64
+ }
65
+
66
+ export const config = {
67
+ route: { path: "/posts", name: "posts.index" },
68
+ middleware: [requireUser],
69
+ validation: { query: postsQuery },
70
+ metadata: ({ data }) => ({ title: data.title }),
71
+ sitemap: { changefreq: "daily" },
72
+ } satisfies PageConfig<typeof loader>;
73
+
74
+ export default function PostsPage() {
75
+ return <main>Posts</main>;
76
+ }
77
+ ```
78
+
79
+ When code navigates by a page name, `href(name, params, query)` validates the
80
+ published route table at runtime. `href("posts.indxe")` is a string that
81
+ TypeScript accepts, then the helper throws with the known route names. Generated
82
+ route-name and parameter types are not available in 5.17.
83
+
84
+ Layouts use one `config satisfies LayoutConfig` object for `prefix`,
85
+ `middleware`, `metadata`, and a sitemap default. Roots use one
86
+ `config satisfies RootConfig` object for `middleware` and the static `strictMode`
87
+ flag: root modules do
88
+ not declare routes, prefixes, metadata, validation, cache, or sitemap policy.
89
+
90
+ ```tsx
91
+ import type { LayoutConfig, RootConfig } from "@warlock.js/web";
92
+
93
+ // src/web/account/layout.tsx
94
+ export const config = {
95
+ prefix: "/account",
96
+ middleware: [requireUser],
97
+ metadata: { robots: "noindex" },
98
+ sitemap: false,
99
+ } satisfies LayoutConfig;
100
+
101
+ // src/web/root.tsx
102
+ export const config = {
103
+ middleware: [loadRequestContext],
104
+ } satisfies RootConfig;
105
+ ```
106
+
107
+ A page file declares its route contract, coordinates a controller-like loader,
108
+ and composes presentational components. The loader validates input, calls
109
+ named services under `src/app/<domain>/services/`, converts results through
110
+ their resources, and returns the page data. **Every model/repository read and
111
+ every business rule belongs in an app service, even when the loader would need
112
+ only one query.** Do not import models or repositories into page or layout
113
+ loaders. Keep service orchestration in the loader; keep business decisions,
114
+ queries, aggregations, and domain transformations in the service. A service
115
+ must have a meaningful domain name and own the operation, not exist as a
116
+ generic pass-through for a repository method.
117
+
118
+ Extract substantive view sections into cohesive components. A component
119
+ that needs any behavior gets a separate feature-named custom-hook file; a
120
+ component with no behavior stays presentational and needs no hook. Split by
121
+ responsibility, not by a mechanical line-count limit. Framework exports
122
+ remain in the page file even when their work is delegated elsewhere.
123
+
124
+ Preserve the framework's rendering boundary: `root.tsx` owns the document,
125
+ `#vessel` and `<Scripts />`; the loader runs on the server, while the rendered
126
+ component can run on the server **and** the client. Do not move request,
127
+ database, or secret access into a component or hook. If page-only exports
128
+ need server dependencies, use them only in server-side exports and follow the
129
+ web skill's import-graph rules. A new auth gate belongs in server middleware,
130
+ not a hidden link or a client-side redirect.
131
+
132
+ **Example split:** a post page's loader passes validated input to
133
+ `app/posts/services/get-public-post.service.ts` and returns its resource
134
+ output; the page composes `PostArticle`, `PostComments`, and `RelatedPosts`;
135
+ `use-post-comments.ts` owns subscription, state, and handlers for
136
+ `PostComments`. Each piece has one reason to change.
137
+
138
+ ## 2. Make the server-to-browser data contract explicit
139
+
140
+ - Validate URL parameters and query input at the page boundary. Use the
141
+ framework's validated request rather than `request.input()` casts where
142
+ validation is available. A `page` parameter must affect a real bounded
143
+ query; accepting and returning it without paging is misleading.
144
+ - Have the app service query only what the view needs. Filter, sort, limit,
145
+ and paginate in the data source **before** serialization or rendering. Use
146
+ a count/aggregate query for totals instead of loading a table to count it.
147
+ Batch related reads; do not do one author lookup or image transformation
148
+ per row on an archive page. Cache does not make an unbounded cold path safe.
149
+ - Treat resources as the wire-format gate. Send the resource's deliberate
150
+ public shape, never a raw model, secret field, or request object to the
151
+ browser. Do not scatter `toJSON() as unknown as SomeRow` through pages:
152
+ fix or explicitly validate the resource contract at a single boundary so
153
+ refactors cannot silently invalidate loader data.
154
+ - Publicly cached pages must not contain viewer-specific state. Keep optional
155
+ identity in an appropriate request-scoped boundary and review cache keys,
156
+ invalidation, and private-page behaviour when the rendered data changes.
157
+ - A deferred key needs a meaningful loading state and an error boundary at
158
+ the consuming section. Keep essential page data non-deferred. Do not wrap
159
+ already-resolved markup in more Suspense boundaries merely to appear
160
+ optimized; measure a real hydration or streaming benefit first.
161
+ - Metadata, canonical links, and sitemap entries should derive from the
162
+ same validated, public data as the page. Keep private routes out of public
163
+ discovery, and do not generate database-backed assets on every GET.
164
+
165
+ ## 3. Components render; custom hooks own all behavior
166
+
167
+ **No behavior in React component bodies, even when it is small.** A
168
+ component may declare props, call its dedicated custom hook if needed,
169
+ and render JSX from its props and the hook's returned values. Put `useState`,
170
+ `useEffect`, subscriptions, timers, browser API calls, derived-value
171
+ calculations, event handlers, mutations, and navigation decisions in a
172
+ separate custom-hook file. Keep pure domain calculations in `src/app`
173
+ services; a hook may call a small pure UI helper outside the component.
174
+ Do not put a one-line callback or inline computation in JSX because it
175
+ "doesn't warrant a hook". Pure presentational components need no hook.
176
+ JSX conditionals that only select which markup to show are presentation,
177
+ not a license to implement business rules in render.
178
+
179
+ Render must be deterministic for the same props and request data. Read
180
+ `window`, `document`, `localStorage`, browser clocks, and capabilities in
181
+ hook handlers or effects, not during server rendering or initial hydration.
182
+ Use request-provided data for the first client frame; do not silently render
183
+ another value and hope hydration reconciles it.
184
+
185
+ Identify the source of truth for each value. Loader data is server truth;
186
+ hook state is for an interaction or an intentional optimistic change.
187
+ Avoid copying props into hook state without a plan for refresh and client
188
+ navigation. Avoid setting another state variable inside a state-updater
189
+ callback. Prefer deriving display values from one authoritative state.
190
+
191
+ Effects own their subscriptions and timers: unsubscribe, cancel, or ignore
192
+ stale async work on unmount or entity change. A failed script/socket load
193
+ must not leave a permanently rejected shared promise or silently disable a
194
+ feature for the rest of the session. Realtime events can arrive twice or
195
+ out of order; dedupe by stable identity and reconcile with refreshed data.
196
+
197
+ ## 4. Keep mutations and forms cohesive
198
+
199
+ Client mutations belong in the feature's custom hook, **never inside the
200
+ component or its JSX callback**. The hook owns the path from input to API
201
+ request to success/error state to refresh or navigation. App services own
202
+ the operation's business rules; the browser must not reimplement them.
203
+ Never assume an HTTP promise cannot reject. Do not swallow a failed mutation
204
+ or network subscription; show actionable UI feedback and log at the
205
+ appropriate boundary without leaking user data.
206
+
207
+ Share a create/edit form's fields, request encoding, and validation-error
208
+ mapping when they are the same contract. Keep differences explicit (such as
209
+ required-on-create image versus optional-on-edit). Do not build a generic
210
+ form engine for one pair of pages. Use typed request and response shapes;
211
+ `unknown` is for a genuine boundary and must be narrowed before use.
212
+
213
+ Keep secrets and access tokens out of browser storage. Client affordances
214
+ are not authorization; protected page loaders and mutation endpoints must
215
+ enforce the same policy independently. Use a full navigation when an auth or
216
+ document boundary requires it, otherwise prefer the framework's navigation
217
+ and refresh primitives so state and loader data stay in sync.
218
+
219
+ ## 5. Ship usable, localizable presentation
220
+
221
+ - Use semantic landmarks, headings, buttons, links, and explicit form labels.
222
+ A placeholder is not a label. Put validation feedback next to its input;
223
+ announce asynchronous success and failure accessibly. Preserve visible
224
+ keyboard focus, reduced-motion preferences, and useful empty/loading/
225
+ failure states.
226
+ - Reuse design tokens and cohesive feature components. Keep utility classes
227
+ readable; extract repeated semantics or interactions, not an abstraction
228
+ for every repeated class string.
229
+ - If the UI offers a language switch, translate first-party visible copy,
230
+ metadata, and accessible names for supported locales. User-generated
231
+ content can remain in its original language. Do not claim a locale is
232
+ complete when only the navigation is translated.
233
+ - Comments explain non-obvious constraints or measured tradeoffs. They do not
234
+ narrate ordinary JSX or promise performance gains without evidence.
235
+ Remove stale starter examples and unexplained debugging writes before
236
+ shipping.
237
+
238
+ ## 6. Review and enforcement
239
+
240
+ Apply the shared `code-standards` rules to web TypeScript as well: `type` for
241
+ plain props/data, no `any`, no swallowed catches, no unsafe assertions used
242
+ to hide an unknown data contract. Configure non-mutating ESLint and formatting
243
+ checks to enforce rules that can be automated; do not rely on a lint command
244
+ that rewrites files as the only quality gate.
245
+
246
+ When tests are enabled, cover high-value behaviour through the public page
247
+ and component contracts: server render and hydration, pagination/filtering,
248
+ auth and error boundaries, form success/failure, navigation refresh, and
249
+ realtime cleanup. Co-locate focused tests per the testing-strategy skill.
250
+ Review accessibility and locale behavior in the actual supported modes.
251
+
252
+ Before approving a web change, ask:
253
+
254
+ 1. Can a reader identify the page's server data, browser state, and side
255
+ effects without untangling unrelated responsibilities?
256
+ 2. Does every loader obtain data and business decisions from a named app
257
+ service, with models/repositories kept out of the web layer?
258
+ 3. Does each behavioral component delegate all state, effects, derived
259
+ values, event handlers, and mutations to its own custom hook file?
260
+ 4. Is the service query bounded, the resource shape intentional, and the
261
+ cache scope safe?
262
+ 5. Do refresh, navigation, errors, retries, and cleanup leave the UI correct?
263
+ 6. Are forms and interactions accessible and translated to the degree the
264
+ interface promises?
265
+ 7. Are the relevant quality rules enforced or covered by focused tests,
266
+ rather than described only in comments?
@@ -1,4 +1,8 @@
1
- import { router } from "@warlock.js/core";
2
- import { fetchUploadedFileController } from "./controllers/fetch-uploaded-file.controller";
1
+ import { router, uploadedFileController } from "@warlock.js/core";
3
2
 
4
- router.get("/uploads/*", fetchUploadedFileController);
3
+ /**
4
+ * Serves local uploads. Image variants are opt-in and bounded: only the names
5
+ * declared in `uploads.images.variants` can be requested, e.g.
6
+ * `/uploads/avatars/me.jpg?variant=thumb&format=webp`.
7
+ */
8
+ router.get("/uploads/*", uploadedFileController);
@@ -5,39 +5,37 @@ import {
5
5
  RedisCacheDriver,
6
6
  type CacheConfigurations,
7
7
  } from "@warlock.js/cache";
8
- import { DatabaseCacheDriver, env, useRequestStore } from "@warlock.js/core";
8
+ import { DatabaseCacheDriver, env } from "@warlock.js/core";
9
9
 
10
10
  /**
11
- * Namespace every cache key by the caller's domain, so two tenants hitting the
12
- * same app never read each other's cached values.
11
+ * Namespace every cache key by this app, fixed at boot.
13
12
  *
14
- * This used to branch on `request.client` first. That property does not exist:
15
- * it survived from v4, where `Request` carried a `[key: string]: any` index
16
- * signature that made any property name compile. v5 removed the index signature
17
- * and the branch became a type error.
13
+ * This used to derive the prefix from `request.originDomain` / the `domain`
14
+ * header / `?domain=` input. That broke two ways at once: a browser GET has
15
+ * no `Origin` while a CSRF-protected POST does, so the two resolved to
16
+ * different prefixes and a write could never invalidate what a read had
17
+ * cached — repository caches and page-cache tags went silently stale. Worse,
18
+ * none of those three inputs are server-validated, so any visitor could pick
19
+ * `?domain=anything` and grow the store under an arbitrary namespace forever.
18
20
  *
19
- * It was deleted rather than renamed. The obvious "fix" — pointing it at
20
- * `request.locals.client` — compiles and is worse than the bug: nothing in the
21
- * framework populates `request.locals`, so the branch would be permanently
22
- * `undefined` and silently dead. `originDomain` below is real (it is derived
23
- * from the `Origin` header) and already covers the multi-tenant case, so the
24
- * scaffold prefixes on that and models nothing the framework does not provide.
21
+ * A cache prefix has to come from something the app decides, not something
22
+ * a request carries.
25
23
  */
26
- const globalPrefix = () => {
27
- const { request } = useRequestStore();
24
+ const globalPrefix = () => env("APP_NAME", "store");
28
25
 
29
- let cachePrefix = "store";
30
-
31
- if (!request) return cachePrefix;
32
-
33
- const domain = request.originDomain || request.header("domain") || request.input("domain");
34
-
35
- if (!domain) return cachePrefix;
36
-
37
- cachePrefix = `${cachePrefix}.${domain}`;
38
-
39
- return cachePrefix;
40
- };
26
+ /**
27
+ * Genuine multi-tenancy (one deployment, many tenants, isolated caches) needs
28
+ * a tenant id the app has already validated server-side — e.g. set on
29
+ * `request.locals.tenant` by the app's own tenant middleware after resolving
30
+ * the host against its tenants table — never the raw `Origin`/`Host`, a
31
+ * header, or query input, none of which the framework trusts.
32
+ *
33
+ * const globalPrefix = () => {
34
+ * const { request } = useRequestStore();
35
+ * const tenant = request?.locals.tenant; // set by app tenant middleware, already validated
36
+ * return tenant ? `${env("APP_NAME", "store")}.${tenant}` : env("APP_NAME", "store");
37
+ * };
38
+ */
41
39
 
42
40
  const cacheConfigurations: CacheConfigurations<"database"> = {
43
41
  // Driven by CACHE_DRIVER so the shipped .env (`memory`) actually wins.
@@ -18,6 +18,23 @@ const httpConfigurations: HttpConfigurations = {
18
18
  // allowed methods
19
19
  methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
20
20
  },
21
+ // Safe for local dev: `false` trusts nothing in front of this process, so
22
+ // request.ip/protocol/hostname read the raw socket. Behind a proxy/LB/CDN
23
+ // in production, set this to `true`, a trusted IP/CIDR string, or a list
24
+ // of trusted proxy IPs/CIDRs — otherwise rate limiting, ip-filter
25
+ // allowlists, and absolute URLs (e.g. the sitemap) trust the wrong client.
26
+ trustProxy: false,
27
+ // Opt-in, nonce-based Content-Security-Policy — off by default, zero
28
+ // behaviour change. Flip `enabled: true` to turn it on; the framework
29
+ // appends the request's own `'nonce-<value>'` to `script-src` for you.
30
+ // csp: {
31
+ // enabled: false,
32
+ // directives: {
33
+ // "script-src": ["'self'"],
34
+ // "object-src": ["'none'"],
35
+ // "base-uri": ["'self'"],
36
+ // },
37
+ // },
21
38
  };
22
39
 
23
40
  export default httpConfigurations;