@starci/hfs 4.2.13 → 4.2.14

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 (203) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -3
  3. package/emit/db-types.mjs +2 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +201 -14
  6. package/runtime/knowledge/hfs/fe-stack.yaml +218 -0
  7. package/runtime/knowledge/hfs/rules.yaml +51 -48
  8. package/runtime/knowledge/hfs/slots.yaml +307 -179
  9. package/runtime/knowledge/patterns/be/event-bus.yaml +15 -0
  10. package/runtime/knowledge/patterns/be/jobs.yaml +10 -0
  11. package/runtime/knowledge/patterns/be/projections.yaml +10 -0
  12. package/runtime/knowledge/patterns/be/queues.yaml +15 -0
  13. package/runtime/knowledge/patterns/be/reactors.yaml +8 -0
  14. package/runtime/knowledge/patterns/be/realtime.yaml +17 -0
  15. package/runtime/knowledge/patterns/be/saga.yaml +13 -0
  16. package/runtime/knowledge/patterns/fe/folder.yaml +24 -18
  17. package/runtime/knowledge/sonar-gate.yaml +10 -2
  18. package/runtime/modules/kernel/failure-codes.yaml +40 -60
  19. package/runtime/modules/kernel/removed-vocabulary.yaml +48 -0
  20. package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +0 -2
  21. package/runtime/scripts/hfs/architecture/config.mjs +2 -1
  22. package/runtime/scripts/hfs/architecture/dead-exports.mjs +6 -2
  23. package/runtime/scripts/hfs/architecture/error-masked.mjs +10 -1
  24. package/runtime/scripts/hfs/architecture/frontend.mjs +10 -3
  25. package/runtime/scripts/hfs/architecture/hfs.mjs +6 -3
  26. package/runtime/scripts/hfs/architecture/index.mjs +2 -0
  27. package/runtime/scripts/hfs/architecture/owners.mjs +19 -16
  28. package/runtime/scripts/hfs/architecture/route-files-thin.mjs +4 -1
  29. package/runtime/scripts/hfs/architecture/server-action.mjs +75 -20
  30. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +2 -2
  31. package/runtime/scripts/hfs/architecture/supabase.mjs +36 -210
  32. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -33
  33. package/runtime/scripts/hfs/architecture/transport-owner.mjs +110 -54
  34. package/runtime/scripts/hfs/architecture/type-context.mjs +4 -1
  35. package/runtime/scripts/hfs/check.mjs +10 -6
  36. package/runtime/scripts/hfs/edition-slots.mjs +13 -4
  37. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  38. package/runtime/scripts/hfs/rule-catalog.mjs +21 -1
  39. package/runtime/scripts/hfs/rules/database-constants.mjs +4 -0
  40. package/runtime/scripts/hfs/rules/database-migrations.mjs +12 -7
  41. package/runtime/scripts/hfs/rules/database.mjs +4 -3
  42. package/runtime/scripts/hfs/rules/edition.mjs +49 -37
  43. package/runtime/scripts/hfs/rules/fe-no-tests.mjs +4 -5
  44. package/runtime/scripts/hfs/rules/fe-stack.mjs +71 -0
  45. package/scaffold/add-app.mjs +24 -18
  46. package/scaffold/add-cli-lite.mjs +33 -3
  47. package/scaffold/add-inbox-lite.mjs +24 -8
  48. package/scaffold/add-table.mjs +222 -74
  49. package/scaffold/add.mjs +85 -35
  50. package/scaffold/app.mjs +140 -36
  51. package/scaffold/edition-gate.mjs +21 -2
  52. package/scaffold/lite-exports.mjs +30 -1
  53. package/scaffold/service.mjs +5 -8
  54. package/src/main.mjs +2 -2
  55. package/sync/index.mjs +19 -15
  56. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +14 -0
  57. package/templates/app/codegen-graphql.mjs.partial +34 -0
  58. package/templates/app/codegen-lite/scripts/codegen.mjs +18 -13
  59. package/templates/app/package-scripts/core.json +1 -0
  60. package/templates/app/package-scripts-lite/package.json +4 -0
  61. package/templates/app/skeleton/README.md +1 -1
  62. package/templates/app/skeleton/scripts/codegen.mjs +4 -1
  63. package/templates/app/skeleton-lite/README.md +7 -4
  64. package/templates/app/skeleton-lite/supabase/config.toml +1 -1
  65. package/templates/app/skeleton-lite/supabase/migrations/__timestamp___baseline.sql +13 -14
  66. package/templates/be/inbox/database-client.spec.ts.tpl +47 -0
  67. package/templates/be/inbox/service.spec.ts.tpl +46 -0
  68. package/templates/be/inbox/webhook-door.spec.ts.tpl +76 -0
  69. package/templates/be/patterns/api/graphql.module.ts.tpl +2 -2
  70. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +6 -3
  71. package/templates/be/platform/errors-graphql/graphql-error.mapper.spec.ts +52 -0
  72. package/templates/be/platform/errors-graphql/graphql-error.mapper.ts +31 -0
  73. package/templates/be/platform/graphql/graphql-depth.policy.spec.ts +43 -0
  74. package/templates/be/platform/graphql/graphql-depth.policy.ts +21 -0
  75. package/templates/be/platform/graphql/graphql.contracts.ts +12 -0
  76. package/templates/be/platform/graphql/graphql.module-definition.ts +7 -0
  77. package/templates/be/platform/graphql/graphql.module.ts +49 -0
  78. package/templates/be/platform/graphql/graphql.options.ts +2 -0
  79. package/templates/be/platform/graphql/index.ts +1 -0
  80. package/templates/be/platform/graphql/localize-errors.mapper.spec.ts +114 -0
  81. package/templates/be/platform/graphql/localize-errors.mapper.ts +51 -0
  82. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.spec.ts +11 -0
  83. package/templates/be/skeleton/src/modules/platform/errors/errors.filter.ts +8 -3
  84. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.spec.ts +13 -1
  85. package/templates/be/skeleton/src/modules/platform/http-security/execution-request.mapper.ts +11 -2
  86. package/templates/be/skeleton-lite/src/modules/domain/identity/admission.policy.spec.ts +21 -0
  87. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.spec.ts +94 -0
  88. package/templates/be/skeleton-lite/src/modules/domain/identity/auth.guard.ts +2 -4
  89. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +10 -0
  90. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +2 -2
  91. package/templates/be/skeleton-lite/src/modules/integrations/supabase/index.ts +2 -6
  92. package/templates/be/skeleton-lite/src/modules/integrations/supabase/supabase.client.spec.ts +15 -0
  93. package/templates/be/skeleton-lite/src/modules/platform/i18n/index.ts +1 -1
  94. package/templates/be/skeleton-lite/src/modules/platform/logging/index.ts +1 -1
  95. package/templates/be/table/api/create.command.ts.tpl +10 -0
  96. package/templates/be/table/api/create.contracts.ts.tpl +8 -0
  97. package/templates/be/table/api/create.handler.ts.tpl +22 -0
  98. package/templates/be/table/api/create.mapper.ts.tpl +5 -0
  99. package/templates/be/table/api/create.resolver.ts.tpl +21 -0
  100. package/templates/be/table/api/create.type.ts.tpl +9 -0
  101. package/templates/be/table/api/feature.index.ts.tpl +1 -0
  102. package/templates/be/table/api/feature.module.ts.tpl +7 -0
  103. package/templates/be/table/api/get.contracts.ts.tpl +11 -0
  104. package/templates/be/table/api/get.handler.ts.tpl +22 -0
  105. package/templates/be/table/api/get.input.ts.tpl +12 -0
  106. package/templates/be/table/api/get.mapper.ts.tpl +9 -0
  107. package/templates/be/table/api/get.query.ts.tpl +10 -0
  108. package/templates/be/table/api/get.resolver.ts.tpl +27 -0
  109. package/templates/be/table/api/get.type.ts.tpl +9 -0
  110. package/templates/be/table/api/graphql.module.ts.tpl +8 -0
  111. package/templates/be/table/error.api.ts.tpl +19 -0
  112. package/templates/be/table/migration.sql.tpl +13 -21
  113. package/templates/be/table/service.api.ts.tpl +30 -0
  114. package/templates/be/table/sql.api.ts.tpl +7 -0
  115. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/index.tsx +16 -18
  116. package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignOutButton/component.tsx +7 -3
  117. package/templates/fe/skeleton/apps/__app__/src/components/blocks/SignOutButton/index.tsx +23 -0
  118. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +49 -0
  119. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +4 -0
  120. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSession.ts +6 -0
  121. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignIn.ts +29 -0
  122. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useSignOut.ts +27 -0
  123. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/index.ts +1 -0
  124. package/templates/fe/skeleton/apps/__app__/src/hooks/effects/useAuthSessionEffect.ts +14 -0
  125. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/SwrSideEffects.tsx +8 -0
  126. package/templates/fe/skeleton/apps/__app__/src/hooks/swr/index.ts +1 -0
  127. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/index.ts +1 -0
  128. package/templates/fe/skeleton/apps/__app__/src/modules/api/graphql/types.ts +26 -0
  129. package/templates/fe/skeleton/apps/__app__/src/modules/api/index.ts +1 -0
  130. package/templates/fe/skeleton/apps/__app__/src/modules/auth/client.ts +21 -0
  131. package/templates/fe/skeleton/apps/__app__/src/modules/auth/contracts.ts +27 -0
  132. package/templates/fe/skeleton/apps/__app__/src/modules/auth/index.ts +2 -0
  133. package/templates/fe/skeleton/apps/__app__/src/modules/toast/api.ts +52 -0
  134. package/templates/fe/skeleton/apps/__app__/src/modules/toast/hooks.ts +33 -0
  135. package/templates/fe/skeleton/apps/__app__/src/modules/toast/index.ts +1 -0
  136. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +27 -0
  137. package/templates/fe/skeleton/apps/__app__/src/modules/types/ambient/styles.d.ts +2 -0
  138. package/templates/fe/skeleton/apps/__app__/src/redux/ReduxProvider.tsx +11 -0
  139. package/templates/fe/skeleton/apps/__app__/src/redux/hooks.ts +8 -0
  140. package/templates/fe/skeleton/apps/__app__/src/redux/index.ts +3 -0
  141. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +26 -0
  142. package/templates/fe/skeleton/apps/__app__/src/redux/store.ts +14 -0
  143. package/templates/fe/skeleton/apps/app/src/app/[locale]/layout.tsx +5 -2
  144. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/component.tsx +10 -2
  145. package/templates/fe/skeleton/apps/app/src/features/pages/AppHomePage/index.tsx +1 -1
  146. package/templates/fe/skeleton/apps/app/src/modules/i18n/messages/vi.json +23 -0
  147. package/templates/fe/skeleton/apps/landing/src/modules/types/ambient/styles.d.ts +2 -0
  148. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +51 -0
  149. package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/providers.tsx +10 -13
  150. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/component.tsx +8 -5
  151. package/templates/fe/skeleton-lite/apps/web/src/features/pages/AppHomePage/index.tsx +2 -17
  152. package/templates/fe/skeleton-lite/apps/web/src/modules/config/index.ts +3 -10
  153. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/index.ts +0 -6
  154. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +11 -8
  155. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/index.ts +0 -3
  156. package/templates/fe/skeleton-lite/apps/web/src/proxy.ts +9 -25
  157. package/templates/fe/table/graphql/mutation.ts.tpl +25 -0
  158. package/templates/fe/table/graphql/mutation.types.ts.tpl +7 -0
  159. package/templates/fe/table/graphql/query.ts.tpl +25 -0
  160. package/templates/fe/table/graphql/query.types.ts.tpl +7 -0
  161. package/templates/fe/table/graphql/useMutateSwr.ts.tpl +21 -0
  162. package/templates/fe/table/graphql/useQuerySwr.ts.tpl +17 -0
  163. package/templates/fe/transport/graphql/clients/clients/auth.ts +39 -0
  164. package/templates/fe/transport/graphql/clients/clients/index.ts +2 -0
  165. package/templates/fe/transport/graphql/clients/clients/no-auth.ts +33 -0
  166. package/templates/fe/transport/graphql/clients/index.ts +2 -0
  167. package/templates/fe/transport/graphql/clients/links/attach-access-token.ts +9 -0
  168. package/templates/fe/transport/graphql/clients/links/error.ts +30 -0
  169. package/templates/fe/transport/graphql/clients/links/http.ts +16 -0
  170. package/templates/fe/transport/graphql/clients/links/index.ts +8 -0
  171. package/templates/fe/transport/graphql/clients/links/locale.ts +13 -0
  172. package/templates/fe/transport/graphql/clients/links/options.ts +17 -0
  173. package/templates/fe/transport/graphql/clients/links/refresh-token.ts +36 -0
  174. package/templates/fe/transport/graphql/clients/links/retry.ts +16 -0
  175. package/templates/fe/transport/graphql/clients/links/timeout.ts +28 -0
  176. package/templates/fe/transport/graphql/clients/response.ts +43 -0
  177. package/upgrade/check.mjs +1 -1
  178. package/upgrade/index.mjs +3 -2
  179. package/runtime/scripts/hfs/architecture/supabase-results.mjs +0 -182
  180. package/templates/app/quality-config-lite/sonar-project.properties +0 -5
  181. package/templates/be/patterns/api/feature.index.http.ts.tpl +0 -1
  182. package/templates/be/patterns/api/http.controller.ts.tpl +0 -27
  183. package/templates/be/patterns/api/http.mapper.ts.tpl +0 -9
  184. package/templates/be/patterns/api/http.module.ts.tpl +0 -7
  185. package/templates/be/patterns/api/http.request.ts.tpl +0 -10
  186. package/templates/be/patterns/api/http.response.ts.tpl +0 -5
  187. package/templates/fe/skeleton-lite/apps/web/src/app/auth/callback/route.ts +0 -24
  188. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignOutButton/index.tsx +0 -38
  189. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/read-session.ts +0 -25
  190. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-in.ts +0 -25
  191. package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +0 -17
  192. package/templates/fe/skeleton-lite/apps/web/src/modules/db/browser.ts +0 -14
  193. package/templates/fe/skeleton-lite/apps/web/src/modules/db/index.ts +0 -3
  194. package/templates/fe/skeleton-lite/apps/web/src/modules/db/outcome.ts +0 -59
  195. package/templates/fe/skeleton-lite/apps/web/src/modules/db/principal.ts +0 -29
  196. package/templates/fe/skeleton-lite/apps/web/src/modules/db/server.ts +0 -70
  197. package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +0 -58
  198. package/templates/fe/skeleton-lite/apps/web/src/modules/routes/safe-next-path.ts +0 -26
  199. package/templates/fe/table/read.ts.tpl +0 -16
  200. package/templates/fe/table/write.ts.tpl +0 -16
  201. /package/templates/be/patterns/api/{application.command.http.ts.tpl → application.command.principal.ts.tpl} +0 -0
  202. /package/templates/be/patterns/api/{application.handler.http.ts.tpl → application.handler.principal.ts.tpl} +0 -0
  203. /package/templates/fe/{skeleton-lite/apps/web → skeleton/apps/__app__}/src/components/blocks/SignInForm/component.tsx +0 -0
