@starci/hfs 5.1.0 → 5.2.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 (199) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +10 -1
  3. package/db/postgres.mjs +9 -1
  4. package/package.json +1 -1
  5. package/runtime/knowledge/hfs/canon-pins.yaml +30 -9
  6. package/runtime/knowledge/hfs/fe-stack.yaml +7 -7
  7. package/runtime/knowledge/hfs/fork-images.yaml +7 -0
  8. package/runtime/knowledge/hfs/rules.yaml +55 -7
  9. package/runtime/knowledge/hfs/slots.yaml +75 -24
  10. package/runtime/modules/kernel/failure-codes.yaml +62 -0
  11. package/runtime/modules/schemas/application-stacks.schema.yaml +6 -0
  12. package/runtime/scripts/hfs/architecture/identity.mjs +18 -83
  13. package/runtime/scripts/hfs/architecture/transport-owner.d.mts +1 -2
  14. package/runtime/scripts/hfs/architecture/transport-owner.mjs +3 -6
  15. package/runtime/scripts/hfs/manifest-shape.d.mts +1 -0
  16. package/runtime/scripts/hfs/manifest-shape.mjs +3 -1
  17. package/runtime/scripts/hfs/rules/a15-findings.mjs +5 -0
  18. package/runtime/scripts/hfs/rules/stacks-local.d.mts +7 -0
  19. package/runtime/scripts/hfs/rules/stacks-local.mjs +71 -0
  20. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +6 -1
  21. package/runtime/scripts/hfs/rules/stacks-service-images.d.mts +12 -0
  22. package/runtime/scripts/hfs/rules/stacks-service-images.mjs +120 -0
  23. package/runtime/scripts/hfs/secret-dev-init.d.mts +11 -0
  24. package/runtime/scripts/hfs/secret-dev-init.mjs +79 -0
  25. package/runtime/scripts/hfs/secret.mjs +10 -7
  26. package/runtime/scripts/hfs/slots.mjs +1 -1
  27. package/runtime/scripts/hfs/stack-local-config.d.mts +101 -0
  28. package/runtime/scripts/hfs/stack-local-config.mjs +320 -0
  29. package/runtime/scripts/hfs/stacks-gate.mjs +1 -1
  30. package/runtime/scripts/lib/swarm-stack.d.mts +2 -1
  31. package/runtime/scripts/lib/swarm-stack.mjs +2 -2
  32. package/scaffold/add-module.d.mts +16 -0
  33. package/scaffold/add-module.mjs +232 -0
  34. package/scaffold/add-queue-lite.mjs +49 -0
  35. package/scaffold/add.mjs +5 -0
  36. package/scaffold/app.mjs +7 -7
  37. package/scaffold/module-registry.d.mts +19 -0
  38. package/scaffold/module-registry.mjs +84 -0
  39. package/src/main.mjs +23 -2
  40. package/stack/cli.mjs +6 -1
  41. package/stack/context.d.mts +15 -0
  42. package/stack/context.mjs +1 -0
  43. package/stack/local-cli.d.mts +25 -0
  44. package/stack/local-cli.mjs +89 -0
  45. package/stack/local-config.d.mts +17 -0
  46. package/stack/local-config.mjs +37 -0
  47. package/stack/local-custody.d.mts +24 -0
  48. package/stack/local-custody.mjs +109 -0
  49. package/stack/local-doctor.d.mts +6 -0
  50. package/stack/local-doctor.mjs +49 -0
  51. package/stack/local-down.d.mts +8 -0
  52. package/stack/local-down.mjs +20 -0
  53. package/stack/local-host.d.mts +42 -0
  54. package/stack/local-host.mjs +163 -0
  55. package/stack/local-logs.d.mts +8 -0
  56. package/stack/local-logs.mjs +32 -0
  57. package/stack/local-ports.d.mts +7 -0
  58. package/stack/local-ports.mjs +39 -0
  59. package/stack/local-prepare.d.mts +22 -0
  60. package/stack/local-prepare.mjs +62 -0
  61. package/stack/local-run.d.mts +71 -0
  62. package/stack/local-run.mjs +200 -0
  63. package/stack/local-state.d.mts +35 -0
  64. package/stack/local-state.mjs +29 -0
  65. package/stack/local-status.d.mts +6 -0
  66. package/stack/local-status.mjs +36 -0
  67. package/stack/local-up.d.mts +11 -0
  68. package/stack/local-up.mjs +166 -0
  69. package/stack/local-verify.d.mts +6 -0
  70. package/stack/local-verify.mjs +21 -0
  71. package/sync/index.d.mts +13 -12
  72. package/sync/index.mjs +83 -28
  73. package/templates/app/module/jest.config.js.tpl +8 -0
  74. package/templates/app/module/src/index.ts.tpl +2 -0
  75. package/templates/app/module/src/manifest/declaration.spec.ts.tpl +11 -0
  76. package/templates/app/module/src/manifest/declaration.ts.tpl +13 -0
  77. package/templates/app/module/src/manifest/index.ts.tpl +1 -0
  78. package/templates/app/module/src/public/index.ts.tpl +2 -0
  79. package/templates/app/module/src/template/__name__.template.spec.ts.tpl +13 -0
  80. package/templates/app/module/src/template/__name__.template.ts.tpl +41 -0
  81. package/templates/app/module/src/template/index.ts.tpl +1 -0
  82. package/templates/app/module/src/workbench/components/__pascal__WorkbenchView.tsx.tpl +16 -0
  83. package/templates/app/module/src/workbench/index.ts.tpl +2 -0
  84. package/templates/app/package-scripts/package.json +1 -1
  85. package/templates/app/package-scripts-lite/package.json +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +76 -70
  87. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +23 -13
  88. package/templates/app/skeleton-lite/.starcistacks/dev/infra/compose/compose.yaml +25 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/runtime/env/KEYS.md +18 -26
  90. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +1 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/runtime/env/KEYS.md +2 -1
  92. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +57 -14
  93. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +11 -0
  94. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.handler.ts +23 -0
  95. package/templates/be/auth-lite/src/features/api/auth/application/get-jwks.query.ts +12 -0
  96. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.command.ts +11 -0
  97. package/templates/be/auth-lite/src/features/api/auth/application/google-begin.handler.ts +22 -0
  98. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.command.ts +12 -0
  99. package/templates/be/auth-lite/src/features/api/auth/application/google-complete.handler.ts +23 -0
  100. package/templates/be/auth-lite/src/features/api/auth/application/login.command.ts +12 -0
  101. package/templates/be/auth-lite/src/features/api/auth/application/login.handler.ts +23 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/logout.command.ts +11 -0
  103. package/templates/be/auth-lite/src/features/api/auth/application/logout.handler.ts +22 -0
  104. package/templates/be/auth-lite/src/features/api/auth/application/refresh.command.ts +12 -0
  105. package/templates/be/auth-lite/src/features/api/auth/application/refresh.handler.ts +23 -0
  106. package/templates/be/auth-lite/src/features/api/auth/application/session.command.ts +12 -0
  107. package/templates/be/auth-lite/src/features/api/auth/application/session.handler.ts +23 -0
  108. package/templates/be/auth-lite/src/features/api/auth/auth.module-definition.ts +13 -0
  109. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +16 -0
  110. package/templates/be/auth-lite/src/features/api/auth/auth.options.ts +3 -0
  111. package/templates/be/auth-lite/src/features/api/auth/index.ts +2 -0
  112. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module-definition.ts +13 -0
  113. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.module.ts +9 -0
  114. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth-http.options.ts +3 -0
  115. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +90 -0
  116. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/google-start.request.ts +17 -0
  117. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/login.request.ts +16 -0
  118. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +46 -0
  119. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +28 -0
  120. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +145 -0
  121. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +95 -0
  122. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-identity.entity.ts +38 -0
  123. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/auth-user.entity.ts +54 -0
  124. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-attempt.entity.ts +18 -0
  125. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +34 -0
  126. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-session.entity.ts +30 -0
  127. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +17 -0
  128. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +86 -0
  129. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +45 -0
  130. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +97 -0
  131. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +81 -0
  132. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +78 -0
  133. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +87 -0
  134. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +44 -0
  135. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -5
  136. package/templates/be/skeleton-lite/apps/api/src/compose/api.ts +3 -2
  137. package/templates/be/skeleton-lite/apps/api/src/compose/integrations.ts +10 -5
  138. package/templates/be/skeleton-lite/src/modules/domain/identity/errors/identity.error.ts +34 -0
  139. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.decorators.ts +22 -1
  140. package/templates/be/skeleton-lite/src/modules/domain/identity/identity.module.ts +47 -2
  141. package/templates/be/skeleton-lite/src/modules/domain/identity/index.ts +2 -0
  142. package/templates/be/skeleton-lite/src/modules/domain/identity/messages/identity.messages.ts +24 -0
  143. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/index.ts +3 -3
  144. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.config.ts +3 -1
  145. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.decorators.ts +8 -4
  146. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module-definition.ts +12 -0
  147. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.module.ts +29 -0
  148. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.options.ts +6 -31
  149. package/templates/be/skeleton-lite/src/modules/integrations/passport/errors/passport.error.ts +18 -9
  150. package/templates/be/skeleton-lite/src/modules/integrations/passport/index.ts +35 -11
  151. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.spec.ts +44 -8
  152. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.config.ts +40 -18
  153. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.decorators.ts +18 -6
  154. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.spec.ts +73 -0
  155. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.kit.ts +26 -0
  156. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.options.ts +24 -53
  157. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.registration.ts +31 -0
  158. package/templates/be/skeleton-lite/src/modules/platform/cqrs/index.ts +1 -1
  159. package/templates/be/skeleton-lite/src/modules/platform/database/entities.ts +2 -1
  160. package/templates/be/skeleton-lite/src/modules/platform/http-security/index.ts +1 -1
  161. package/templates/be/skeleton-lite/src/tests/fixtures/builders/api.builder.ts +11 -4
  162. package/templates/be/test-world/e2e/serves-once-migrated.e2e-spec.ts.tpl +9 -9
  163. package/templates/be/test-world/fixtures/migrations.sql.ts.tpl +4 -4
  164. package/templates/fe/auth-lite/apps/web/src/modules/auth/client.ts +26 -0
  165. package/templates/fe/common/site-shell.tsx.partial +3 -3
  166. package/templates/fe/skeleton/apps/__app__/src/components/providers/AppProviders/index.tsx +33 -10
  167. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/index.ts +1 -0
  168. package/templates/fe/skeleton/apps/__app__/src/hooks/auth/useViewerId.ts +11 -0
  169. package/templates/fe/skeleton/apps/__app__/src/modules/toast/toast.tsx +15 -12
  170. package/templates/fe/skeleton/apps/__app__/src/modules/viewer-cache/index.ts +5 -0
  171. package/templates/fe/skeleton/apps/__app__/src/redux/slices/session.ts +1 -1
  172. package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -1
  173. package/templates/fe/skeleton/packages/__project__-ui/src/composites/NotFoundNotice/index.tsx +4 -7
  174. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/index.tsx +2 -3
  175. package/templates/fe/skeleton-lite/apps/web/src/components/composites/NotFoundNotice/index.tsx +4 -7
  176. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/index.tsx +1 -2
  177. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/Caddyfile +0 -16
  178. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/owner-actions.md +0 -11
  179. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/recovery/restore-proof.md +0 -7
  180. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/current.yaml +0 -6
  181. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/images.lock.yaml +0 -8
  182. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/releases/previous.yaml +0 -6
  183. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -186
  184. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.spec.ts +0 -22
  185. package/templates/be/skeleton-lite/src/modules/integrations/object-storage/object-storage.client.ts +0 -25
  186. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.spec.ts +0 -104
  187. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.cookie.ts +0 -34
  188. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.spec.ts +0 -28
  189. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.password.ts +0 -11
  190. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.spec.ts +0 -88
  191. package/templates/be/skeleton-lite/src/modules/integrations/passport/passport.tokens.ts +0 -69
  192. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/google.strategy.ts +0 -41
  193. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/local.strategy.ts +0 -40
  194. package/templates/be/skeleton-lite/src/modules/integrations/passport/strategies/oauth-state.store.ts +0 -63
  195. package/templates/fe/skeleton/packages/__project__-ui/src/frames/SiteShell/classNames.ts +0 -4
  196. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/auth-door.ts +0 -30
  197. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/client.ts +0 -50
  198. package/templates/fe/skeleton-lite/apps/__app__/src/modules/auth/token-store.ts +0 -31
  199. package/templates/fe/skeleton-lite/apps/web/src/components/frames/SiteShell/classNames.ts +0 -4
