@skyf0xx/hedgehog-core-full-stack-app 1.0.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 (137) hide show
  1. package/CLAUDE.core.md +147 -0
  2. package/LICENSE +21 -0
  3. package/README.md +41 -0
  4. package/agents/backend-eng.md +189 -0
  5. package/agents/front-end-eng.md +176 -0
  6. package/agents/ux-planner.md +174 -0
  7. package/hedgehog-core.yaml +20 -0
  8. package/package.json +41 -0
  9. package/skills/hedgehog-bootstrap/SKILL.md +324 -0
  10. package/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +411 -0
  11. package/skills/hedgehog-loop/SKILL.md +643 -0
  12. package/skills/link-workspace-packages/SKILL.md +41 -0
  13. package/skills/nx-generate/SKILL.md +165 -0
  14. package/skills/nx-run-tasks/SKILL.md +52 -0
  15. package/skills/nx-workspace/SKILL.md +261 -0
  16. package/skills/nx-workspace/references/AFFECTED.md +27 -0
  17. package/vendor-skills/GSAP/ATTRIBUTION.md +30 -0
  18. package/vendor-skills/GSAP/LICENSE +21 -0
  19. package/vendor-skills/GSAP/gsap-core/SKILL.md +254 -0
  20. package/vendor-skills/GSAP/gsap-frameworks/SKILL.md +266 -0
  21. package/vendor-skills/GSAP/gsap-performance/SKILL.md +79 -0
  22. package/vendor-skills/GSAP/gsap-plugins/SKILL.md +433 -0
  23. package/vendor-skills/GSAP/gsap-react/SKILL.md +136 -0
  24. package/vendor-skills/GSAP/gsap-scrolltrigger/SKILL.md +296 -0
  25. package/vendor-skills/GSAP/gsap-timeline/SKILL.md +107 -0
  26. package/vendor-skills/GSAP/gsap-utils/SKILL.md +284 -0
  27. package/vendor-skills/GSAP/llms.txt +39 -0
  28. package/workspace/.env.example +5 -0
  29. package/workspace/.github/workflows/phase-gate.yml +17 -0
  30. package/workspace/.prettierignore +5 -0
  31. package/workspace/.prettierrc +3 -0
  32. package/workspace/.vscode/extensions.json +3 -0
  33. package/workspace/apps/api/eslint.config.mjs +3 -0
  34. package/workspace/apps/api/package.json +117 -0
  35. package/workspace/apps/api/src/app/app.module.spec.ts +15 -0
  36. package/workspace/apps/api/src/app/app.module.ts +15 -0
  37. package/workspace/apps/api/src/app/feature-modules.ts +8 -0
  38. package/workspace/apps/api/src/app/health.controller.ts +9 -0
  39. package/workspace/apps/api/src/assets/.gitkeep +0 -0
  40. package/workspace/apps/api/src/main.ts +32 -0
  41. package/workspace/apps/api/tsconfig.app.json +22 -0
  42. package/workspace/apps/api/tsconfig.json +13 -0
  43. package/workspace/apps/api/tsconfig.spec.json +36 -0
  44. package/workspace/apps/api/vitest.config.mts +18 -0
  45. package/workspace/apps/api/webpack.config.cjs +25 -0
  46. package/workspace/apps/api-e2e/eslint.config.mjs +3 -0
  47. package/workspace/apps/api-e2e/package.json +28 -0
  48. package/workspace/apps/api-e2e/src/api/api.spec.ts +11 -0
  49. package/workspace/apps/api-e2e/src/support/global-setup.ts +17 -0
  50. package/workspace/apps/api-e2e/src/support/test-setup.ts +6 -0
  51. package/workspace/apps/api-e2e/tsconfig.json +10 -0
  52. package/workspace/apps/api-e2e/tsconfig.spec.json +28 -0
  53. package/workspace/apps/api-e2e/vitest.config.mts +20 -0
  54. package/workspace/apps/web/.env.example +12 -0
  55. package/workspace/apps/web/.prettierrc.js +10 -0
  56. package/workspace/apps/web/.swcrc +30 -0
  57. package/workspace/apps/web/components.json +21 -0
  58. package/workspace/apps/web/eslint.config.mjs +12 -0
  59. package/workspace/apps/web/index.d.ts +7 -0
  60. package/workspace/apps/web/next-env.d.ts +7 -0
  61. package/workspace/apps/web/next.config.js +9 -0
  62. package/workspace/apps/web/package.json +38 -0
  63. package/workspace/apps/web/postcss.config.mjs +7 -0
  64. package/workspace/apps/web/public/.gitkeep +0 -0
  65. package/workspace/apps/web/public/favicon.ico +0 -0
  66. package/workspace/apps/web/src/app/global.css +83 -0
  67. package/workspace/apps/web/src/app/layout.tsx +34 -0
  68. package/workspace/apps/web/src/app/module-routes.ts +13 -0
  69. package/workspace/apps/web/src/app/page.tsx +34 -0
  70. package/workspace/apps/web/src/app/providers.tsx +12 -0
  71. package/workspace/apps/web/src/components/theme-toggle.spec.tsx +20 -0
  72. package/workspace/apps/web/src/components/theme-toggle.tsx +32 -0
  73. package/workspace/apps/web/src/components/ui/button.tsx +60 -0
  74. package/workspace/apps/web/src/lib/utils.ts +6 -0
  75. package/workspace/apps/web/src/test-setup.ts +1 -0
  76. package/workspace/apps/web/tsconfig.json +62 -0
  77. package/workspace/apps/web/tsconfig.spec.json +37 -0
  78. package/workspace/apps/web/vitest.config.mts +27 -0
  79. package/workspace/apps/web-e2e/eslint.config.mjs +12 -0
  80. package/workspace/apps/web-e2e/package.json +10 -0
  81. package/workspace/apps/web-e2e/playwright.config.mts +75 -0
  82. package/workspace/apps/web-e2e/src/example.spec.ts +7 -0
  83. package/workspace/apps/web-e2e/tsconfig.json +26 -0
  84. package/workspace/commitlint.config.cjs +3 -0
  85. package/workspace/core.yaml +68 -0
  86. package/workspace/docker-compose.yml +14 -0
  87. package/workspace/eslint.config.mjs +15 -0
  88. package/workspace/gitignore.template +24 -0
  89. package/workspace/lefthook.yml +52 -0
  90. package/workspace/nx.json +90 -0
  91. package/workspace/package.json +99 -0
  92. package/workspace/packages/config/eslint-base.js +205 -0
  93. package/workspace/packages/config/package.json +28 -0
  94. package/workspace/packages/config/prettier.js +13 -0
  95. package/workspace/packages/config/src/env.schema.spec.ts +38 -0
  96. package/workspace/packages/config/src/env.schema.ts +18 -0
  97. package/workspace/packages/config/src/index.ts +1 -0
  98. package/workspace/packages/config/tsconfig.json +13 -0
  99. package/workspace/packages/config/tsconfig.lib.json +27 -0
  100. package/workspace/packages/config/tsconfig.spec.json +34 -0
  101. package/workspace/packages/config/vitest.config.mts +18 -0
  102. package/workspace/packages/db/package.json +27 -0
  103. package/workspace/packages/db/src/index.ts +2 -0
  104. package/workspace/packages/db/src/lib/db.spec.ts +48 -0
  105. package/workspace/packages/db/src/lib/db.ts +54 -0
  106. package/workspace/packages/db/src/schema/index.ts +4 -0
  107. package/workspace/packages/db/tsconfig.json +13 -0
  108. package/workspace/packages/db/tsconfig.lib.json +27 -0
  109. package/workspace/packages/db/tsconfig.spec.json +34 -0
  110. package/workspace/packages/db/vitest.config.mts +18 -0
  111. package/workspace/pnpm-lock.yaml +23719 -0
  112. package/workspace/pnpm-workspace.yaml +7 -0
  113. package/workspace/tools/generate-feature-modules.cjs +104 -0
  114. package/workspace/tools/generate-module-routes.cjs +89 -0
  115. package/workspace/tools/generators/contract/generator.ts +336 -0
  116. package/workspace/tools/generators/contract/schema.json +24 -0
  117. package/workspace/tools/generators/controller/generator.ts +390 -0
  118. package/workspace/tools/generators/controller/schema.json +24 -0
  119. package/workspace/tools/generators/fields.ts +160 -0
  120. package/workspace/tools/generators/generators.json +42 -0
  121. package/workspace/tools/generators/hook/generator.ts +291 -0
  122. package/workspace/tools/generators/hook/schema.json +19 -0
  123. package/workspace/tools/generators/lib-shell.ts +124 -0
  124. package/workspace/tools/generators/naming.ts +84 -0
  125. package/workspace/tools/generators/package.json +6 -0
  126. package/workspace/tools/generators/repository/generator.ts +298 -0
  127. package/workspace/tools/generators/repository/schema.json +15 -0
  128. package/workspace/tools/generators/schema/generator.ts +169 -0
  129. package/workspace/tools/generators/schema/schema.json +20 -0
  130. package/workspace/tools/generators/screen/generator.ts +218 -0
  131. package/workspace/tools/generators/screen/schema.json +15 -0
  132. package/workspace/tools/generators/service/generator.ts +270 -0
  133. package/workspace/tools/generators/service/schema.json +19 -0
  134. package/workspace/tools/phase-gate.cjs +77 -0
  135. package/workspace/tsconfig.base.json +21 -0
  136. package/workspace/tsconfig.json +25 -0
  137. package/workspace/vitest.workspace.ts +4 -0