package/scaffold/app.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  // The selected edition owns the app root and its be/fe sides under knowledge/hfs/slots.yaml.
3
3
  //
4
4
  // The skeleton files are written once from templates/<app|be|fe>/skeleton ({{project}}, {{app}} and {{appPascal}} filled). Path
5
- // variables: a `__app__` folder is written once per app of the side, named after it; an fe `apps/<name>/` folder is the skeleton of
6
- // the declared fe app <name> alone (landing and app differ); `__project__` in a path is the project name (packages/__project__-ui is
7
- // packages/<name>-ui). The workspace manifests and every tsconfig.json are written from code, never kept as template files (a
5
+ // variables: a `__app__` folder is written once per app of the side, named after it (on the fe side: once per product app, every
6
+ // declared Next app but the marketing landing, which calls no back end: the shared app core of both editions, modules/api, auth,
7
+ // toast, redux, the providers and the hooks); an fe `apps/<name>/` folder is the skeleton of the declared fe app <name> alone
8
+ // (landing and app differ); `__project__` in a path is the project name (packages/__project__-ui is packages/<name>-ui). The workspace manifests and every tsconfig.json are written from code, never kept as template files (a
8
9
  // tsconfig.json under templates/ would be a TypeScript project of this repository); the managed files are the render of `starci app sync` (sync/index.mjs), so a fresh app