@@ -0,0 +1,166 @@
1
+ // local-up.mjs - `starci app stack up --env <dev env> [--execute]`: the one command that brings the whole dev environment up (PROJECT-STACKS-6).
2
+ // Order: host prerequisites, dev custody, setup (install, codegen), compose services and their health, the environment's own database role,
3
+ // migrations with a receipt, seed, native app processes, readiness, origins. Idempotent: what runs is kept, what changed is converged.
4
+ // A dry run prints the plan. It never generates a replacement secret, never removes a volume and touches only its own compose project.
5
+ import crypto from 'node:crypto';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { firstLine } from './context.mjs';
9
+ import { failure, refusal } from './errors.mjs';
10
+ import { missingKeys, missingStep, openCustody } from './local-custody.mjs';
11
+ import { hostRefusal, preflight, runSetup } from './local-prepare.mjs';
12
+ import { DEFAULT_TIMEOUT_SECONDS, makeDataDirectories, commandDigest, compose, composeValues, databaseUrl, isAlive, probe, runCommand, runEnvironment, serviceReady, startProcess, stateOf, waitFor } from './local-run.mjs';
13
+ import { readRecord, writeRecord } from './local-state.mjs';
14
+ const SAFE_PASSWORD = /^[A-Za-z0-9_-]{8,}$/u;
15
+ /** The digest of the migration folder (names and contents): what a migrate receipt records. */
16
+ export function migrationsDigest(repoRoot) {
17
+ const directory = path.join(repoRoot, 'db', 'migrations');
18
+ const hash = crypto.createHash('sha256');
19
+ let names = [];
20
+ try {
21
+ names = fs.readdirSync(directory).filter((name) => name.endsWith('.sql')).sort();
22
+ }
23
+ catch {
24
+ return 'none';
25
+ }
26
+ for (const name of names)
27
+ hash.update(name).update(fs.readFileSync(path.join(directory, name)));
28
+ return hash.digest('hex').slice(0, 16);
29
+ }
30
+ /** Creates the environment's own login role and database if absent and sets its password (psql in the db container, SQL on stdin, never argv). */
31
+ async function provisionDatabase(ctx, local, custody) {
32
+ const database = local.database;
33
+ if (!database)
34
+ return 'database: none declared';
35
+ const password = custody.values.get(database.password.key) ?? '';
36
+ if (!SAFE_PASSWORD.test(password))
37
+ throw refusal('STACK_LOCAL_DB_PASSWORD', `${database.password.key} must be at least 8 characters of letters, digits, dash and underscore (the generated password kind is); the owner replaces it with secret unset and secret init`);
38
+ const sql = [
39
+ `DO $do$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${database.role}') THEN CREATE ROLE "${database.role}" LOGIN SUPERUSER PASSWORD '${password}'; ELSE ALTER ROLE "${database.role}" LOGIN SUPERUSER PASSWORD '${password}'; END IF; END $do$;`,
40
+ `SELECT 'CREATE DATABASE "${database.name}" OWNER "${database.role}"' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '${database.name}')\\gexec`,
41
+ '',
42
+ ].join('\n');
43
+ const result = await compose(ctx, local, ['exec', '-T', database.service, 'psql', '-v', 'ON_ERROR_STOP=1', '-U', database.adminUser, '-d', 'postgres'], { values: composeValues(local, custody), input: sql });
44
+ if (result.status !== 0)
45
+ throw failure('STACK_LOCAL_DB_ROLE', `could not provision the role ${database.role}: ${(result.stderr.trim().split(/\r?\n/u)[0] ?? '').replaceAll(password, '[redacted]')}`);
46
+ return `database: role ${database.role} and database ${database.name} ready on port ${local.ports[database.port]}`;
47
+ }
48
+ /** Gives the app role the migrations created its own password for this environment (cluster roles are never shared: the db container is this environment's alone). */
49
+ async function appRolePassword(ctx, local, custody) {
50
+ const database = local.database;
51
+ if (!database?.appRole || !database.appPassword)
52
+ return '';
53
+ const password = custody.values.get(database.appPassword.key) ?? '';
54
+ if (!SAFE_PASSWORD.test(password))
55
+ throw refusal('STACK_LOCAL_DB_PASSWORD', `${database.appPassword.key} must be at least 8 characters of letters, digits, dash and underscore`);
56
+ const sql = `DO $do$ BEGIN IF EXISTS (SELECT FROM pg_roles WHERE rolname = '${database.appRole}') THEN ALTER ROLE "${database.appRole}" LOGIN PASSWORD '${password}'; ELSE RAISE EXCEPTION 'role ${database.appRole} does not exist: the migrations create it'; END IF; END $do$;\n`;
57
+ const result = await compose(ctx, local, ['exec', '-T', database.service, 'psql', '-v', 'ON_ERROR_STOP=1', '-U', database.adminUser, '-d', database.name], { values: composeValues(local, custody), input: sql });
58
+ if (result.status !== 0)
59
+ throw failure('STACK_LOCAL_DB_ROLE', `could not set the password of ${database.appRole}: ${(result.stderr.trim().split(/\r?\n/u)[0] ?? '').replaceAll(password, '[redacted]')}`);
60
+ return `, ${database.appRole} password set`;
61
+ }
62
+ /** Migrates through `starci app db migrate` in this process; the URL stays in the environment of that call. */
63
+ async function migrate(ctx, local, custody, deps) {
64
+ const database = local.database;
65
+ const url = databaseUrl(local, custody);
66
+ if (!database || url === null)
67
+ return 'migrate: no database declared';
68
+ const container = firstLine((await compose(ctx, local, ['ps', '-q', database.service])).stdout);
69
+ const env = { ...ctx.env, DATABASE_URL: url, STARCI_DB_CONTAINER: container };
70
+ const run = deps.migrate ?? (async (urlEnv, environment, cwd) => {
71
+ const { main } = await import('../src/main.mjs');
72
+ return main(['db', 'migrate', '--db-url-env', urlEnv, '--execute'], { cwd, env: environment, stdout: () => { }, stderr: () => { } });
73
+ });
74
+ const code = await run('DATABASE_URL', env, local.repoRoot);
75
+ if (code !== 0)
76
+ throw failure('STACK_LOCAL_MIGRATE', `starci app db migrate exited ${code}; run it with --db-url-env DATABASE_URL to read its report`);
77
+ const dir = stateOf(ctx, local);
78
+ const record = readRecord(dir);
79
+ record.receipts.migrate = { at: new Date(ctx.now()).toISOString(), digest: migrationsDigest(local.repoRoot) };
80
+ writeRecord(dir, record);
81
+ return `migrate: applied, receipt ${record.receipts.migrate.digest}${await appRolePassword(ctx, local, custody)}`;
82
+ }
83
+ /** Runs the seed commands once each (a receipt keyed by the command's digest); the seed owns its idempotence beyond that. */
84
+ async function seed(ctx, local, custody, after) {
85
+ const dir = stateOf(ctx, local);
86
+ const record = readRecord(dir);
87
+ const out = [];
88
+ for (const run of local.seed.filter((item) => item.after === after)) {
89
+ if (record.receipts.seed[run.name]?.digest === commandDigest(run)) {
90
+ out.push(`seed ${run.name}: already done`);
91
+ continue;
92
+ }
93
+ await runCommand(ctx, local, run, runEnvironment(ctx, local, run, custody, 'owner'));
94
+ record.receipts.seed[run.name] = { at: new Date(ctx.now()).toISOString(), digest: commandDigest(run) };
95
+ writeRecord(dir, record);
96
+ out.push(`seed ${run.name}: done`);
97
+ }
98
+ return out;
99
+ }
100
+ /** Starts each native process that is not already running. */
101
+ function startProcesses(ctx, local, custody, deps) {
102
+ const dir = stateOf(ctx, local);
103
+ const record = readRecord(dir);
104
+ const out = [];
105
+ const alive = deps.alive ?? isAlive;
106
+ for (const run of local.processes) {
107
+ const known = record.processes[run.name];
108
+ if (known && alive(known.pid)) {
109
+ out.push(`process ${run.name}: running (pid ${known.pid})`);
110
+ continue;
111
+ }
112
+ const pid = startProcess(ctx, local, run, runEnvironment(ctx, local, run, custody, 'app'));
113
+ record.processes[run.name] = { pid, startedAt: new Date(ctx.now()).toISOString(), port: run.port === null ? null : (local.ports[run.port] ?? null) };
114
+ out.push(`process ${run.name}: started (pid ${pid})`);
115
+ }
116
+ record.ports = { ...local.ports };
117
+ writeRecord(dir, record);
118
+ return out;
119
+ }
120
+ /** The plan a dry run prints. */
121
+ function plan(local, missing) {
122
+ return [
123
+ `plan custody: ${missing.length ? `missing ${missing.join(', ')}: ${missingStep(local, missing)}` : 'open the sealed dev values into host-local files'}`,
124
+ ...local.setup.map((run) => `plan setup ${run.name}: ${run.argv.join(' ')}`),
125
+ `plan services: ${local.services.map((service) => `${service.name} (wait ${service.wait})`).join(', ')}`,
126
+ ...(local.database ? [`plan database: own role ${local.database.role}, database ${local.database.name}, then starci app db migrate`] : []),
127
+ ...local.seed.map((run) => `plan seed ${run.name}: ${run.argv.join(' ')}`),
128
+ ...local.processes.map((run) => `plan process ${run.name}: ${run.argv.join(' ')}`),
129
+ ...local.ready.map((row) => `plan ready ${row.name}: ${row.url}`),
130
+ ];
131
+ }
132
+ /** `up` for a dev environment. */
133
+ export async function upLocal(ctx, local, { execute = false, timeoutSeconds = DEFAULT_TIMEOUT_SECONDS } = {}, deps = {}) {
134
+ const host = await preflight(ctx, local, deps);
135
+ if (!host.ok)
136
+ return hostRefusal(host.lines);
137
+ const missing = missingKeys(local);
138
+ if (!execute)
139
+ return { ok: missing.length === 0, lines: [...host.lines, ...plan(local, missing), 'dry run: nothing was started; arm it with --execute'], data: { rows: host.rows } };
140
+ const lines = [...host.lines];
141
+ const custody = openCustody(ctx, local);
142
+ lines.push(`custody: ${custody.values.size} key file(s) in the host-local directory`);
143
+ lines.push(...await runSetup(ctx, local, custody));
144
+ makeDataDirectories(ctx, local);
145
+ const values = composeValues(local, custody);
146
+ const started = await compose(ctx, local, ['up', '-d', ...local.services.map((service) => service.name)], { values, heavy: true, label: 'compose up' });
147
+ if (started.status !== 0)
148
+ throw failure('STACK_LOCAL_COMPOSE', `docker compose up failed: ${(started.stderr.trim().split(/\r?\n/u)[0] ?? '').slice(0, 200)}`);
149
+ const deadline = ctx.now() + timeoutSeconds * 1000;
150
+ for (const service of local.services) {
151
+ const ready = await serviceReady(ctx, local, service.name, deadline, deps);
152
+ lines.push(`service ${ready.detail}`);
153
+ if (!ready.ok)
154
+ throw failure('STACK_LOCAL_SERVICE', `${service.name} did not become ready in ${timeoutSeconds}s (${ready.detail}); read it with: starci app stack logs --env ${local.env} --service ${service.name}`);
155
+ }
156
+ lines.push(await provisionDatabase(ctx, local, custody), await migrate(ctx, local, custody, deps), ...await seed(ctx, local, custody, 'migrate'), ...startProcesses(ctx, local, custody, deps));
157
+ for (const row of local.ready) {
158
+ const result = await waitFor(ctx, deadline, () => probe(ctx, local, row));
159
+ lines.push(`${result.ok ? 'ok ' : 'FAIL '}ready ${result.detail}`);
160
+ if (!result.ok)
161
+ throw failure('STACK_LOCAL_READY', `${row.name} is not ready: ${result.detail}; read a process log with: starci app stack logs --env ${local.env} --service ${local.processes.map((run) => run.name).join('|')}`);
162
+ }
163
+ lines.push(...await seed(ctx, local, custody, 'ready'));
164
+ lines.push(...local.origins.map((origin) => `origin ${origin.name}: ${origin.url.replaceAll(/\$\{port\.([a-z][a-z0-9-]*)\}/gu, (_whole, name) => String(local.ports[name]))}`));
165
+ return { ok: true, lines, data: { origins: local.origins } };
166
+ }
@@ -0,0 +1,6 @@
1
+ import type { StackContext } from './context.mjs';
2
+ import type { ActionResult } from './remote.mjs';
3
+ import type { LocalConfig } from './local-config.mjs';
4
+ import type { LocalDeps } from './local-run.mjs';
5
+ /** `verify` for a dev environment. */
6
+ export declare function verifyLocal(ctx: StackContext, local: LocalConfig, _options?: Record<string, unknown>, deps?: LocalDeps): Promise<ActionResult>;
@@ -0,0 +1,21 @@
1
+ import { refusal } from './errors.mjs';
2
+ import { isAlive, probe, stateOf } from './local-run.mjs';
3
+ import { readRecord } from './local-state.mjs';
4
+ /** `verify` for a dev environment. */
5
+ export async function verifyLocal(ctx, local, _options = {}, deps = {}) {
6
+ if (local.verify.length === 0)
7
+ throw refusal('STACK_LOCAL_VERIFY_UNDECLARED', `environment ${local.env} declares no local.verify journey: a liveness probe alone is not verification`);
8
+ const record = readRecord(stateOf(ctx, local));
9
+ const alive = deps.alive ?? isAlive;
10
+ const down = local.processes.filter((run) => !(record.processes[run.name] && alive(record.processes[run.name].pid)));
11
+ if (down.length)
12
+ return { ok: false, lines: [`FAIL ${down.map((run) => run.name).join(', ')} not running: start the environment with starci app stack up --env ${local.env} --execute`] };
13
+ const lines = [];
14
+ let ok = true;
15
+ for (const row of local.verify) {
16
+ const result = await probe(ctx, local, row);
17
+ ok &&= result.ok;
18
+ lines.push(`${result.ok ? 'ok ' : 'FAIL '}${result.detail}`);
19
+ }
20
+ return { ok, lines, data: { checked: local.verify.length } };
21
+ }
package/sync/index.d.mts CHANGED
@@ -64,6 +64,8 @@ export interface AppSourceFacts {
64
64
  read: (file: string) => string;
65
65
  workPresent: boolean;
66
66
  hasImage: (file: string) => boolean;
67
+ modules?: readonly string[];
68
+ otherPackages?: readonly string[];
67
69
  stack: any;
68
70
  quality: any;
69
71
  badges: string;
@@ -138,12 +140,6 @@ export declare function sonarScope(presets: Presets | null | undefined): {
138
140
  tests: string[];
139
141
  exclusions: string[];
140
142
  };
141
- /**
142
- * The be source of the app at `root` the coverage components read: { files, read }, `files` be-relative posix paths of every `*.ts` file under
143
- * be/apps and be/src (build output and dependencies skipped) and `read` their text. `workPresent` records whether the optional
144
- * lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
145
- * their content. An app with no be folder has no source files.
146
- */
147
143
  export declare function appSource(root: string, { parseYaml, notices }?: {
148
144
  parseYaml?: ParseYaml | undefined;
149
145
  notices?: string[] | undefined;
@@ -178,7 +174,7 @@ export declare function jestCoverageSource(coverage: {
178
174
  * `docker:build:<app>` builds the image of one app (the exact command its Dockerfile header states, tag `<project>/<app>:dev`) and
179
175
  * `docker:build` builds every image, one after the other, never pushing.
180
176
  */
181
- export declare function appScripts(app: ResolvedApp, source: Partial<Pick<AppSourceFacts, 'hasImage'>> | undefined): string;
177
+ export declare function appScripts(app: ResolvedApp, source: Partial<Pick<AppSourceFacts, 'hasImage' | 'modules'>> | undefined): string;
182
178
  /** The stylesheets of the fe side the CSS canon judges, relative to fe/: every app and every workspace package (slot fe.route, fe.package.*). */
183
179
  export declare const STYLE_GLOB = "{apps,packages}/*/src/**/*.css";
184
180
  /** The apps images.yml builds (one matrix entry per declared app): name and Dockerfile, be then fe, then a lite app's db image. */
@@ -192,14 +188,13 @@ export declare function imageFiles(app: ResolvedApp, source?: Partial<Pick<AppSo
192
188
  path: string;
193
189
  content: string;
194
190
  }[];
195
- /** Every value a template of `scope` can name, derived from hfs.json and the presets. */
196
191
  export declare function variables(app: ResolvedApp, scope: string, presets: Presets | null | undefined, sonarKey: string | null | undefined, manifest?: SlotManifest, source?: Pick<AppSourceFacts, 'files' | 'read'> & Partial<AppSourceFacts>): Record<string, string>;
197
192
  /**
198
- * The managed content of every target of the app: [{ path, mode, content, hash, scope }], paths app-relative (a side's targets under
199
- * its folder); a scripts target also carries `scripts`, the parsed block. `sonarKey` is the key the be side's stack declaration
200
- * names; without one the key is the project.
193
+ * The rendered targets of an app declaration. Without source facts the app counts the back-end source files its own targets
194
+ * render (the managed sign-in glue), as a check reading the disk after the targets are written does, so a render of a declaration
195
+ * alone is already in sync with itself.
201
196
  */
202
- export declare function renderTargets(hfs: unknown, presets: Presets | null | undefined, { sonarKey, readTemplate, manifest, source }?: {
197
+ export declare function renderTargets(hfs: unknown, presets: Presets | null | undefined, options?: {
203
198
  sonarKey?: string | null | undefined;
204
199
  readTemplate?: (name: string) => string;
205
200
  manifest?: SlotManifest;
@@ -212,6 +207,12 @@ export declare function writtenContent(target: RenderedTarget, existing: string
212
207
  export declare function writeTargets(root: string, targets: readonly RenderedTarget[]): string[];
213
208
  /** The hfs.json object of the app at `root` (validated), or a SyncError. */
214
209
  export declare function loadHfs(root: string, manifest?: SlotManifest): Record<string, any>;
210
+ /**
211
+ * The source facts as they stand once the rendered back-end source files are on disk: a managed `.ts` target of the be side counts as a
212
+ * file of the app (and reads as its render), so the CI shard count and the coverage scope that count or read the source name the same
213
+ * files before and after the targets are written, and one sync leaves the app in sync.
214
+ */
215
+ export declare function settleSource(source: AppSourceFacts, targets: readonly RenderedTarget[]): AppSourceFacts;
215
216
  /** The rendered targets of the app at `root`: its hfs.json, the Sonar key the be side's stack declaration names, the presets it installs. */
216
217
  export declare function renderRepo(root: string, { presets, parseYaml, notices, manifest }?: {
217
218
  presets?: Presets | undefined;
package/sync/index.mjs CHANGED
@@ -34,6 +34,7 @@ import { DB_DOCKERFILE, DB_IMAGE_APP, RETIRED_DB_DOCKERFILE } from '../db/image.
34
34
  import { byCodeUnit } from '../report/order.mjs';
35
35
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
36
36
  import { ciWorkflowCommands } from './ci-commands.mjs';
37
+ import { MODULE_PREFIX, moduleNames, registryFiles } from '../scaffold/module-registry.mjs';
37
38
  import { dependabotUpdates, hasDeclaredServices, readDeclaredQuality, readDeclaredBadges, sonarHostLine } from './quality-services.mjs';
38
39
  import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
39
40
  import { grammarLintFor } from '../runtime/scripts/lib/grammar-lint.mjs';
@@ -244,6 +245,16 @@ const SOURCE_SKIPPED = new Set(['node_modules', 'dist', 'coverage']);
244
245
  * lite brand tree exists, so sync can preserve its absence. `hasImage` identifies authored optional Dockerfiles without reading
245
246
  * their content. An app with no be folder has no source files.
246
247
  */
248
+ /** The folders of `packages/` that are neither a module package nor the registry. */
249
+ function otherPackages(root) {
250
+ try {
251
+ return fs.readdirSync(path.join(root, 'packages'), { withFileTypes: true })
252
+ .filter(entry => entry.isDirectory() && !entry.name.startsWith(MODULE_PREFIX) && entry.name !== 'modules').map(entry => entry.name).sort(byCodeUnit);
253
+ }
254
+ catch {
255
+ return [];
256
+ }
257
+ }
247
258
  export function appSource(root, { parseYaml, notices } = {}) {
248
259
  const files = [];
249
260
  const walk = (dir) => {
@@ -267,6 +278,7 @@ export function appSource(root, { parseYaml, notices } = {}) {
267
278
  for (const dir of ['apps', 'src'])
268
279
  walk(dir);
269
280
  return { runtimeExample: isRuntimeExampleApp(root), files: files.sort(byCodeUnit), read: (file) => fs.readFileSync(path.join(root, 'be', file), 'utf8'),
281
+ modules: moduleNames(root), otherPackages: otherPackages(root),
270
282
  workPresent: fs.existsSync(path.join(root, '.starciwork')), hasImage: (file) => isFile(path.join(root, file), { followLinks: false }),
271
283
  stack: readDeclaredStack(root, { parseYaml }), quality: readDeclaredQuality(root, { parseYaml }),
272
284
  badges: readDeclaredBadges(root, { parseYaml, notices }),
@@ -321,6 +333,7 @@ export function appScripts(app, source) {
321
333
  }),
322
334
  ...fe.map(entry => line(`start:${entry.name}`, `npm run start -w ${workspace(entry)}`)),
323
335
  ...(app.edition !== 'lite' && app.optionalSlots.includes('app.browser') ? [line('test:browser', 'playwright test -c browser/playwright.config.ts')] : []),
336
+ ...(source?.modules?.length ? [line('build:modules', 'turbo run build --filter=./packages/*'), line('test:modules', 'turbo run test --filter=./packages/module-*')] : []),
324
337
  line('docker:build', images.map(image => `npm run docker:build:${image.entry.name}`).join(' && ')),
325
338
  ...images.map(image => line(`docker:build:${image.entry.name}`, imageCommand(image))),
326
339
  ].join('\n ');
@@ -377,6 +390,12 @@ export function imageFiles(app, source) {
377
390
  });
378
391
  }
379
392
  /** Every value a template of `scope` can name, derived from hfs.json and the presets. */
393
+ /** What a module package keeps out of the measured scope: its workbench (drawn with Grammar, a front end has no tests) and its specs. The logic of its template and manifest is measured. */
394
+ const moduleExclusions = (dirs) => dirs.flatMap(dir => [`${dir}/src/workbench/**`, `${dir}/src/**/index.ts`]);
395
+ /** The managed files of the registry package for the module packages the app has (empty when it has none). */
396
+ function registryTargets(app, source) {
397
+ return registryFiles(app.project, source?.modules ?? []).map((file) => ({ path: file.path, scope: APP_SCOPE, mode: 'file', content: file.content, hash: hashOf(file.content) }));
398
+ }
380
399
  export function variables(app, scope, presets, sonarKey, manifest = loadSlotManifest(), source = { files: [], read: () => '' }) {
381
400
  const stack = source.stack ?? { images: [], passEnv: [] };
382
401
  const fe = app.sides.fe;
@@ -384,6 +403,10 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
384
403
  const lite = app.edition === 'lite';
385
404
  const providers = (app.sides.be.connections ?? []).map(connection => connection.provider);
386
405
  const quality = source.quality ?? qualityServices({});
406
+ const modules = source.modules ?? [];
407
+ const moduleDirs = modules.map(name => `packages/${MODULE_PREFIX}${name}`);
408
+ // The module packages are built before anything imports them (typecheck, lint, the fe build and the be run all follow the registry).
409
+ const modulesFirst = modules.length ? 'npm run build:modules --silent && ' : '';
387
410
  const grammarLint = scope === 'fe' ? grammarLintFor(fe.grammar) : null;
388
411
  return {
389
412
  header: HEADER(scope),
@@ -401,11 +424,12 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
401
424
  // A lite app's declared Redis (sides.be.backing, ruling:R212/case-10) runs in development as one container of its pinned image.
402
425
  backingScripts: lite && (app.sides.be.backing ?? []).includes('redis') ? redisDevText(app.project) : '',
403
426
  // turbo builds every fe app workspace; ^build (turbo.json) builds the packages each app imports first, and caches both.
404
- buildFe: 'npm run codegen --silent && turbo run build --filter=./fe/apps/*',
427
+ buildFe: `${modulesFirst}npm run codegen --silent && turbo run build --filter=./fe/apps/*`,
405
428
  // The be side with its tsc; every fe workspace with its own typecheck script through turbo (the packages are built first: ^build).
406
- typecheck: 'npm run codegen --silent && tsc -p be/tsconfig.json && turbo run typecheck',
429
+ typecheck: `${modulesFirst}npm run codegen --silent && tsc -p be/tsconfig.json && turbo run typecheck`,
407
430
  // The typed lint reads the types the fe packages declare (their dist), so the packages are built first.
408
- lintPrefix: packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && ',
431
+ lintPrefix: `${modulesFirst}${packages ? 'npm run codegen --silent && turbo run build --filter=./fe/packages/* && ' : 'npm run codegen --silent && '}`,
432
+ moduleTests: modules.length ? ' && npm run test:modules' : '',
409
433
  nodeMajor: String(NODE_MAJOR),
410
434
  // The deploy server's age identity the restore workflow writes (stack/server-key.mjs owns the path).
411
435
  serverAgeIdentity: SERVER_AGE_IDENTITY,
@@ -415,19 +439,19 @@ export function variables(app, scope, presets, sonarKey, manifest = loadSlotMani
415
439
  sonarHostLine: sonarHostLine(quality.sonar),
416
440
  sonarExclusions: sonarScope(presets).exclusions.join(','),
417
441
  sonarTestInclusions: sonarScope(presets).tests.join(','),
418
- sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : [])].join(','),
442
+ sonarSources: ['be/apps', 'be/src', 'fe/apps', ...(packages ? ['fe/packages'] : []), ...moduleDirs.map(dir => `${dir}/src`)].join(','),
419
443
  lcovReport: LCOV_REPORT,
420
444
  cpdExclusions: SONAR_CPD_EXCLUSIONS.join(','),
421
445
  ...ciWorkflowCommands(source, LCOV_REPORT, source.qualityDeclared === false ? undefined : source.quality, { app, packages }),
422
446
  // The coverage scope is the same in both editions: lite measures exactly what full measures (ruling:R212/case-1, amended by ruling:R212/case-4).
423
447
  jestCoverage: scope === 'be' ? jestCoverageSource(jestCoverage(manifest, providers)) : '',
424
- coverageExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).join(',') : '',
425
- codecovExclusions: scope === APP_SCOPE ? sonarCoverageExclusions(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
426
- codecovPaths: scope === APP_SCOPE ? codecovPaths(manifest, providers).map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
448
+ coverageExclusions: scope === APP_SCOPE ? [...sonarCoverageExclusions(manifest, providers), ...moduleExclusions(moduleDirs)].join(',') : '',
449
+ codecovExclusions: scope === APP_SCOPE ? [...sonarCoverageExclusions(manifest, providers), ...moduleExclusions(moduleDirs)].map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
450
+ codecovPaths: scope === APP_SCOPE ? [...codecovPaths(manifest, providers), ...moduleDirs.flatMap(dir => [`${dir}/src/manifest/**`, `${dir}/src/template/**`])].map(glob => ` - ${JSON.stringify(glob)}`).join('\n') : '',
427
451
  codecovComponents: scope === APP_SCOPE ? componentsYaml(coverageComponents(manifest, { ...source, apps: app.sides.be.apps, providers })) : '',
428
452
  codecovTarget: quality.codecov.target,
429
453
  dependabotUpdates: dependabotUpdates(quality.dependabot),
430
- tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : [])].join(','),
454
+ tsconfigPaths: ['be/tsconfig.json', ...fe.apps.map(entry => `fe/apps/${entry.name}/tsconfig.json`), ...(packages ? ['fe/packages/*/tsconfig.json'] : []), ...moduleDirs.map(dir => `${dir}/tsconfig.json`)].join(','),
431
455
  styleGlob: STYLE_GLOB,
432
456
  grammarLintImport: grammarLint?.package ? `import { ${grammarLint.factory} } from "${grammarLint.package}"` : '',
433
457
  grammarLintExport: grammarLint?.package ? `export default [...starciFeConfig({ hfs }), ...${grammarLint.factory}({ hfs })]` : 'export default starciFeConfig({ hfs })',
@@ -446,12 +470,24 @@ function parseScripts(text) {
446
470
  return null;
447
471
  }
448
472
  }
473
+ /**
474
+ * The rendered targets of an app declaration. Without source facts the app counts the back-end source files its own targets
475
+ * render (the managed sign-in glue), as a check reading the disk after the targets are written does, so a render of a declaration
476
+ * alone is already in sync with itself.
477
+ */
478
+ export function renderTargets(hfs, presets, options = {}) {
479
+ const first = renderOnce(hfs, presets, options);
480
+ if (options.source !== undefined)
481
+ return first;
482
+ const settled = settleSource({ files: [], read: () => '' }, first); // reason: the settled facts name only the rendered source files; every other fact stays absent as without source
483
+ return settled.files.length === 0 ? first : renderOnce(hfs, presets, { ...options, source: settled });
484
+ }
449
485
  /**
450
486
  * The managed content of every target of the app: [{ path, mode, content, hash, scope }], paths app-relative (a side's targets under
451
487
  * its folder); a scripts target also carries `scripts`, the parsed block. `sonarKey` is the key the be side's stack declaration
452
488
  * names; without one the key is the project.
453
489
  */
454
- export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundled, manifest = loadSlotManifest(), source } = {}) {
490
+ function renderOnce(hfs, presets, { sonarKey, readTemplate = readBundled, manifest = loadSlotManifest(), source } = {}) {
455
491
  const app = validateHfs(hfs, manifest);
456
492
  const hasTemplate = (name) => { try {
457
493
  return readTemplate(name) !== undefined;
@@ -467,24 +503,24 @@ export function renderTargets(hfs, presets, { sonarKey, readTemplate = readBundl
467
503
  declaredTargets.push({ path: 'README.md', mode: 'block', markdown: true, template: 'app/readme-badges/README.md' });
468
504
  const targets = declaredTargets
469
505
  .filter(target => !(app.edition === 'lite' && source?.workPresent === false && target.path === '.starciwork/.gitignore'));
470
- return targets.map((target) => {
471
- const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
472
- ? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
473
- : readTemplate(target.template);
474
- const body = trailingNewline(render(template, vars, readTemplate));
475
- const where = onSide(scope, target.path);
476
- if (target.mode === 'scripts') {
477
- const scripts = parseScripts(body);
478
- if (!scripts)
479
- throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `the rendered ${target.template} has no scripts object`);
480
- const content = scriptsText(scripts);
481
- return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
482
- }
483
- const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
484
- const spacing = target.markdown ? '\n' : '';
485
- const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
486
- return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
487
- });
506
+ return [...(scope === APP_SCOPE ? registryTargets(app, source) : []), ...targets.map((target) => {
507
+ const template = target.path === 'codecov.yml' && source?.quality?.codecov.enabled === false
508
+ ? '{{header}}\n# Codecov is disabled in the manifest.\ncodecov:\n require_ci_to_pass: false\n'
509
+ : readTemplate(target.template);
510
+ const body = trailingNewline(render(template, vars, readTemplate));
511
+ const where = onSide(scope, target.path);
512
+ if (target.mode === 'scripts') {
513
+ const scripts = parseScripts(body);
514
+ if (!scripts)
515
+ throw new SyncError('HFS_SYNC_TEMPLATE_VARIABLE', `the rendered ${target.template} has no scripts object`);
516
+ const content = scriptsText(scripts);
517
+ return { path: where, scope, mode: 'scripts', content, hash: hashOf(content), scripts };
518
+ }
519
+ const markers = target.markdown ? { begin: '<!-- >>> starci app sync (managed block; do not edit) >>> -->', end: '<!-- <<< starci app sync <<< -->' } : { begin: BLOCK_BEGIN, end: BLOCK_END };
520
+ const spacing = target.markdown ? '\n' : '';
521
+ const managed = target.mode === 'block' ? `${markers.begin}\n${spacing}${trailingNewline(body)}${spacing}${markers.end}\n` : body;
522
+ return { path: where, scope, mode: target.mode, markdown: target.markdown, markers, content: managed, hash: hashOf(managed) };
523
+ })];
488
524
  });
489
525
  }
490
526
  // The part of a file sync manages: all of it, all of it but the verb's section, the marked block, or the scripts block as text.
@@ -591,11 +627,30 @@ export function loadHfs(root, manifest = loadSlotManifest()) {
591
627
  validateHfs(hfs, manifest);
592
628
  return hfs;
593
629
  }
630
+ /**
631
+ * The source facts as they stand once the rendered back-end source files are on disk: a managed `.ts` target of the be side counts as a
632
+ * file of the app (and reads as its render), so the CI shard count and the coverage scope that count or read the source name the same
633
+ * files before and after the targets are written, and one sync leaves the app in sync.
634
+ */
635
+ export function settleSource(source, targets) {
636
+ const added = new Map();
637
+ for (const target of targets) {
638
+ const rel = target.scope === 'be' && target.path.startsWith('be/') ? target.path.slice('be/'.length) : '';
639
+ if (SOURCE_FILE.test(rel) && (rel.startsWith('apps/') || rel.startsWith('src/')) && !source.files.includes(rel))
640
+ added.set(rel, target.content);
641
+ }
642
+ if (added.size === 0)
643
+ return source;
644
+ return { ...source, files: [...source.files, ...added.keys()].sort(byCodeUnit), read: (file) => added.get(file) ?? source.read(file) };
645
+ }
594
646
  /** The rendered targets of the app at `root`: its hfs.json, the Sonar key the be side's stack declaration names, the presets it installs. */
595
647
  export async function renderRepo(root, { presets, parseYaml, notices, manifest = loadSlotManifest() } = {}) {
596
648
  const hfs = loadHfs(root, manifest);
597
649
  const sonarKey = await readDeclaredSonarKey(root, { parseYaml, fail: (message) => { throw new SyncError('HFS_SYNC_SONAR_KEY', message); } });
598
- return { hfs, targets: renderTargets(hfs, presets ?? await loadPresets(root), { sonarKey, manifest, source: appSource(root, { parseYaml, notices }) }) };
650
+ const installed = presets ?? await loadPresets(root);
651
+ const source = appSource(root, { parseYaml, notices });
652
+ const settled = settleSource(source, renderTargets(hfs, installed, { sonarKey, manifest, source }));
653
+ return { hfs, targets: renderTargets(hfs, installed, { sonarKey, manifest, source: settled }) };
599
654
  }
600
655
  /** The folders a lite app's sync retires (ruling lite-self-hosted-postgres-passport-minio): the generated Supabase row types nothing reads any more. */
601
656
  const LITE_RETIRED_DIRS = Object.freeze(['supabase/types']);
@@ -0,0 +1,8 @@
1
+ /** Unit run of the {{pascal}} module package: the logic of its template and manifest is measured at 100 per file. */
2
+ module.exports = {
3
+ testEnvironment: "node",
4
+ transform: { "^.+\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.json", isolatedModules: true }] },
5
+ testMatch: ["<rootDir>/src/**/*.spec.ts"],
6
+ collectCoverageFrom: ["src/manifest/**/*.ts", "src/template/**/*.ts", "!src/**/*.spec.ts", "!src/**/index.ts"],
7
+ coverageThreshold: { "./src/manifest/**/*.ts": { lines: 100, branches: 100, functions: 100, statements: 100 }, "./src/template/**/*.ts": { lines: 100, branches: 100, functions: 100, statements: 100 } },
8
+ }
@@ -0,0 +1,2 @@
1
+ /** The key of the {{pascal}} module: its catalogue entry, its workbench and its message namespace. */
2
+ export const {{upper}}_MODULE_KEY: "{{name}}" = "{{name}}"
@@ -0,0 +1,11 @@
1
+ import { {{upper}}_VIEW_IDS, {{upper}}_WORKBENCH } from "./declaration"
2
+
3
+ describe("the {{pascal}} workbench declaration", () => {
4
+ it("declares one view per id, in strip order", () => {
5
+ expect({{upper}}_WORKBENCH.views.map((view) => view.id)).toEqual([...{{upper}}_VIEW_IDS])
6
+ })
7
+
8
+ it("is keyed like its module", () => {
9
+ expect({{upper}}_WORKBENCH).toMatchObject({ key: "{{name}}", namespace: "{{name}}" })
10
+ })
11
+ })
@@ -0,0 +1,13 @@
1
+ import type { WorkbenchDeclaration } from "@{{project}}/agent-sdk/workbench/contract"
2
+
3
+ /** The views of the {{pascal}} workbench in strip order (`?tab=`), each labelled from the module's namespace. */
4
+ export const {{upper}}_VIEW_IDS: readonly ["overview"] = ["overview"]
5
+
6
+ /** The {{pascal}} workbench: one overview every member of the workspace opens; grow its views, permissions, operations and exposed reads here. */
7
+ export const {{upper}}_WORKBENCH: WorkbenchDeclaration = {
8
+ key: "{{name}}",
9
+ namespace: "{{name}}",
10
+ views: [{ id: "overview", labelKey: "tab_overview" }],
11
+ permissions: [],
12
+ operations: [],
13
+ }
@@ -0,0 +1 @@
1
+ export { {{upper}}_VIEW_IDS, {{upper}}_WORKBENCH } from "./declaration"
@@ -0,0 +1,2 @@
1
+ /** The public face of the {{pascal}} module for a front end: what a page may import without the workbench. */
2
+ export { {{upper}}_MODULE_KEY } from "../index"
@@ -0,0 +1,13 @@
1
+ import { moduleListProblems } from "@{{project}}/agent-sdk/module"
2
+ import { {{upper}}_SERVER, {{upper}}_TEMPLATE } from "./{{name}}.template"
3
+
4
+ describe("{{upper}}_SERVER", () => {
5
+ it("is a module list the host may load", () => {
6
+ expect(moduleListProblems([{{upper}}_SERVER])).toEqual([])
7
+ })
8
+
9
+ it("serves its template under the key of the module", () => {
10
+ expect({{upper}}_SERVER.key).toBe({{upper}}_TEMPLATE.key)
11
+ expect({{upper}}_TEMPLATE).toMatchObject({ key: "{{name}}", workbench: "{{name}}" })
12
+ })
13
+ })
@@ -0,0 +1,41 @@
1
+ import type { ModuleServer, ModuleTemplate } from "@{{project}}/agent-sdk/module"
2
+
3
+ /** The {{pascal}} module's template: its copy, agent, setup gates, authority actions and accepted commands. */
4
+ export const {{upper}}_TEMPLATE: ModuleTemplate = {
5
+ key: "{{name}}",
6
+ order: 100,
7
+ name: { vi: "{{pascal}}", en: "{{pascal}}" },
8
+ shortName: { vi: "{{pascal}}", en: "{{pascal}}" },
9
+ description: { vi: "{{pascal}}", en: "{{pascal}}" },
10
+ points: { vi: [], en: [] },
11
+ purpose: "{{pascal}}",
12
+ icon: "module",
13
+ glyph: "M12 3l9 5v8l-9 5-9-5V8z",
14
+ mascot: "",
15
+ category: "operations",
16
+ status: "early",
17
+ audience: "customer",
18
+ includedWithWorkspace: false,
19
+ available: false,
20
+ knowledgeFolder: "{{name}}",
21
+ workbench: "{{name}}",
22
+ settingsExtras: null,
23
+ requires: { capabilities: [], connections: [] },
24
+ connectionProviders: [],
25
+ defaultOperatingMode: "assist",
26
+ multiInstance: false,
27
+ agent: {
28
+ label: { vi: "{{pascal}}", en: "{{pascal}}" },
29
+ handle: "{{name}}",
30
+ summary: { vi: "{{pascal}}", en: "{{pascal}}" },
31
+ capabilities: { vi: [], en: [] },
32
+ role: { vi: "{{pascal}}", en: "{{pascal}}" },
33
+ instructions: { vi: "{{pascal}}", en: "{{pascal}}" },
34
+ },
35
+ gates: [],
36
+ authorityActions: [],
37
+ accepts: [],
38
+ }
39
+
40
+ /** The server half of the {{pascal}} module: its template and the words it adds to a graph. */
41
+ export const {{upper}}_SERVER: ModuleServer = { key: "{{name}}", template: {{upper}}_TEMPLATE }
@@ -0,0 +1 @@
1
+ export { {{upper}}_SERVER, {{upper}}_TEMPLATE } from "./{{name}}.template"
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from "react"
2
+ import { EmptyNotice, Page } from "@starci/grammar/common"
3
+
4
+ /** What the {{pascal}} workbench draws: its title and the words of its empty state. */
5
+ export interface {{pascal}}WorkbenchViewProps {
6
+ readonly title: string
7
+ readonly emptyTitle: string
8
+ readonly emptyBody: string
9
+ }
10
+
11
+ /** Draws the {{pascal}} workbench with Grammar alone; it owns no class string, colour or visual of its own. */
12
+ export const {{pascal}}WorkbenchView = (props: {{pascal}}WorkbenchViewProps): ReactNode => (
13
+ <Page header={{ title: props.title }}>
14
+ <EmptyNotice message={props.emptyTitle} description={props.emptyBody} />
15
+ </Page>
16
+ )
@@ -0,0 +1,2 @@
1
+ export { {{pascal}}WorkbenchView } from "./components/{{pascal}}WorkbenchView"
2
+ export type { {{pascal}}WorkbenchViewProps } from "./components/{{pascal}}WorkbenchView"
@@ -4,7 +4,7 @@
4
4
  {{appScripts}}
5
5
  {{> app/package-scripts/core.json}}
6
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
- "test": "cd be && jest --selectProjects unit --coverage",
7
+ "test": "cd be && jest --selectProjects unit --coverage{{moduleTests}}",
8
8
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
9
9
  "test:e2e": "npm run typecheck:tests && cd be && jest --selectProjects e2e",
10
10
  "test:contract": "npm run typecheck:tests && cd be && jest --selectProjects contract",
@@ -4,7 +4,7 @@
4
4
  {{appScripts}}
5
5
  {{> app/package-scripts/core.json}}
6
6
  "typecheck:tests": "tsc -p be/src/tests/tsconfig.json",
7
- "test": "cd be && jest --selectProjects unit --coverage",
7
+ "test": "cd be && jest --selectProjects unit --coverage{{moduleTests}}",
8
8
  "test:integration": "npm run typecheck:tests && cd be && jest --selectProjects integration",
9
9
  {{> app/package-scripts/db.json}}
10
10
  {{backingScripts}}