@@ -0,0 +1,7 @@
1
+ packages:
2
+ - 'packages/*'
3
+ - 'apps/*'
4
+ # Domain libs land as libs/<module>/<layer> during Phase A. Declared up
5
+ # front so the first module through the repository layer doesn't have to
6
+ # edit this file before pnpm will link its workspace:* deps.
7
+ - 'libs/*/*'
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerates apps/api/src/app/feature-modules.ts — the barrel that lists
4
+ * every domain module's NestJS `@Module` class for AppModule to import.
5
+ *
6
+ * Written as CommonJS (`.cjs`) deliberately: root `package.json` sets
7
+ * `"type": "module"`, so a plain `.js` file here would be parsed as ESM
8
+ * and `require`/`module.exports` below would fail with "require is not
9
+ * defined".
10
+ *
11
+ * Why generated rather than hand-edited: the controller layer's scope is
12
+ * `apps/api/src/app/{module}/**` (core.yaml) — module-disjoint by
13
+ * construction, so two modules' controller tasks never touch the same
14
+ * file. AppModule itself is exclusive: true / once (see core.yaml), so it
15
+ * can't carry a per-module edit either. A file that every module's
16
+ * controller task hand-edited (even one line each) would sit outside
17
+ * every task's declared scope, invisible to the scheduler's conflict
18
+ * check (src/db/conflict.mjs only compares declared scope globs) — two
19
+ * concurrent module builds editing it would look conflict-free to the
20
+ * scheduler while actually racing on one file. Generating it instead
21
+ * removes the shared edit entirely: every module writes only inside its
22
+ * own `apps/api/src/app/{module}/` directory (a `{module}.module.ts`
23
+ * file, discovered here by naming convention), and this script is the
24
+ * only thing that ever writes feature-modules.ts.
25
+ *
26
+ * Convention: any file matching apps/api/src/app/*\/*.module.ts (module
27
+ * directories only, one level deep — not app.module.ts itself) is treated
28
+ * as a domain module and must have a named export ending in "Module".
29
+ *
30
+ * Run via the `generate-feature-modules` Nx target, which `test` and
31
+ * `build` depend on (nx.json targetDefaults) — never run by hand as part
32
+ * of normal development.
33
+ */
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ const APP_DIR = path.join(__dirname, '..', 'apps', 'api', 'src', 'app');
38
+ const OUTPUT_FILE = path.join(APP_DIR, 'feature-modules.ts');
39
+ const HEADER =
40
+ '// GENERATED FILE — do not hand-edit. Regenerated by\n' +
41
+ '// tools/generate-feature-modules.cjs (the generate-feature-modules Nx\n' +
42
+ '// target, which test and build depend on) from every\n' +
43
+ "// apps/api/src/app/*/*.module.ts file on disk. A module directory's\n" +
44
+ '// controller layer only ever creates its own *.module.ts inside its own\n' +
45
+ '// directory — never edits this file.\n';
46
+
47
+ function findModuleFiles() {
48
+ if (!fs.existsSync(APP_DIR)) return [];
49
+ const entries = fs.readdirSync(APP_DIR, { withFileTypes: true });
50
+ const files = [];
51
+ for (const entry of entries) {
52
+ if (!entry.isDirectory()) continue;
53
+ const moduleDir = path.join(APP_DIR, entry.name);
54
+ for (const file of fs.readdirSync(moduleDir)) {
55
+ if (file.endsWith('.module.ts')) {
56
+ files.push(path.join(entry.name, file.replace(/\.ts$/, '')));
57
+ }
58
+ }
59
+ }
60
+ return files.sort();
61
+ }
62
+
63
+ function exportNameFor(relativePath) {
64
+ // "tasks/tasks.module" -> "TasksModule". Every domain module generator
65
+ // (Nx's @nx/nest:module or hand-authored) names its class
66
+ // `<PascalCase>Module` to match the file's own `<kebab>.module.ts` name
67
+ // — the same convention @nx/nest itself uses.
68
+ const base = path.basename(relativePath).replace(/\.module$/, '');
69
+ const pascal = base
70
+ .split('-')
71
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1))
72
+ .join('');
73
+ return `${pascal}Module`;
74
+ }
75
+
76
+ function main() {
77
+ const moduleFiles = findModuleFiles();
78
+ const imports = [];
79
+ const names = [];
80
+
81
+ for (const relativePath of moduleFiles) {
82
+ const importName = exportNameFor(relativePath);
83
+ imports.push(`import { ${importName} } from './${relativePath}';`);
84
+ names.push(importName);
85
+ }
86
+
87
+ const body =
88
+ names.length > 0
89
+ ? `export const featureModules = [${names.join(', ')}];\n`
90
+ : 'export const featureModules = [];\n';
91
+
92
+ const content =
93
+ HEADER +
94
+ (imports.length > 0 ? '\n' + imports.join('\n') + '\n' : '') +
95
+ '\n' +
96
+ body;
97
+
98
+ fs.writeFileSync(OUTPUT_FILE, content);
99
+ console.log(
100
+ `Generated ${path.relative(process.cwd(), OUTPUT_FILE)} with ${names.length} feature module(s).`,
101
+ );
102
+ }
103
+
104
+ main();
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Regenerates apps/web/src/app/module-routes.ts — the list of every
4
+ * domain module's route for the root page to link.
5
+ *
6
+ * Written as CommonJS (`.cjs`) for the same reason as its sibling
7
+ * generate-feature-modules.cjs: root `package.json` sets `"type":
8
+ * "module"`, so a plain `.js` file here would be parsed as ESM.
9
+ *
10
+ * Why generated rather than hand-edited: the screen layer's scope is
11
+ * `apps/web/src/app/{module}/**` (core.yaml) — module-disjoint by
12
+ * construction, and `validateCore` requires that `{module}` on a
13
+ * module-axis core, so `apps/web/src/app/page.tsx` one level up is
14
+ * outside every layer's scope. Without this the root page can never
15
+ * learn about the modules built beneath it: a finished build ships a
16
+ * landing page with no route into the app it just built. This is the
17
+ * same shape as the API's feature-modules barrel, and gets the same
18
+ * answer — a shared file no module-scoped task can safely touch is
19
+ * generated, never hand-edited.
20
+ *
21
+ * Convention: any apps/web/src/app/<segment>/page.tsx (route directories
22
+ * only, one level deep — not the root page.tsx itself) is a module
23
+ * route. Next.js route groups and private directories are skipped:
24
+ * `(group)` does not appear in the URL, and `_private`/`.`-prefixed
25
+ * directories are not routes at all.
26
+ *
27
+ * Run via the `generate-module-routes` Nx target, which build, typecheck
28
+ * and test depend on (nx.json targetDefaults) — never run by hand as
29
+ * part of normal development.
30
+ */
31
+ const fs = require('node:fs');
32
+ const path = require('node:path');
33
+
34
+ const APP_DIR = path.join(__dirname, '..', 'apps', 'web', 'src', 'app');
35
+ const OUTPUT_FILE = path.join(APP_DIR, 'module-routes.ts');
36
+ const HEADER =
37
+ '// GENERATED FILE — do not hand-edit. Regenerated by\n' +
38
+ '// tools/generate-module-routes.cjs (the generate-module-routes Nx\n' +
39
+ '// target, which build, typecheck and test depend on) from every\n' +
40
+ "// apps/web/src/app/*/page.tsx file on disk. A module's screen layer\n" +
41
+ '// only ever creates its own page.tsx inside its own directory —\n' +
42
+ '// never edits this file or the root page.\n';
43
+
44
+ function findRouteDirs() {
45
+ if (!fs.existsSync(APP_DIR)) return [];
46
+ const entries = fs.readdirSync(APP_DIR, { withFileTypes: true });
47
+ const routes = [];
48
+ for (const entry of entries) {
49
+ if (!entry.isDirectory()) continue;
50
+ // `(group)` is a route group — it organises files without adding a
51
+ // URL segment, so it is not a route. `_x` and `.x` are conventions
52
+ // for "not a route" too.
53
+ if (/^[(_.]/.test(entry.name)) continue;
54
+ if (fs.existsSync(path.join(APP_DIR, entry.name, 'page.tsx'))) {
55
+ routes.push(entry.name);
56
+ }
57
+ }
58
+ return routes.sort();
59
+ }
60
+
61
+ /** "order-items" -> "Order items" — a label, not a class name. */
62
+ function labelFor(segment) {
63
+ const spaced = segment.replace(/-/g, ' ');
64
+ return spaced.charAt(0).toUpperCase() + spaced.slice(1);
65
+ }
66
+
67
+ function main() {
68
+ const routes = findRouteDirs();
69
+
70
+ const entries = routes
71
+ .map((segment) => ` { href: '/${segment}', label: '${labelFor(segment)}' },`)
72
+ .join('\n');
73
+
74
+ const body =
75
+ `export interface ModuleRoute {\n` +
76
+ ` href: string;\n` +
77
+ ` label: string;\n` +
78
+ `}\n\n` +
79
+ (routes.length > 0
80
+ ? `export const moduleRoutes: ModuleRoute[] = [\n${entries}\n];\n`
81
+ : `export const moduleRoutes: ModuleRoute[] = [];\n`);
82
+
83
+ fs.writeFileSync(OUTPUT_FILE, HEADER + '\n' + body);
84
+ console.log(
85
+ `Generated ${path.relative(process.cwd(), OUTPUT_FILE)} with ${routes.length} module route(s).`,
86
+ );
87
+ }
88
+
89
+ main();
@@ -0,0 +1,336 @@
1
+ import { formatFiles, Tree } from '@nx/devkit';
2
+ import { Field, isDateField, parseFields, zodSchema } from '../fields';
3
+ import { appendBarrelExport, generateLibShell } from '../lib-shell';
4
+ import { moduleNames, ModuleNames } from '../naming';
5
+
6
+ interface ContractGeneratorOptions {
7
+ module: string;
8
+ fields: string;
9
+ toggleField?: string;
10
+ }
11
+
12
+ const PACKAGE_ROOT = 'packages/contracts';
13
+
14
+ export default async function contractGenerator(
15
+ tree: Tree,
16
+ options: ContractGeneratorOptions,
17
+ ) {
18
+ const names = moduleNames(options.module);
19
+ const fields = parseFields(options.fields);
20
+
21
+ await generateLibShell(tree, {
22
+ directory: PACKAGE_ROOT,
23
+ importName: 'contracts',
24
+ tags: ['scope:contracts', 'type:contract'],
25
+ dependencies: {
26
+ '@ts-rest/core': '3.53.0-rc.1',
27
+ zod: '^4.4.3',
28
+ },
29
+ });
30
+
31
+ writeTimestampSchema(tree);
32
+
33
+ const dir = `${PACKAGE_ROOT}/src/${names.module}`;
34
+ tree.write(`${dir}/${names.module}.schema.ts`, entitySchemaFile(names, fields));
35
+ tree.write(`${dir}/${names.module}.errors.ts`, errorSchemaFile(names));
36
+ tree.write(
37
+ `${dir}/${names.module}.contract.ts`,
38
+ contractFile(names, options.toggleField),
39
+ );
40
+ tree.write(`${dir}/index.ts`, barrelFile(names));
41
+ tree.write(
42
+ `${dir}/${names.module}.spec.ts`,
43
+ specFile(names, fields, options.toggleField),
44
+ );
45
+
46
+ appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, `./${names.module}/index`);
47
+
48
+ await formatFiles(tree);
49
+ }
50
+
51
+ /**
52
+ * The shared half of the date-mode rule: a `timestamp` column declared
53
+ * `mode: 'date'` reflects through `createSelectSchema` as `z.date()`, which
54
+ * the server satisfies with a real `Date` and the browser never can — JSON
55
+ * carries it as an ISO string. Neither side alone is right, and no
56
+ * `.transform()` satisfies both, so the contract accepts the union.
57
+ */
58
+ function writeTimestampSchema(tree: Tree) {
59
+ const path = `${PACKAGE_ROOT}/src/timestamp.ts`;
60
+ if (tree.exists(path)) return;
61
+
62
+ tree.write(
63
+ path,
64
+ `import { z } from 'zod';
65
+
66
+ export const timestampSchema = z.union([z.date(), z.iso.datetime()]);
67
+
68
+ export type Timestamp = z.infer<typeof timestampSchema>;
69
+ `,
70
+ );
71
+ appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, './timestamp');
72
+ }
73
+
74
+ function entitySchemaFile(names: ModuleNames, fields: Field[]): string {
75
+ const bodyFields = fields
76
+ .map((field) => ` ${field.name}: ${zodSchema(field)},`)
77
+ .join('\n');
78
+ const createFields = fields
79
+ .map(
80
+ (field) =>
81
+ ` ${field.name}: ${zodSchema(field)}${field.nullable ? '.optional()' : ''},`,
82
+ )
83
+ .join('\n');
84
+
85
+ return `import { z } from 'zod';
86
+ import { timestampSchema } from '../timestamp';
87
+
88
+ export const ${names.entityCamel}Schema = z.object({
89
+ id: z.uuid(),
90
+ ${bodyFields}
91
+ createdAt: timestampSchema,
92
+ updatedAt: timestampSchema,
93
+ });
94
+
95
+ export const create${names.entityPascal}Schema = z.object({
96
+ ${createFields}
97
+ });
98
+
99
+ export const update${names.entityPascal}Schema = create${names.entityPascal}Schema.partial();
100
+
101
+ export type ${names.entityPascal} = z.infer<typeof ${names.entityCamel}Schema>;
102
+ export type Create${names.entityPascal} = z.infer<typeof create${names.entityPascal}Schema>;
103
+ export type Update${names.entityPascal} = z.infer<typeof update${names.entityPascal}Schema>;
104
+ `;
105
+ }
106
+
107
+ function errorSchemaFile(names: ModuleNames): string {
108
+ return `import { z } from 'zod';
109
+
110
+ export const ${names.entityCamel}NotFoundSchema = z.object({
111
+ error: z.literal('${names.entityPascal}NotFound'),
112
+ message: z.string(),
113
+ });
114
+
115
+ export const ${names.entityCamel}BadRequestSchema = z.object({
116
+ error: z.literal('${names.entityPascal}BadRequest'),
117
+ message: z.string(),
118
+ });
119
+
120
+ export type ${names.entityPascal}NotFound = z.infer<typeof ${names.entityCamel}NotFoundSchema>;
121
+ export type ${names.entityPascal}BadRequest = z.infer<typeof ${names.entityCamel}BadRequestSchema>;
122
+ `;
123
+ }
124
+
125
+ /**
126
+ * The toggle route carries `c.noBody()` deliberately: the new value is
127
+ * derived from stored state on the server, inside the service's existing
128
+ * transaction. A route that took the current value as a body would be a
129
+ * read-modify-write with the read served from the client's cache, so two
130
+ * toggles racing from one stale row would both write the same value and
131
+ * lose a flip.
132
+ *
133
+ * `@ts-rest/core` generates no `body` parameter for a `c.noBody()` route,
134
+ * so the call site is `client.toggle({ params: { id } })` — passing
135
+ * `body: undefined` is a compile error.
136
+ */
137
+ function toggleRoute(names: ModuleNames): string {
138
+ return ` toggle: {
139
+ method: 'POST',
140
+ path: '/${names.module}/:id/toggle',
141
+ pathParams: z.object({ id: z.uuid() }),
142
+ body: c.noBody(),
143
+ responses: {
144
+ 200: ${names.entityCamel}Schema,
145
+ 404: ${names.entityCamel}NotFoundSchema,
146
+ },
147
+ summary: 'Flip a ${names.entityCamel} from its stored state',
148
+ },
149
+ `;
150
+ }
151
+
152
+ function contractFile(names: ModuleNames, toggleField?: string): string {
153
+ return `import { initContract } from '@ts-rest/core';
154
+ import { z } from 'zod';
155
+ import {
156
+ ${names.entityCamel}Schema,
157
+ create${names.entityPascal}Schema,
158
+ update${names.entityPascal}Schema,
159
+ } from './${names.module}.schema';
160
+ import {
161
+ ${names.entityCamel}BadRequestSchema,
162
+ ${names.entityCamel}NotFoundSchema,
163
+ } from './${names.module}.errors';
164
+
165
+ const c = initContract();
166
+
167
+ export const ${names.camel}Contract = c.router(
168
+ {
169
+ list: {
170
+ method: 'GET',
171
+ path: '/${names.module}',
172
+ responses: {
173
+ 200: z.array(${names.entityCamel}Schema),
174
+ },
175
+ summary: 'List every ${names.entityCamel}',
176
+ },
177
+ get: {
178
+ method: 'GET',
179
+ path: '/${names.module}/:id',
180
+ pathParams: z.object({ id: z.uuid() }),
181
+ responses: {
182
+ 200: ${names.entityCamel}Schema,
183
+ 404: ${names.entityCamel}NotFoundSchema,
184
+ },
185
+ summary: 'Fetch one ${names.entityCamel} by id',
186
+ },
187
+ create: {
188
+ method: 'POST',
189
+ path: '/${names.module}',
190
+ body: create${names.entityPascal}Schema,
191
+ responses: {
192
+ 201: ${names.entityCamel}Schema,
193
+ 400: ${names.entityCamel}BadRequestSchema,
194
+ },
195
+ summary: 'Create a ${names.entityCamel}',
196
+ },
197
+ update: {
198
+ method: 'PATCH',
199
+ path: '/${names.module}/:id',
200
+ pathParams: z.object({ id: z.uuid() }),
201
+ body: update${names.entityPascal}Schema,
202
+ responses: {
203
+ 200: ${names.entityCamel}Schema,
204
+ 400: ${names.entityCamel}BadRequestSchema,
205
+ 404: ${names.entityCamel}NotFoundSchema,
206
+ },
207
+ summary: 'Update a ${names.entityCamel}',
208
+ },
209
+ remove: {
210
+ method: 'DELETE',
211
+ path: '/${names.module}/:id',
212
+ pathParams: z.object({ id: z.uuid() }),
213
+ responses: {
214
+ 204: c.noBody(),
215
+ 404: ${names.entityCamel}NotFoundSchema,
216
+ },
217
+ summary: 'Delete a ${names.entityCamel}',
218
+ },
219
+ ${toggleField ? toggleRoute(names) : ''} },
220
+ {
221
+ // apps/api sets /api as a global prefix at runtime (apps/api/src/main.ts),
222
+ // so the paths above stay prefix-free and the client adds the base URL.
223
+ strictStatusCodes: true,
224
+ },
225
+ );
226
+ `;
227
+ }
228
+
229
+ function barrelFile(names: ModuleNames): string {
230
+ return `export * from './${names.module}.contract';
231
+ export * from './${names.module}.errors';
232
+ export * from './${names.module}.schema';
233
+ `;
234
+ }
235
+
236
+ function specFile(
237
+ names: ModuleNames,
238
+ fields: Field[],
239
+ toggleField?: string,
240
+ ): string {
241
+ const dateField = fields.find(isDateField);
242
+ const requiredField = fields.find((field) => !field.nullable);
243
+
244
+ return `import { describe, expect, it } from 'vitest';
245
+ import {
246
+ ${names.camel}Contract,
247
+ ${names.entityCamel}Schema,
248
+ create${names.entityPascal}Schema,
249
+ } from './index';
250
+
251
+ const sample = ${sampleLiteral(names, fields)};
252
+
253
+ describe('${names.camel} contract', () => {
254
+ it('exposes the full CRUD route set under /${names.module}', () => {
255
+ expect(${names.camel}Contract.list.method).toBe('GET');
256
+ expect(${names.camel}Contract.list.path).toBe('/${names.module}');
257
+ expect(${names.camel}Contract.get.path).toBe('/${names.module}/:id');
258
+ expect(${names.camel}Contract.create.method).toBe('POST');
259
+ expect(${names.camel}Contract.update.method).toBe('PATCH');
260
+ expect(${names.camel}Contract.remove.method).toBe('DELETE');
261
+ });
262
+ ${
263
+ toggleField
264
+ ? `
265
+ it('takes no body on toggle, so a stale client value cannot overwrite state', () => {
266
+ expect(${names.camel}Contract.toggle.method).toBe('POST');
267
+ expect(${names.camel}Contract.toggle.path).toBe('/${names.module}/:id/toggle');
268
+ // c.noBody() is a marker symbol at runtime; what it buys is a client
269
+ // call signature with no body parameter at all, so the new ${toggleField}
270
+ // can only be derived server-side.
271
+ expect(String(${names.camel}Contract.toggle.body)).toBe(
272
+ 'Symbol(ContractNoBody)',
273
+ );
274
+ });
275
+ `
276
+ : ''
277
+ }
278
+ it('accepts a server-side row, where every timestamp is a Date', () => {
279
+ expect(${names.entityCamel}Schema.parse(sample)).toBeDefined();
280
+ });
281
+
282
+ it('accepts the same row as JSON, where every timestamp is an ISO string', () => {
283
+ const overTheWire = {
284
+ ...sample,${dateField ? `\n ${dateField.name}: sample.${dateField.name}.toISOString(),` : ''}
285
+ createdAt: sample.createdAt.toISOString(),
286
+ updatedAt: sample.updatedAt.toISOString(),
287
+ };
288
+
289
+ expect(${names.entityCamel}Schema.parse(overTheWire)).toBeDefined();
290
+ });
291
+ ${
292
+ requiredField
293
+ ? `
294
+ it('rejects a create body missing ${requiredField.name}', () => {
295
+ const rest = { ...createInput } as Partial<typeof createInput>;
296
+ delete rest.${requiredField.name};
297
+
298
+ expect(create${names.entityPascal}Schema.safeParse(rest).success).toBe(false);
299
+ });
300
+ `
301
+ : ''
302
+ }});
303
+
304
+ const createInput = ${createLiteral(fields)};
305
+ `;
306
+ }
307
+
308
+ const SAMPLE_UUID = '00000000-0000-4000-8000-000000000000';
309
+
310
+ function sampleLiteral(names: ModuleNames, fields: Field[]): string {
311
+ const entries = [
312
+ `id: '${SAMPLE_UUID}'`,
313
+ ...fields.map((field) => `${field.name}: ${sampleValue(field)}`),
314
+ 'createdAt: new Date()',
315
+ 'updatedAt: new Date()',
316
+ ];
317
+ return `{\n ${entries.join(',\n ')},\n}`;
318
+ }
319
+
320
+ function createLiteral(fields: Field[]): string {
321
+ const entries = fields
322
+ .filter((field) => !field.nullable)
323
+ .map((field) => `${field.name}: ${sampleValue(field)}`);
324
+ return `{\n ${entries.join(',\n ')},\n}`;
325
+ }
326
+
327
+ function sampleValue(field: Field): string {
328
+ const byType: Record<string, string> = {
329
+ string: `'sample'`,
330
+ text: `'sample'`,
331
+ boolean: 'false',
332
+ integer: '1',
333
+ timestamp: 'new Date()',
334
+ };
335
+ return byType[field.type] ?? 'null';
336
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "http://json-schema.org/schema",
3
+ "$id": "HedgehogContractLayer",
4
+ "title": "ts-rest contract for one domain module",
5
+ "type": "object",
6
+ "properties": {
7
+ "module": {
8
+ "type": "string",
9
+ "description": "Domain module name, plural kebab-case (e.g. tasks, order-items).",
10
+ "$default": { "$source": "argv", "index": 0 },
11
+ "x-prompt": "Domain module name (plural kebab-case)?"
12
+ },
13
+ "fields": {
14
+ "type": "string",
15
+ "description": "The same name:type list the schema layer was generated with; a trailing ? marks the field nullable, and string takes an optional length (title:string(500)).",
16
+ "x-prompt": "Fields (name:type, comma-separated)?"
17
+ },
18
+ "toggleField": {
19
+ "type": "string",
20
+ "description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
21
+ }
22
+ },
23
+ "required": ["module", "fields"]
24
+ }