9
10
  // is in sync by construction. The lockfile is never written by hand: once the files are written, npm resolves the real one
10
11
  // (`npm install --package-lock-only`, no node_modules, no scripts), so `npm ci` installs the new app as it is. When npm cannot
@@ -33,9 +34,58 @@ const APP_DIR = '__app__';
33
34
  const PROJECT_DIR = '__project__';
34
35
  const TIMESTAMP = '__timestamp__';
35
36
  const PINS_FILE = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'hfs', 'canon-pins.yaml');
37
+ /**
38
+ * The front-end stack catalog (knowledge/hfs/fe-stack.yaml): the bundled runtime copy, else the runtime checkout's own file (the
39
+ * package inside the StarCi repository before the runtime copy carries it).
40
+ */
41
+ const FE_STACK_FILES = [
42
+ path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'hfs', 'fe-stack.yaml'),
43
+ path.join(import.meta.dirname, '..', '..', '..', 'knowledge', 'hfs', 'fe-stack.yaml'),
44
+ ];
36
45
  const SONAR_GATE_FILE = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'sonar-gate.yaml');
46
+ /** The runtime-peer catalog (knowledge/hfs/peer-integrations.yaml, R111) the package carries. */
47
+ const PEERS_FILE = path.join(import.meta.dirname, '..', 'runtime', 'knowledge', 'hfs', 'peer-integrations.yaml');
37
48
  const pascal = name => name.split('-').map(part => part[0].toUpperCase() + part.slice(1)).join('');
38
- const bundledPins = () => parseYaml(fs.readFileSync(PINS_FILE, 'utf8')).pins;
49
+ /** The canon pins (knowledge/hfs/canon-pins.yaml) the package carries. */
50
+ export const bundledPins = () => parseYaml(fs.readFileSync(PINS_FILE, 'utf8')).pins;
51
+
52
+ /** The parsed front-end stack catalog; a package without it cannot know what every front end installs. */
53
+ function bundledFeStack() {
54
+ const file = FE_STACK_FILES.find(candidate => fs.existsSync(candidate));
55
+ if (!file) throw new ScaffoldError('HFS_SCAFFOLD_STACK_MISSING', 'knowledge/hfs/fe-stack.yaml is not carried by this package; regenerate the runtime copy');
56
+ return parseYaml(fs.readFileSync(file, 'utf8'));
57
+ }
58
+
59
+ /**
60
+ * The marketing fe app of the full starter: it calls no back end, so the shared app core (`apps/__app__/`) is not written into
61
+ * it and the repository keeps one transport client folder (FE_TRANSPORT_OWNER).
62
+ */
63
+ const MARKETING_FE_APPS = Object.freeze(new Set(['landing']));
64
+ /** The fe apps that carry the shared app core: every declared Next app but the marketing one. */
65
+ export const coreFeApps = apps => apps.filter(entry => entry.kind === 'next' && !MARKETING_FE_APPS.has(entry.name));
66
+
67
+ /**
68
+ * The tool packages of the fe stack, installed once at the app root (devDependencies) and never in an fe workspace: the
69
+ * concerns whose packages only the root scripts run (the GraphQL codegen, lint and format), plus the compiler and the CSS engine.
70
+ */
71
+ const FE_ROOT_TOOL_CONCERNS = Object.freeze(new Set(['codegen', 'lint']));
72
+ const FE_ROOT_TOOL_PACKAGES = Object.freeze(new Set(['typescript', 'tailwindcss']));
73
+
74
+ /**
75
+ * The `tier: core` packages of the fe stack every scaffold installs, read from fe-stack.yaml (never listed here), split into
76
+ * `app` (the runtime imports of each product fe app workspace) and `root` (the tools at the app root). Both editions alike.
77
+ */
78
+ export function coreFeStackPackages(stack = bundledFeStack()) {
79
+ const app = new Set();
80
+ const root = new Set();
81
+ for (const concern of stack.concerns ?? []) {
82
+ if (concern.tier !== 'core') continue;
83
+ for (const name of concern.packages ?? []) {
84
+ (FE_ROOT_TOOL_CONCERNS.has(concern.id) || FE_ROOT_TOOL_PACKAGES.has(name) ? root : app).add(name);
85
+ }
86
+ }
87
+ return { app: [...app].sort(byCodeUnit), root: [...root].sort(byCodeUnit) };
88
+ }
39
89
 
40
90
  /**
41
91
  * A JSON file as prettier prints it (the app's format check judges every file the scaffold writes): an object one key per line, an
@@ -87,7 +137,7 @@ const LITE_STARTER_SUPABASE = Object.freeze({
87
137
  enableSignup: false,
88
138
  jwtExpiry: 3600,
89
139
  siteUrl: 'http://127.0.0.1:3000',
90
- redirectUrls: ['http://127.0.0.1:3000/auth/callback'],
140
+ redirectUrls: ['http://127.0.0.1:3000/'],
91
141
  });
92
142
 
93
143
  /**
@@ -97,8 +147,8 @@ const LITE_STARTER_SUPABASE = Object.freeze({
97
147
  */
98
148
  const STARTER_DEPENDENCIES = Object.freeze({
99
149
  dependencies: {
100
- '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null,
101
- '@nestjs/platform-express': null, '@nestjs/typeorm': null, 'nest-commander': null, pg: null,
150
+ '@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
151
+ '@nestjs/platform-express': null, '@nestjs/typeorm': null, 'nest-commander': null, pg: null, graphql: null,
102
152
  'class-transformer': null, 'class-validator': null, 'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null,
103
153
  },
104
154
  devDependencies: {
@@ -110,37 +160,45 @@ const STARTER_DEPENDENCIES = Object.freeze({
110
160
  },
111
161
  });
112
162
 
113
- /** Lite keeps the production stack and canon tools, but has no test runtime or test types. */
163
+ /**
164
+ * Lite keeps the production stack, the canon tools and the back end's unit run (owner ruling 2026-10-12); it runs no test world of
165
+ * its own (no @starci/test-world). Both editions serve GraphQL from the api app (@nestjs/graphql over Apollo, owner ruling
166
+ * 2026-10-13); the fe stack's root tools are added from fe-stack.yaml by packageManifest.
167
+ */
114
168
  const LITE_STARTER_DEPENDENCIES = Object.freeze({
115
169
  dependencies: {
116
- '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null,
170
+ '@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
117
171
  '@nestjs/platform-express': null, '@nestjs/typeorm': null, '@supabase/supabase-js': null,
118
- 'class-transformer': null, 'class-validator': null, jose: null, pg: null,
172
+ 'class-transformer': null, 'class-validator': null, graphql: null, jose: null, pg: null,
119
173
  'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null,
120
174
  },
121
175
  devDependencies: {
122
- turbo: null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/cli': null,
123
- '@starci/prettier-config': null, '@starci/stylelint-canon': null, '@starci/tsconfig': null, '@tailwindcss/postcss': null,
124
- '@types/express': null, '@types/node': null, '@types/react': null, '@types/react-dom': null,
125
- eslint: null, 'eslint-plugin-react-hooks': null, husky: null, 'postcss-value-parser': null, prettier: null,
126
- stylelint: null, supabase: null, tailwindcss: null, 'ts-node-dev': null, 'tsc-alias': null,
176
+ turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null, '@starci/cli': null,
177
+ '@starci/jest-preset': null, '@starci/prettier-config': null, '@starci/stylelint-canon': null, '@starci/tsconfig': null, '@tailwindcss/postcss': null,
178
+ '@types/express': null, '@types/jest': null, '@types/node': null, '@types/react': null, '@types/react-dom': null,
179
+ eslint: null, 'eslint-plugin-react-hooks': null, husky: null, jest: null, 'postcss-value-parser': null, prettier: null,
180
+ stylelint: null, supabase: null, tailwindcss: null, 'ts-jest': null, 'ts-node-dev': null, 'tsc-alias': null,
127
181
  'tsconfig-paths': null, typescript: null,
128
182
  },
129
183
  });
130
184
 
131
185
  /**
132
- * The runtime dependencies of every fe app workspace of the starter: what its skeleton imports (the two workspace packages at "*",
133
- * next with next-intl, react, the grammar and the HeroUI styles of its globals.css), @heroui/react and @xyflow/react
134
- * (the grammar's required peers, which every consumer declares: the grammar contract) and react-dom (next's peer). `@<project>/<pkg>` names are filled with the project.
186
+ * The runtime dependencies of every fe app workspace of the starter beyond the fe stack's core packages (fe-stack.yaml, added by
187
+ * feAppManifest for a product app): the two workspace packages at "*", the grammar and its required peers (@heroui/react,
188
+ * @xyflow/react: the grammar contract), next with next-intl, react and react-dom (next's peer). `@<project>/<pkg>` names are
189
+ * filled with the project. The marketing landing takes exactly these.
135
190
  */
136
191
  const FE_APP_DEPENDENCIES = Object.freeze({
137
192
  '@<project>/i18n': '*', '@<project>/ui': '*',
138
193
  '@heroui/react': null, '@heroui/styles': null, '@starci/grammar': null, '@xyflow/react': null, next: null, 'next-intl': null, react: null, 'react-dom': null,
139
194
  });
140
195
 
141
- /** The sole lite FE workspace owns its Supabase clients directly; no shared FE package is emitted. */
196
+ /**
197
+ * The lite fe workspace: the same base without the shared packages, plus its infra adapter (owner ruling 2026-10-13: Supabase on
198
+ * the front end is the Auth client of modules/auth only) and server-only for its own next-intl request config.
199
+ */
142
200
  const LITE_FE_APP_DEPENDENCIES = Object.freeze({
143
- '@heroui/react': null, '@heroui/styles': null, '@starci/grammar': null, '@xyflow/react': null, '@supabase/ssr': null,
201
+ '@heroui/react': null, '@heroui/styles': null, '@starci/grammar': null, '@xyflow/react': null,
144
202
  '@supabase/supabase-js': null, next: null, 'next-intl': null, react: null, 'react-dom': null, 'server-only': null,
145
203
  });
146
204
 
@@ -178,18 +236,51 @@ function pinnedSection(entries, pins) {
178
236
  return Object.fromEntries(Object.entries(entries).sort(([a], [b]) => a.localeCompare(b)).map(([dependency, range]) => [dependency, pinned(dependency, range)]));
179
237
  }
180
238
 
181
- /** The root package.json of a new app, the monorepo root: name, workspaces, npm and its edition's dependencies at canon pins. */
182
- function packageManifest(name, pins, edition) {
239
+ /**
240
+ * The runtime peers (R111, knowledge/hfs/peer-integrations.yaml) a root package.json needs and does not declare in its
241
+ * dependencies: the `requires` of every pair whose `when` packages it declares (at the named major, read from the declared
242
+ * exact pin), each at its canon pin. Both editions alike: @nestjs/apollo on @nestjs/platform-express 11 needs
243
+ * @as-integrations/express5.
244
+ */
245
+ export function peerDependencies(pkg, pins, pairs = parseYaml(fs.readFileSync(PEERS_FILE, 'utf8')).pairs) {
246
+ const runtime = pkg.dependencies ?? {};
247
+ const declared = { ...pkg.devDependencies, ...runtime };
248
+ const major = version => Number(/^\D*(\d+)/.exec(String(version))?.[1]);
249
+ const needed = pairs.filter(pair => typeof runtime[pair.requires] !== 'string'
250
+ && pair.when.every(entry => typeof declared[entry.package] === 'string' && (entry.major === undefined || major(declared[entry.package]) === entry.major)));
251
+ return pinnedSection(Object.fromEntries(needed.map(pair => [pair.requires, null])), pins);
252
+ }
253
+
254
+ /**
255
+ * The fe-stack core packages one version of which the whole install resolves (npm `overrides`, at their canon pins): a
256
+ * transitive dependency otherwise brings its own (zustand 4 under @xyflow/react, dayjs under typeorm, a hoisted newer zod
257
+ * that breaks the zodResolver types), and @xyflow/react itself stays the grammar's one version.
258
+ */
259
+ const ROOT_OVERRIDES = Object.freeze(['@xyflow/react', 'dayjs', 'zod', 'zustand']);
260
+
261
+ /**
262
+ * The root package.json of a new app, the monorepo root: name, workspaces, npm, its edition's dependencies with their runtime
263
+ * peers, the fe stack's root tools and the one-version overrides, at canon pins.
264
+ */
265
+ function packageManifest(name, pins, edition, stack = coreFeStackPackages()) {
183
266
  const dependencies = edition === 'lite' ? LITE_STARTER_DEPENDENCIES : STARTER_DEPENDENCIES;
184
- return { name, version: '0.0.0', private: true, packageManager: PACKAGE_MANAGER, workspaces: [...WORKSPACES], dependencies: pinnedSection(dependencies.dependencies, pins), devDependencies: pinnedSection(dependencies.devDependencies, pins) };
267
+ const devDependencies = { ...dependencies.devDependencies, ...Object.fromEntries(stack.root.map(dependency => [dependency, null])) };
268
+ const pkg = { name, version: '0.0.0', private: true, packageManager: PACKAGE_MANAGER, workspaces: [...WORKSPACES], dependencies: pinnedSection(dependencies.dependencies, pins), devDependencies: pinnedSection(devDependencies, pins) };
269
+ pkg.dependencies = pinnedSection({ ...pkg.dependencies, ...peerDependencies(pkg, pins) }, pins);
270
+ pkg.overrides = pinnedSection(Object.fromEntries(ROOT_OVERRIDES.map(dependency => [dependency, null])), pins);
271
+ return pkg;
185
272
  }
186
273
 
187
274
  /** A dependency section of an fe workspace with `@<project>/` names filled and the canon pins taken. */
188
275
  const workspaceSection = (entries, project, pins) => pinnedSection(Object.fromEntries(Object.entries(entries).map(([name, range]) => [name.replace('@<project>/', `@${project}/`), range])), pins);
189
276
 
190
- /** The package.json of the fe app workspace `app` of the app `project`: @<project>/<app>, private, the workspace scripts, its own dependencies. */
191
- function feAppManifest(project, app, pins, edition) {
192
- const dependencies = edition === 'lite' ? LITE_FE_APP_DEPENDENCIES : FE_APP_DEPENDENCIES;
277
+ /**
278
+ * The package.json of the fe app workspace `app` of the app `project`: @<project>/<app>, private, the workspace scripts, its own
279
+ * dependencies. A product app (`core`, it carries the shared app core) also takes every core runtime package of the fe stack.
280
+ */
281
+ function feAppManifest(project, app, pins, edition, { core = true, stack = coreFeStackPackages() } = {}) {
282
+ const base = edition === 'lite' ? LITE_FE_APP_DEPENDENCIES : FE_APP_DEPENDENCIES;
283
+ const dependencies = { ...base, ...(core ? Object.fromEntries(stack.app.filter(name => !Object.hasOwn(base, name)).map(name => [name, null])) : {}) };
193
284
  return { name: feAppPackageName(project, app), version: '0.0.0', private: true, scripts: { ...FE_APP_SCRIPTS }, dependencies: workspaceSection(dependencies, project, pins) };
194
285
  }
195
286
 
@@ -253,7 +344,7 @@ function fill(source, vars, file) {
253
344
  }
254
345
 
255
346
  /** Let sync's renderer expand partials first while leaving every source placeholder for `fill` to validate or preserve. */
256
- const PARTIAL_VARIABLES = new Proxy(Object.create(null), {
347
+ export const PARTIAL_VARIABLES = new Proxy(Object.create(null), {
257
348
  get: (_target, key) => `{{${String(key)}}}`,
258
349
  getOwnPropertyDescriptor: (_target, key) => ({ configurable: true, value: `{{${String(key)}}}` }),
259
350
  });
@@ -269,10 +360,7 @@ const LITE_DEFERRED_GENERATOR_FILES = new Set([
269
360
  'src/modules/platform/database/migration-runner.service.ts',
270
361
  'src/modules/platform/database/seed-runner.service.ts',
271
362
  ]);
272
- const liteBaseFile = (scope, rel) => {
273
- if (scope === 'app') return LITE_APP_REUSED.has(rel);
274
- if (scope === 'fe') return false;
275
- return !rel.startsWith('apps/cli/')
363
+ const liteBaseSource = (rel) => !rel.startsWith('apps/cli/')
276
364
  && !rel.startsWith('src/features/cli/')
277
365
  && !rel.startsWith('src/modules/domain/note/')
278
366
  && rel !== 'src/modules/platform/database/connection-source.client.ts'
@@ -280,8 +368,23 @@ const liteBaseFile = (scope, rel) => {
280
368
  && rel !== 'src/modules/platform/database/seed-connections.client.ts'
281
369
  && rel !== 'src/modules/platform/primitives/sequence.policy.ts'
282
370
  && !LITE_DEFERRED_GENERATOR_FILES.has(rel)
283
- && !rel.startsWith('src/tests/')
284
- && !rel.endsWith('.spec.ts');
371
+ && !rel.startsWith('src/tests/');
372
+ /** The be files the lite overlay replaces: a base unit spec of such a file tests the full variant, so lite carries its own. */
373
+ const liteOverlaid = (() => {
374
+ let set = null;
375
+ return () => (set ??= new Set(listFiles(path.join(TEMPLATES_DIR, 'be', 'skeleton-lite'))));
376
+ })();
377
+ /**
378
+ * A base be file lite reuses: a source file of the shared shape, and the unit spec beside a reused source file that the lite overlay
379
+ * does not replace (owner ruling 2026-10-12: a lite back end keeps full's unit specs).
380
+ */
381
+ const liteBaseFile = (scope, rel) => {
382
+ if (scope === 'app') return LITE_APP_REUSED.has(rel);
383
+ // The shared fe app core (apps/__app__/) is one tree for both editions; the lite overlay replaces only modules/auth/client.ts.
384
+ if (scope === 'fe') return rel.startsWith(`apps/${APP_DIR}/`);
385
+ if (!rel.endsWith('.spec.ts')) return liteBaseSource(rel);
386
+ const subject = rel.replace(/\.spec\.ts$/, '.ts');
387
+ return liteBaseSource(subject) && !liteOverlaid().has(subject);
285
388
  };
286
389
  const liteOverlayFile = (scope, rel) => scope !== 'be' || !LITE_DEFERRED_GENERATOR_FILES.has(rel);
287
390
 
@@ -290,6 +393,7 @@ function skeletonDirectory(scope, directory, app, vars, include = () => true) {
290
393
  const dir = path.join(TEMPLATES_DIR, scope, directory);
291
394
  if (!fs.existsSync(dir)) throw new ScaffoldError('HFS_SCAFFOLD_SKELETON_MISSING', `no skeleton templates in templates/${scope}/${directory}`);
292
395
  const apps = scope === 'app' ? [] : app.sides[scope].apps.filter(entry => scope === 'fe' || entry.kind === 'api');
396
+ const appsOf = rel => (scope === 'fe' && rel.includes(APP_DIR) ? coreFeApps(apps) : apps);
293
397
  const prefix = scope === 'app' ? '' : `${scope}/`;
294
398
  const files = [];
295
399
  for (const rel of listFiles(dir).filter(include)) {
@@ -304,7 +408,7 @@ function skeletonDirectory(scope, directory, app, vars, include = () => true) {
304
408
  files.push({ path: `${prefix}${output(rel)}`, content: fill(source, { ...once, ...appVars }, `${directory}/${rel}`) });
305
409
  continue;
306
410
  }
307
- for (const entry of apps) files.push({ path: `${prefix}${output(rel.split(APP_DIR).join(entry.name))}`, content: fill(source, { ...once, app: entry.name, appPascal: pascal(entry.name) }, `${directory}/${rel}`) });
411
+ for (const entry of appsOf(rel)) files.push({ path: `${prefix}${output(rel.split(APP_DIR).join(entry.name))}`, content: fill(source, { ...once, app: entry.name, appPascal: pascal(entry.name) }, `${directory}/${rel}`) });
308
412
  }
309
413
  return files;
310
414
  }
@@ -317,7 +421,7 @@ export function scaffoldWorkSeeds(project) {
317
421
 
318
422
  /**
319
423
  * The skeleton files of one scope (app root, be, fe): [{ path, content }], app-relative; a `__app__` file once per api app of the
320
- * be side (once per app of the fe side), the be `apps/cli/` folder once when the cli app is declared, an fe `apps/<name>/` folder
424
+ * be side (once per product app of the fe side, coreFeApps), the be `apps/cli/` folder once when the cli app is declared, an fe `apps/<name>/` folder
321
425
  * once for the declared fe app <name> (none other), `__project__` in a path replaced by the project name.
322
426
  */
323
427
  function skeletonOf(scope, app, vars) {
@@ -465,7 +569,7 @@ export function scaffoldApp({ name, into, presets, edition = 'full', manifest =
465
569
  path: `fe/apps/${entry.name}/src/modules/types/ambient/styles.d.ts`,
466
570
  content: '/** Declares stylesheets loaded by the Next bundler; non-CSS side-effect imports remain checked. */\ndeclare module "*.css" {}\n',
467
571
  })),
468
- ...app.sides.fe.apps.map(entry => ({ path: `fe/apps/${entry.name}/package.json`, content: packageJsonText(feAppManifest(name, entry.name, pins, app.edition)) })),
572
+ ...app.sides.fe.apps.map(entry => ({ path: `fe/apps/${entry.name}/package.json`, content: packageJsonText(feAppManifest(name, entry.name, pins, app.edition, { core: coreFeApps([entry]).length === 1 })) })),
469
573
  ...imageFiles(app),
470
574
  ...(app.edition === 'lite' ? [] : Object.keys(FE_PACKAGES).flatMap(pkg => [
471
575
  { path: `fe/packages/${name}-${pkg}/package.json`, content: packageJsonText(fePackageManifest(name, pkg, pins)) },
@@ -7,12 +7,31 @@
7
7
 
8
8
  /** The refusal of a verb a non-full edition does not carry: printed verbatim by the CLI, exit 2, nothing written. */
9
9
  export class EditionRefusal extends Error {
10
- constructor(command) {
11
- super(`${command}: full edition only; run starci app upgrade --edition full`);
10
+ constructor(command, message = `${command}: full edition only; run starci app upgrade --edition full`) {
11
+ super(message);
12
12
  this.name = 'EditionRefusal';
13
13
  }
14
14
  }
15
15
 
16
+ /**
17
+ * The refusal of a pattern noun in a lite app whose topic carries a `liteAdapter` (owner ruling 2026-10-12): lite HAS the pattern,
18
+ * but the package's templates are the full adapter (BullMQ, Kafka, sockets, TypeORM entities, e2e proofs), so generating them would
19
+ * write what lite forbids. The pattern is written by hand on its Supabase adapter until the package carries a lite template.
20
+ */
21
+ export class LiteAdapterRefusal extends EditionRefusal {
22
+ constructor(command, topics) {
23
+ super(command, `${command}: the package carries only the full adapter of ${topics.join(', ')}; in a lite app write it on its Supabase adapter (knowledge/patterns/be/${topics[0]}.yaml liteAdapter, knowledge/patterns/be/supabase-queues.yaml)`);
24
+ this.name = 'LiteAdapterRefusal';
25
+ }
26
+ }
27
+
28
+ /** Refuses `command` in a lite app when one of `topics` (pattern topic ids with their parsed documents) carries a `liteAdapter`. */
29
+ export function refuseFullAdapterInLite({ edition, topics, command }) {
30
+ if (edition !== 'lite') return;
31
+ const adapted = topics.filter(({ doc }) => doc?.liteAdapter !== undefined).map(({ id }) => id).sort();
32
+ if (adapted.length) throw new LiteAdapterRefusal(command, adapted);
33
+ }
34
+
16
35
  /**
17
36
  * Refuses `command` (`add <noun>`, `new <noun>`) when one of `slotIds` - the slots the verb writes into - does not
18
37
  * exist for the edition of `resolver`'s repository or is forbidden under it. `resolver` is the
@@ -43,9 +43,10 @@ const REGISTRATIONS = Object.freeze({
43
43
  beforeSource: "./errors/database.error",
44
44
  },
45
45
  {
46
+ // PRIMARY_ENTITY_MANAGER: the token the inbox service's unit spec provides its EntityManager double under.
46
47
  file: "be/src/modules/platform/database/index.ts",
47
48
  source: "./primary.decorators",
48
- names: ["InjectPrimaryEntityManager"],
49
+ names: ["InjectPrimaryEntityManager", "PRIMARY_ENTITY_MANAGER"],
49
50
  },
50
51
  {
51
52
  file: "be/src/modules/platform/http-security/index.ts",
@@ -90,6 +91,31 @@ const REGISTRATIONS = Object.freeze({
90
91
  names: ["WebhookSignatureService"],
91
92
  },
92
93
  ],
94
+ // The GraphQL server capability (be/src/modules/platform/graphql, written by the first GraphQL feature): the errors service and
95
+ // the request locale it formats and localizes errors with.
96
+ graphql: [
97
+ {
98
+ file: "be/src/modules/platform/errors/index.ts",
99
+ source: "./errors.decorators",
100
+ names: ["ERRORS_SERVICE"],
101
+ },
102
+ {
103
+ file: "be/src/modules/platform/errors/index.ts",
104
+ source: "./graphql-error.mapper",
105
+ names: ["formatGraphqlError"],
106
+ },
107
+ {
108
+ file: "be/src/modules/platform/errors/index.ts",
109
+ source: "./errors.service",
110
+ names: ["ErrorsService"],
111
+ type: true,
112
+ },
113
+ {
114
+ file: "be/src/modules/platform/i18n/index.ts",
115
+ source: "./i18n.decorators",
116
+ names: ["REQUEST_LOCALE"],
117
+ },
118
+ ],
93
119
  cli: [
94
120
  {
95
121
  file: "be/src/modules/platform/database/index.ts",
@@ -138,6 +164,9 @@ function addExport(text, registration) {
138
164
  return `${text.replace(/\n+$/, "")}\n${added}\n`
139
165
  }
140
166
 
167
+ /** The app-relative barrels one generator's registrations edit (a caller snapshots them before registerLiteExports). */
168
+ export const registrationFiles = (generator) => [...new Set((REGISTRATIONS[generator] ?? []).map((registration) => registration.file))]
169
+
141
170
  /** Adds only the barrel exports first consumed by one generator; full webhooks share the same optional platform API. */
142
171
  export function registerLiteExports({ root, generator }) {
143
172
  const registrations = REGISTRATIONS[generator]
@@ -15,8 +15,7 @@ import fs from 'node:fs';
15
15
  import path from 'node:path';
16
16
  import { createRequire } from 'node:module';
17
17
  import { explainPath } from '../runtime/scripts/hfs/check.mjs';
18
- import { createSlotResolver, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
19
- import { refuseInEdition } from './edition-gate.mjs';
18
+ import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
20
19
  import { byCodeUnit } from '../report/order.mjs';
21
20
 
22
21
  export class ScaffoldError extends Error {
@@ -320,9 +319,8 @@ const writeNew = (repoRoot, relative, text) => {
320
319
 
321
320
  /** `starci app new spec be/<file>.service.ts` at the app root: writes the spec skeleton of an existing service; returns the created app-relative paths. */
322
321
  export function newSpec({ repoRoot: appRoot, file, ts, manifest = loadSlotManifest() }) {
323
- const { repo, beRoot: repoRoot } = backEndOf(appRoot, manifest);
324
- // `new spec` writes a unit spec: the test world it belongs to (be.tests.*) is a full-edition capability.
325
- refuseInEdition({ resolver: createSlotResolver(manifest, repo), slotIds: ['be.tests.world'], command: 'new spec' });
322
+ // A unit spec belongs to both editions (owner ruling 2026-10-12): a lite back end keeps full's unit specs.
323
+ const { beRoot: repoRoot } = backEndOf(appRoot, manifest);
326
324
  const relative = belowBackEnd(file);
327
325
  if (!relative.endsWith('.service.ts')) throw new ScaffoldError('HFS_NEW_NOT_A_SERVICE', `${relative} is not a *.service.ts file: only services have a unit spec`);
328
326
  const absolute = path.join(repoRoot, relative);
@@ -335,9 +333,8 @@ export function newSpec({ repoRoot: appRoot, file, ts, manifest = loadSlotManife
335
333
 
336
334
  /** `starci app new service be/<dir> <name> [--inject ...]` at the app root: writes the service and its spec skeleton; returns the created app-relative paths. */
337
335
  export function newService({ repoRoot: appRoot, dir, name, inject = [], ts, manifest = loadSlotManifest() }) {
338
- const { repo, beRoot: repoRoot } = backEndOf(appRoot, manifest);
339
- // `new service` writes the service and its unit spec: the test world (be.tests.*) is a full-edition capability.
340
- refuseInEdition({ resolver: createSlotResolver(manifest, repo), slotIds: ['be.tests.world'], command: 'new service' });
336
+ // The service and its unit spec belong to both editions (owner ruling 2026-10-12).
337
+ const { beRoot: repoRoot } = backEndOf(appRoot, manifest);
341
338
  if (!SERVICE_NAME.test(name)) throw new ScaffoldError('HFS_NEW_NAME_INVALID', `the service name ${name} must be kebab-case (member-profile), without the .service suffix`);
342
339
  const relative = path.posix.join(belowBackEnd(dir), `${name}.service.ts`);
343
340
  requireSlot(repoRoot, relative);
package/src/main.mjs CHANGED
@@ -33,7 +33,7 @@
33
33
  // a back-end `<name>.service.ts` and its `<name>.service.spec.ts` skeleton (scaffold/service.mjs; <dir> is
34
34
  // relative to be/): the spec is built
35
35
  // with Test.createTestingModule, one provider per constructor dependency (kit doubles from @starci/jest-preset),
36
- // one placeholder it per public method. Never overwrites a file. In a lite app `new service` and `new spec` refuse (full edition only: the unit spec they write has no test world).
36
+ // one placeholder it per public method. Never overwrites a file. Both editions: a lite back end keeps full's unit specs (owner ruling 2026-10-12).
37
37
  // starci app new image [--cwd <dir>] the Dockerfile of every declared app that has none, from the image canon (scaffold/image.mjs); never overwrites
38
38
  // starci app new spec <file>.service.ts [--cwd <dir>] the spec skeleton of an existing service, read from its constructor with the repository's TypeScript
39
39
  // starci app secret list | show <slug> | set <slug> | gen <slug> [--key NAME] [--env NAME] [--age RECIPIENT] [--bytes N] [--cwd DIR]
@@ -309,7 +309,7 @@ export async function main(argv, {
309
309
  const [name, ...extra] = opts.positional;
310
310
  if (!name || extra.length) throw new Error('starci app scaffold takes `<name> [--into <dir>] [--edition full|lite]`');
311
311
  const edition = global.edition ?? 'full';
312
- const { root: created, files } = scaffoldApp({ name, into: path.resolve(global.cwd, opts.into ?? '.'), edition, presets: presets ?? (edition === 'lite' ? undefined : await scaffoldPresets()), ...(lock ? { lock } : {}) });
312
+ const { root: created, files } = scaffoldApp({ name, into: path.resolve(global.cwd, opts.into ?? '.'), edition, presets: presets ?? await scaffoldPresets(), ...(lock ? { lock } : {}) });
313
313
  out(`starci app scaffold: created ${created} (${files.length} files); next: npm ci, then starci app lint\n`);
314
314
  return 0;
315
315
  }
package/sync/index.mjs CHANGED
@@ -106,18 +106,20 @@ function readBundled(name) {
106
106
  return lf(fs.readFileSync(path.join(TEMPLATES_DIR, name), 'utf8'));
107
107
  }
108
108
 
109
- /** Fill `{{name}}` from vars and expand `{{> partial}}` lines. An unknown name is an error, never an empty string. */
109
+ /**
110
+ * Fill `{{name}}` from vars and expand `{{> partial}}` lines. A partial line may be written as a line comment (`// {{> partial}}`)
111
+ * so a JavaScript template stays valid source before rendering. An unknown name is an error, never an empty string.
112
+ */
110
113
  export function render(text, vars, readTemplate = readBundled) {
111
- const withPartials = text.replace(/^\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => render(readTemplate(name), vars, readTemplate).replace(/\n*$/, '\n'));
114
+ const withPartials = text.replace(/^(?:\/\/ )?\{\{> ([\w./-]+)\}\}\n/gm, (_, name) => render(readTemplate(name), vars, readTemplate).replace(/\n*$/, '\n'));
112
115
  return withPartials.replace(/\{\{([A-Za-z]\w*)\}\}/g, (_, key) => {
113
116
  if (!Object.hasOwn(vars, key)) throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `template names {{${key}}}, which sync does not provide`);
114
117
  return vars[key];
115
118
  });
116
119
  }
117
120
 
118
- /** What the app's sync reads from the jest preset it installs for its be side: the Sonar exclusions ({ sonarExclusions }); a lite app installs none. */
119
- export async function loadPresets(root, edition = 'full') {
120
- if (edition === 'lite') return {};
121
+ /** What the app's sync reads from the jest preset it installs for its be side: the Sonar exclusions ({ sonarExclusions }); both editions install it (a lite back end keeps full's unit run, owner ruling 2026-10-12). */
122
+ export async function loadPresets(root) {
121
123
  const name = '@starci/jest-preset';
122
124
  const require = createRequire(path.join(root, 'package.json'));
123
125
  let resolved;
@@ -190,7 +192,8 @@ export function appScripts(app) {
190
192
  const watch = entry => `cd be && ts-node-dev --respawn -r tsconfig-paths/register apps/${entry.name}/src/main.ts`;
191
193
  const built = entry => `node be/dist/apps/${entry.name}/src/main.js`;
192
194
  const workspace = entry => feAppPackageName(app.project, entry.name);
193
- const dev = entry => `${app.edition === 'lite' ? '' : 'npm run codegen --silent && '}turbo run dev --filter=${workspace(entry)}`;
195
+ // Both editions generate before they run (owner ruling 2026-10-13): the fe's GraphQL wire types, and under lite the Supabase types check.
196
+ const dev = entry => `npm run codegen --silent && turbo run dev --filter=${workspace(entry)}`;
194
197
  const images = imageEntries(app);
195
198
  const imageCommand = ({ side, entry }) => `docker build -f ${dockerfilePath(side, entry.name)} -t ${app.project}/${entry.name}:dev .`;
196
199
  return [
@@ -264,19 +267,20 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
264
267
  appBinary: APP_BINARY,
265
268
  appScripts: appScripts(app),
266
269
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
267
- buildFe: `${lite ? '' : 'npm run codegen --silent && '}turbo run build --filter=./fe/apps/*`,
270
+ buildFe: 'npm run codegen --silent && turbo run build --filter=./fe/apps/*',
268
271
  // The be side with its tsc; every fe workspace with its own typecheck script through turbo (the packages are built first: ^build).
269
- typecheck: `${lite ? '' : 'npm run codegen --silent && '}tsc -p be/tsconfig.json && turbo run typecheck`,
270
- lintPrefix: lite ? '' : 'npm run codegen --silent && ',
272
+ typecheck: 'npm run codegen --silent && tsc -p be/tsconfig.json && turbo run typecheck',
273
+ lintPrefix: 'npm run codegen --silent && ',
271
274
  nodeMajor: String(NODE_MAJOR),
272
275
  sonarKey: sonarKey ?? app.project,
273
- sonarExclusions: (lite ? ['**/.next/**', '**/node_modules/**', '**/src/messages/**', 'supabase/types/**'] : [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**']).filter(Boolean).join(','),
276
+ sonarExclusions: [presets?.sonarExclusions, '**/.next/**', '**/node_modules/**', '**/src/messages/**', ...(lite ? ['supabase/types/**'] : [])].filter(Boolean).join(','),
274
277
  sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
275
278
  lcovReport: LCOV_REPORT,
276
- jestCoverage: !lite && scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
277
- coverageExclusions: !lite && scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
278
- codecovPaths: !lite && scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
279
- codecovComponents: !lite && scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
279
+ // The coverage scope is the same in both editions: lite measures exactly what full measures (owner ruling 2026-10-12).
280
+ jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
281
+ coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
282
+ codecovPaths: scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
283
+ codecovComponents: scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
280
284
  tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])].join(','),
281
285
  styleGlob: STYLE_GLOB,
282
286
  imageMatrix: imageMatrix(app),
@@ -400,7 +404,7 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
400
404
  export async function renderRepo(root, { presets, parseYaml, manifest = loadSlotManifest() } = {}) {
401
405
  const hfs = loadHfs(root, manifest);
402
406
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: message => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
403
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root, hfs.edition), { sonarKey, manifest, source: appSource(root) }) };
407
+ return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root) }) };
404
408
  }
405
409
 
406
410
  /** `starci app sync --check | --write [--cwd <dir>]` at the app root; returns 0 clean, 1 drift, 2 usage. */
@@ -17,6 +17,10 @@ concurrency:
17
17
  jobs:
18
18
  ci:
19
19
  runs-on: ubuntu-latest
20
+ # id-token: write lets the Codecov upload authenticate with GitHub's OIDC token: no repository secret, no silent skip.
21
+ permissions:
22
+ contents: read
23
+ id-token: write
20
24
  env:
21
25
  SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
22
26
  steps:
@@ -36,6 +40,16 @@ jobs:
36
40
  run: npm run format:check
37
41
  - name: typecheck
38
42
  run: npm run typecheck
43
+ - name: unit
44
+ run: npm test -- --ci
45
+ - name: coverage upload
46
+ uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5
47
+ if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}
48
+ with:
49
+ use_oidc: true
50
+ files: {{lcovReport}}
51
+ disable_search: true
52
+ fail_ci_if_error: true
39
53
  - name: build be
40
54
  run: npm run build:be
41
55
  - name: build fe
@@ -0,0 +1,34 @@
1
+ // GraphQL wire types, the same in both editions (owner ruling 2026-10-13): every fe app with operations under
2
+ // src/modules/api/graphql/{queries,mutations} gets its typed documents generated from the contract snapshots the back end emits
3
+ // (be/contracts/<app>/schema.graphql, `npm run contract:emit`) into the gitignored src/modules/api/graphql/__generated__/.
4
+ // Nothing to generate (no snapshot yet, or no fe app with a modules/api/graphql folder) is not an error.
5
+ const CONTRACTS = path.join("be", "contracts")
6
+ const FE_APPS = path.join("fe", "apps")
7
+ const posix = (file) => file.split(path.sep).join("/")
8
+ const schemas = (fs.existsSync(CONTRACTS) ? fs.readdirSync(CONTRACTS) : [])
9
+ .map((app) => path.join(CONTRACTS, app, "schema.graphql"))
10
+ .filter((file) => fs.existsSync(file))
11
+ .sort()
12
+ .map(posix)
13
+ const operationRoots = (fs.existsSync(FE_APPS) ? fs.readdirSync(FE_APPS) : [])
14
+ .map((app) => path.join(FE_APPS, app, "src", "modules", "api", "graphql"))
15
+ .filter((dir) => fs.existsSync(dir))
16
+ .sort()
17
+ .map(posix)
18
+ if (schemas.length === 0 || operationRoots.length === 0) {
19
+ process.stdout.write("codegen: no GraphQL contract under be/contracts yet\n")
20
+ } else {
21
+ const { generate } = await import("@graphql-codegen/cli")
22
+ const generates = Object.fromEntries(
23
+ operationRoots.map((root) => [
24
+ `${root}/__generated__/`,
25
+ {
26
+ preset: "client",
27
+ documents: [`${root}/queries/*.ts`, `${root}/mutations/*.ts`],
28
+ presetConfig: { fragmentMasking: false },
29
+ config: { useTypeImports: true, enumsAsTypes: true, avoidOptionals: true },
30
+ },
31
+ ]),
32
+ )
33
+ await generate({ schema: schemas, generates, ignoreNoDocuments: true, silent: true }, true)
34
+ }
@@ -2,6 +2,7 @@
2
2
  // Database types hook, offline: the app compiles against the committed supabase/types/database.types.ts, which `npm run contract:emit`
3
3
  // regenerates from the local stack and stamps with the digest of the migrations it was generated from. This refuses a missing or
4
4
  // stale file, so lint, typecheck, dev and build never run against types older than the migrations; it starts nothing and needs no stack.
5
+ // `--graphql` runs only the GraphQL wire types below (`npm run codegen:graphql`).
5
6
  import { createHash } from "node:crypto"
6
7
  import fs from "node:fs"
7
8
  import path from "node:path"
@@ -15,18 +16,22 @@ const fail = (message) => {
15
16
  process.exit(1)
16
17
  }
17
18
 
18
- const hash = createHash("sha256")
19
- for (const name of (fs.existsSync(MIGRATIONS) ? fs.readdirSync(MIGRATIONS) : [])
20
- .filter((file) => file.endsWith(".sql"))
21
- .sort()) {
22
- hash.update(`${name}\0${fs.readFileSync(path.join(MIGRATIONS, name), "utf8").replace(/\r\n/g, "\n")}\0`)
19
+ if (!process.argv.includes("--graphql")) {
20
+ const hash = createHash("sha256")
21
+ for (const name of (fs.existsSync(MIGRATIONS) ? fs.readdirSync(MIGRATIONS) : [])
22
+ .filter((file) => file.endsWith(".sql"))
23
+ .sort()) {
24
+ hash.update(`${name}\0${fs.readFileSync(path.join(MIGRATIONS, name), "utf8").replace(/\r\n/g, "\n")}\0`)
25
+ }
26
+ const digest = hash.digest("hex")
27
+
28
+ if (!fs.existsSync(TYPES))
29
+ fail(`${TYPES} is missing; start the local stack and run \`npm run contract:emit\`, then commit it`)
30
+ const stamped = STAMP.exec(fs.readFileSync(TYPES, "utf8").split(/\r?\n/, 1)[0])?.[1]
31
+ if (stamped !== digest)
32
+ fail(
33
+ `${TYPES} is ${stamped === undefined ? "not stamped" : "older than the migrations"}; run \`npm run contract:emit\` and commit the regenerated types`,
34
+ )
23
35
  }
24
- const digest = hash.digest("hex")
25
36
 
26
- if (!fs.existsSync(TYPES))
27
- fail(`${TYPES} is missing; start the local stack and run \`npm run contract:emit\`, then commit it`)
28
- const stamped = STAMP.exec(fs.readFileSync(TYPES, "utf8").split(/\r?\n/, 1)[0])?.[1]
29
- if (stamped !== digest)
30
- fail(
31
- `${TYPES} is ${stamped === undefined ? "not stamped" : "older than the migrations"}; run \`npm run contract:emit\` and commit the regenerated types`,
32
- )
37
+ // {{> app/codegen-graphql.mjs.partial}}