@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,232 @@
1
+ // starci app add module <name> / starci app remove module <name> - a module package of the product's agent SDK and its registry.
2
+ //
3
+ // A module is a workspace package `packages/module-<name>` (`@<project>/module-<name>`) built on the product's agent SDK (`packages/agent-sdk`): its
4
+ // template and workbench declaration (React-free, for the back end), and its workbench (drawn with Grammar alone). `add` writes the package from
5
+ // templates/app/module, registers it (root `workspaces`, the app dependencies on the registry and the module) and re-renders the managed registry
6
+ // package `packages/modules` (module-registry.mts, the same render `starci app sync` checks); `remove` reverses every one of those steps.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { TEMPLATES_DIR } from '../sync/index.mjs';
10
+ import { readRepoDeclaration, loadSlotManifest } from '../runtime/scripts/hfs/slots.mjs';
11
+ import { byCodeUnit } from '../report/order.mjs';
12
+ import { ScaffoldError, pascalOf, camelOf } from './service.mjs';
13
+ import { packageJsonText } from './app.mjs';
14
+ import { MODULE_PREFIX, REGISTRY_DIR, SDK_DIR, moduleNames, registryFiles, upperOf } from './module-registry.mjs';
15
+ const NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
16
+ const TEMPLATE_DIR = path.join(TEMPLATES_DIR, 'app', 'module');
17
+ const WORKSPACE_GLOB = 'packages/*';
18
+ const read = (file) => JSON.parse(fs.readFileSync(file, 'utf8'));
19
+ const sortedDependencies = (dependencies) => Object.fromEntries(Object.entries(dependencies).sort(([left], [right]) => byCodeUnit(left, right)));
20
+ const listFiles = (dir, base = dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
21
+ const full = path.join(dir, entry.name);
22
+ return entry.isDirectory() ? listFiles(full, base) : [path.relative(base, full).split(path.sep).join('/')];
23
+ });
24
+ /** A template's text with `{{project}}`, `{{name}}`, `{{pascal}}`, `{{camel}}` and `{{upper}}` filled; any other placeholder is a defect of the template. */
25
+ function fill(text, values, file) {
26
+ return text.replace(/\{\{([A-Za-z]\w*)\}\}/g, (whole, key) => {
27
+ const value = values[key];
28
+ if (value === undefined)
29
+ throw new ScaffoldError('HFS_ADD_PLACEHOLDER_UNKNOWN', `module template ${file} uses ${whole}, which add module does not provide`);
30
+ return value;
31
+ }).replace(/\r\n/g, '\n');
32
+ }
33
+ /** The package files written from code: the manifests are never template files. */
34
+ function manifests(project, name, versions) {
35
+ const sdk = `@${project}/agent-sdk`;
36
+ const exports = {
37
+ '.': { types: './dist/index.d.ts', default: './dist/index.js' },
38
+ './template': { types: './dist/template/index.d.ts', default: './dist/template/index.js' },
39
+ './manifest': { types: './dist/manifest/index.d.ts', default: './dist/manifest/index.js' },
40
+ './workbench': { types: './dist/esm/workbench/index.d.ts', default: './dist/esm/workbench/index.js' },
41
+ './public': { types: './dist/esm/public/index.d.ts', default: './dist/esm/public/index.js' },
42
+ };
43
+ const packageJson = {
44
+ name: `@${project}/${MODULE_PREFIX}${name}`,
45
+ version: '0.0.0',
46
+ private: true,
47
+ description: `The ${pascalOf(name)} module on the ${project} agent SDK: its template (./template) and workbench declaration (./manifest), both React-free for the back end, its Grammar-only workbench (./workbench) and its public face (./public) for the front end.`,
48
+ exports,
49
+ scripts: { build: 'tsc -p tsconfig.build.json && tsc -p tsconfig.build.workbench.json', typecheck: 'tsc --noEmit --pretty false', test: 'jest --coverage' },
50
+ dependencies: { [sdk]: '*' },
51
+ peerDependencies: { '@starci/grammar': versions['@starci/grammar'], react: versions['react'] },
52
+ devDependencies: sortedDependencies({ '@types/jest': versions['@types/jest'] ?? '', '@types/react': versions['@types/react'] ?? '', jest: versions['jest'] ?? '', 'ts-jest': versions['ts-jest'] ?? '', typescript: versions['typescript'] ?? '' }),
53
+ };
54
+ const build = { rootDir: 'src', noEmit: false, declaration: true, types: [] };
55
+ return [
56
+ { relative: 'package.json', body: packageJsonText(packageJson) },
57
+ { relative: 'tsconfig.json', body: packageJsonText({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: ['jest', 'node'] }, include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], exclude: ['node_modules', 'dist'] }) },
58
+ { relative: 'tsconfig.build.json', body: packageJsonText({ extends: './tsconfig.json', compilerOptions: { ...build, outDir: 'dist', module: 'node16', moduleResolution: 'node16' }, exclude: ['node_modules', 'dist', 'src/**/*.spec.ts', 'src/workbench/**', 'src/public/**'] }) },
59
+ { relative: 'tsconfig.build.workbench.json', body: packageJsonText({ extends: './tsconfig.json', compilerOptions: { ...build, outDir: 'dist/esm' }, include: ['src/**/*.ts', 'src/**/*.tsx', 'src/**/*.json'], exclude: ['node_modules', 'dist', 'src/**/*.spec.ts'] }) },
60
+ ];
61
+ }
62
+ /** The versions a module package declares, read from the app's own manifests so it installs the one version of each. */
63
+ function versionsOf(root, feAppFiles) {
64
+ const rootPackage = read(path.join(root, 'package.json'));
65
+ const all = { ...rootPackage.devDependencies, ...rootPackage.dependencies };
66
+ for (const file of feAppFiles)
67
+ Object.assign(all, read(file).dependencies);
68
+ const wanted = ['@starci/grammar', 'react', '@types/jest', '@types/react', 'jest', 'ts-jest', 'typescript'];
69
+ const missing = wanted.filter((name) => all[name] === undefined);
70
+ if (missing.length > 0)
71
+ throw new ScaffoldError('HFS_ADD_MODULE_PINS', `add module reads the versions of ${missing.join(', ')} from the app's manifests, which declare none of them`);
72
+ return Object.fromEntries(wanted.map((name) => [name, all[name]]));
73
+ }
74
+ /** The package.json of every front-end Next app of the app. */
75
+ const feManifests = (root) => {
76
+ const apps = path.join(root, 'fe', 'apps');
77
+ if (!fs.existsSync(apps))
78
+ return [];
79
+ return fs.readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory() && fs.existsSync(path.join(apps, entry.name, 'package.json'))).map((entry) => path.join(apps, entry.name, 'package.json')).sort(byCodeUnit);
80
+ };
81
+ /** Everything a verb changes, snapshotted first so a failure leaves the tree as it was. */
82
+ class Transaction {
83
+ before = new Map();
84
+ root;
85
+ constructor(root) { this.root = root; }
86
+ write(relative, body) {
87
+ const file = path.join(this.root, ...relative.split('/'));
88
+ if (!this.before.has(file))
89
+ this.before.set(file, fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null);
90
+ fs.mkdirSync(path.dirname(file), { recursive: true });
91
+ fs.writeFileSync(file, body);
92
+ }
93
+ remove(relative) {
94
+ const file = path.join(this.root, ...relative.split('/'));
95
+ if (!fs.existsSync(file))
96
+ return;
97
+ for (const inner of fs.statSync(file).isDirectory() ? listFiles(file).map((name) => path.join(file, ...name.split('/'))) : [file])
98
+ this.before.set(inner, fs.readFileSync(inner, 'utf8'));
99
+ fs.rmSync(file, { recursive: true, force: true });
100
+ }
101
+ rollback() {
102
+ for (const [file, text] of this.before) {
103
+ if (text === null)
104
+ fs.rmSync(file, { force: true });
105
+ else {
106
+ fs.mkdirSync(path.dirname(file), { recursive: true });
107
+ fs.writeFileSync(file, text);
108
+ }
109
+ }
110
+ }
111
+ }
112
+ function context(root, name) {
113
+ if (!NAME.test(String(name)))
114
+ throw new ScaffoldError('HFS_ADD_NAME_INVALID', `module name ${name} must be kebab-case (letters, digits and single dashes)`);
115
+ const declarationFile = path.join(root, 'hfs.json');
116
+ const packageFile = path.join(root, 'package.json');
117
+ if (!fs.existsSync(declarationFile) || !fs.existsSync(packageFile))
118
+ throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'a module verb runs at the app root containing hfs.json and package.json');
119
+ const repo = readRepoDeclaration(loadSlotManifest(), root);
120
+ return { project: repo.project, declarationFile, packageFile };
121
+ }
122
+ /** Sets or deletes one dependency of a manifest file, keeping the section sorted; answers whether the text changed. */
123
+ function setDependency(tx, root, file, name, version) {
124
+ const manifest = read(file);
125
+ const current = manifest.dependencies ?? {};
126
+ if ((version === null && current[name] === undefined) || current[name] === version)
127
+ return false;
128
+ const next = { ...current };
129
+ if (version === null)
130
+ Reflect.deleteProperty(next, name);
131
+ else
132
+ next[name] = version;
133
+ manifest.dependencies = sortedDependencies(next);
134
+ tx.write(path.relative(root, file).split(path.sep).join('/'), packageJsonText(manifest));
135
+ return true;
136
+ }
137
+ /** Registers the modules on the app: the workspace glob, and the registry and module dependencies of the back end (the root manifest) and every front-end app. */
138
+ function register(tx, root, project, packageFile, names, changed) {
139
+ const rootPackage = read(packageFile);
140
+ const workspaces = new Set(rootPackage.workspaces ?? []);
141
+ const others = fs.existsSync(path.join(root, 'packages')) && fs.readdirSync(path.join(root, 'packages')).some((entry) => !entry.startsWith(MODULE_PREFIX) && entry !== 'modules');
142
+ if (names.length > 0)
143
+ workspaces.add(WORKSPACE_GLOB);
144
+ else if (!others)
145
+ workspaces.delete(WORKSPACE_GLOB);
146
+ if ([...workspaces].join('|') !== (rootPackage.workspaces ?? []).join('|')) {
147
+ rootPackage.workspaces = [...workspaces];
148
+ tx.write('package.json', packageJsonText(rootPackage));
149
+ changed.push('package.json');
150
+ }
151
+ const registry = `@${project}/modules`;
152
+ if (setDependency(tx, root, packageFile, registry, names.length > 0 ? '*' : null))
153
+ changed.push('package.json');
154
+ for (const file of feManifests(root)) {
155
+ let touched = setDependency(tx, root, file, registry, names.length > 0 ? '*' : null);
156
+ const present = Object.keys(read(file).dependencies ?? {}).filter((dependency) => dependency.startsWith(`@${project}/${MODULE_PREFIX}`));
157
+ for (const dependency of present)
158
+ if (!names.some((name) => `@${project}/${MODULE_PREFIX}${name}` === dependency))
159
+ touched = setDependency(tx, root, file, dependency, null) || touched;
160
+ for (const name of names)
161
+ touched = setDependency(tx, root, file, `@${project}/${MODULE_PREFIX}${name}`, '*') || touched;
162
+ if (touched)
163
+ changed.push(path.relative(root, file).split(path.sep).join('/'));
164
+ }
165
+ }
166
+ /** Replaces the registry package by the render of `names`: files written, and a registry of no module deleted. */
167
+ function writeRegistry(tx, root, project, names, result) {
168
+ const files = registryFiles(project, names);
169
+ if (files.length === 0) {
170
+ if (fs.existsSync(path.join(root, REGISTRY_DIR))) {
171
+ tx.remove(REGISTRY_DIR);
172
+ result.removed.push(REGISTRY_DIR);
173
+ }
174
+ return;
175
+ }
176
+ for (const file of files) {
177
+ (fs.existsSync(path.join(root, file.path)) ? result.updated : result.created).push(file.path);
178
+ tx.write(file.path, file.content);
179
+ }
180
+ }
181
+ /** Adds the module package `name`, registers it and re-renders the registry. Refused when the app has no agent SDK package or the module exists. */
182
+ export function addModule({ root, name }) {
183
+ const { project, packageFile } = context(root, name);
184
+ if (!fs.existsSync(path.join(root, SDK_DIR, 'package.json')))
185
+ throw new ScaffoldError('HFS_ADD_MODULE_SDK_MISSING', `a module is built on the product's agent SDK, which must exist as ${SDK_DIR}/ (a workspace package named @${project}/agent-sdk)`);
186
+ const folder = `packages/${MODULE_PREFIX}${name}`;
187
+ if (fs.existsSync(path.join(root, folder)))
188
+ throw new ScaffoldError('HFS_ADD_EXISTS', `module ${name} already exists: ${folder}`);
189
+ const values = { project, name, pascal: pascalOf(name), camel: camelOf(name), upper: upperOf(name) };
190
+ const planned = [
191
+ ...manifests(project, name, versionsOf(root, feManifests(root))),
192
+ ...listFiles(TEMPLATE_DIR).map((file) => ({
193
+ relative: file.replace(/\.tpl$/, '').split('/').map((segment) => segment.replace('__name__', name).replace('__pascal__', values.pascal)).join('/'),
194
+ body: fill(fs.readFileSync(path.join(TEMPLATE_DIR, ...file.split('/')), 'utf8'), values, file),
195
+ })),
196
+ ];
197
+ const result = { created: planned.map((file) => `${folder}/${file.relative}`), updated: [], removed: [] };
198
+ const tx = new Transaction(root);
199
+ try {
200
+ for (const file of planned)
201
+ tx.write(`${folder}/${file.relative}`, file.body);
202
+ const names = moduleNames(root);
203
+ register(tx, root, project, packageFile, names, result.updated);
204
+ writeRegistry(tx, root, project, names, result);
205
+ }
206
+ catch (error) {
207
+ tx.rollback();
208
+ fs.rmSync(path.join(root, folder), { recursive: true, force: true });
209
+ throw error;
210
+ }
211
+ return result;
212
+ }
213
+ /** Removes the module package `name`, its registrations and its entry in the registry; the last module takes the registry package with it. */
214
+ export function removeModule({ root, name }) {
215
+ const { project, packageFile } = context(root, name);
216
+ const folder = `packages/${MODULE_PREFIX}${name}`;
217
+ if (!fs.existsSync(path.join(root, folder)))
218
+ throw new ScaffoldError('HFS_REMOVE_NOT_FOUND', `module ${name} does not exist: ${folder}`);
219
+ const result = { created: [], updated: [], removed: [folder] };
220
+ const tx = new Transaction(root);
221
+ try {
222
+ tx.remove(folder);
223
+ const names = moduleNames(root);
224
+ register(tx, root, project, packageFile, names, result.updated);
225
+ writeRegistry(tx, root, project, names, result);
226
+ }
227
+ catch (error) {
228
+ tx.rollback();
229
+ throw error;
230
+ }
231
+ return result;
232
+ }
@@ -300,6 +300,54 @@ function keysPatch(file) {
300
300
  lines.splice(last + 1, 0, ...KEY_ROWS);
301
301
  return { file, text: lines.join('\n') };
302
302
  }
303
+ const DEV_REDIS_PORT = 56379;
304
+ /** The redis service a dev compose file gains: loopback only, append-only, on the host port the environment declares. */
305
+ const devRedisService = ` redis:
306
+ image: ${REDIS_IMAGE}
307
+ command: ["redis-server", "--appendonly", "yes"]
308
+ ports:
309
+ - "127.0.0.1:\${STACK_PORT_REDIS}:6379"
310
+ volumes:
311
+ - redis-data:/data
312
+ healthcheck:
313
+ test: ["CMD", "redis-cli", "ping"]
314
+ interval: 5s
315
+ timeout: 3s
316
+ retries: 20
317
+ restart: unless-stopped
318
+ `;
319
+ /**
320
+ * A docker-compose dev environment (runtime docker-compose, a `local` block) runs the declared Redis from its compose file, on its own loopback
321
+ * port, and the queue processes reach it there: the compose service, the port, the service entry and the api and worker processes' queue settings.
322
+ */
323
+ function devQueuePatches(repoRoot, manifest, edit, consumers) {
324
+ const patches = [];
325
+ const dev = Object.entries(manifest?.environments ?? {}).filter(([, environment]) => environment?.runtime === 'docker-compose' && environment.local);
326
+ for (const [name, environment] of dev) {
327
+ const composeRelative = `.starcistacks/${name}/${environment.composeFiles?.[0] ?? 'infra/compose/compose.yaml'}`;
328
+ const composeFile = path.join(repoRoot, ...composeRelative.split('/'));
329
+ const compose = read(composeFile).replace(/\r\n/g, '\n');
330
+ if (/^ {2}redis:/m.test(compose))
331
+ continue;
332
+ const base = ['environments', name];
333
+ const value = edit.value.environments[name];
334
+ edit.append([...base, 'components'], `redis: {port: ${DEV_REDIS_PORT}}\n`);
335
+ value.components.redis = { port: DEV_REDIS_PORT };
336
+ edit.append([...base, 'local', 'ports'], `redis: ${DEV_REDIS_PORT}\n`);
337
+ value.local.ports.redis = DEV_REDIS_PORT;
338
+ edit.append([...base, 'local', 'services'], '- {name: redis, wait: healthy}\n');
339
+ value.local.services.push({ name: 'redis', wait: 'healthy' });
340
+ for (const run of value.local.processes ?? []) {
341
+ if (!consumers.includes(run.name))
342
+ continue;
343
+ edit.append([...base, 'local', 'processes', { item: 'name', is: run.name }, 'env'], 'QUEUE_ENABLED: "true"\nQUEUE_REDIS_HOST: 127.0.0.1\nQUEUE_REDIS_PORT: "${port.redis}"\n');
344
+ Object.assign(run.env, { QUEUE_ENABLED: 'true', QUEUE_REDIS_HOST: '127.0.0.1', QUEUE_REDIS_PORT: '${port.redis}' });
345
+ }
346
+ const head = compose.includes('\nvolumes:\n') ? compose.replace('\nvolumes:\n', `\n${devRedisService}volumes:\n`) : `${compose.replace(/\n*$/, '\n')}${devRedisService}volumes:\n`;
347
+ patches.push({ file: composeFile, text: `${head.replace(/\n*$/, '\n')} redis-data: {}\n` });
348
+ }
349
+ return patches;
350
+ }
303
351
  /** Every stack file the declared Redis adds to or changes, planned before anything is written. */
304
352
  function stackFiles(repoRoot, { project, consumers }) {
305
353
  const manifestRelative = '.starcistacks/application-stacks.yaml';
@@ -330,6 +378,7 @@ function stackFiles(repoRoot, { project, consumers }) {
330
378
  if (!fs.existsSync(path.join(repoRoot, ...recovery.split('/'))))
331
379
  created.push({ relative: recovery, text: recoveryRecord });
332
380
  }
381
+ patches.push(...devQueuePatches(repoRoot, manifest, edit, consumers));
333
382
  patches.unshift({ file: manifestFile, text: edit.text() });
334
383
  return { patches: patches.filter((patch) => patch !== null), created, environments: swarm.map(([name]) => name) };
335
384
  }
package/scaffold/add.mjs CHANGED
@@ -23,6 +23,7 @@ import { ScaffoldError, pascalOf } from './service.mjs';
23
23
  import { addTable, graphqlPlatformFiles } from './add-table.mjs';
24
24
  import { addModuleImport, compositionCode, readComposeGroup, writeComposeGroup } from './add-compose.mjs';
25
25
  import { addApp } from './add-app.mjs';
26
+ import { addModule } from './add-module.mjs';
26
27
  import { FE_UNIT_LAYERS, addFeUnit } from './add-fe-unit.mjs';
27
28
  import { addFePage } from './add-fe-page.mjs';
28
29
  import { addFeOverlay } from './add-fe-overlay.mjs';
@@ -57,6 +58,10 @@ export function addKind({ repoRoot, noun, name, options = {}, now = Date.now })
57
58
  const result = addApp({ root: repoRoot, name });
58
59
  return { ...result, registered: { patterns: [], kinds: [] } };
59
60
  }
61
+ if (noun === 'module') {
62
+ const result = addModule({ root: repoRoot, name });
63
+ return { created: [...result.created, ...result.updated], registered: { patterns: [], kinds: [] } };
64
+ }
60
65
  const feOwner = FE_OWNERS[noun];
61
66
  if (feOwner !== undefined || Object.hasOwn(FE_UNIT_LAYERS, noun)) {
62
67
  const result = feOwner === undefined ? addFeUnit({ repoRoot, noun, name, options }) : feOwner({ repoRoot, name, options });
package/scaffold/app.mjs CHANGED
@@ -18,7 +18,7 @@ import { spawnSync } from 'node:child_process';
18
18
  import { loadSlotManifest, resolveAppDeclaration } from '../runtime/scripts/hfs/slots.mjs';
19
19
  import { parseYaml } from '../runtime/engine/yaml.mjs';
20
20
  import { HARNESS_PACKAGE, canonPins } from '../runtime/scripts/hfs/canon-pins.mjs';
21
- import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, writeTargets } from '../sync/index.mjs';
21
+ import { TEMPLATES_DIR, appSource, imageFiles, render, renderTargets, settleSource, writeTargets } from '../sync/index.mjs';
22
22
  import { ScaffoldError } from './service.mjs';
23
23
  import { testWorldFiles } from './test-world.mjs';
24
24
  import { FE_APP_SCRIPTS, PACKAGE_MANAGER, WORKSPACES, feAppPackageName } from '../runtime/scripts/hfs/rules/monorepo.mjs';
@@ -158,15 +158,14 @@ const STARTER_DEPENDENCIES = Object.freeze({
158
158
  const LITE_STARTER_DEPENDENCIES = Object.freeze({
159
159
  dependencies: {
160
160
  '@apollo/server': null, '@nestjs/apollo': null, '@nestjs/common': null, '@nestjs/core': null, '@nestjs/cqrs': null, '@nestjs/graphql': null,
161
- '@nestjs/platform-express': null, '@nestjs/typeorm': null, '@nestjs/passport': null, passport: null, 'passport-google-oauth20': null, 'passport-local': null,
162
- '@aws-sdk/client-s3': null, '@aws-sdk/s3-request-presigner': null, bcryptjs: null,
163
- 'class-transformer': null, 'class-validator': null, graphql: null, jose: null, pg: null,
161
+ '@nestjs/platform-express': null, '@nestjs/typeorm': null, '@starci/auth': null, '@starci/object-storage': null,
162
+ 'class-transformer': null, 'class-validator': null, graphql: null, pg: null,
164
163
  'reflect-metadata': null, rxjs: null, tslib: null, typeorm: null, undici: null, zod: null,
165
164
  },
166
165
  devDependencies: {
167
166
  turbo: null, '@nestjs/testing': null, '@starci/eslint-canon-be': null, '@starci/eslint-canon-fe': null,
168
167
  '@starci/jest-preset': null, '@starci/prettier-config': null, '@starci/stylelint-canon': null, '@starci/tsconfig': null, 'starci-harness': null, '@tailwindcss/postcss': null,
169
- '@types/express': null, '@types/jest': null, '@types/node': null, '@types/passport-google-oauth20': null, '@types/passport-local': null, '@types/react': null, '@types/react-dom': null,
168
+ '@types/express': null, '@types/jest': null, '@types/node': null, '@types/react': null, '@types/react-dom': null,
170
169
  eslint: null, 'eslint-plugin-react-hooks': null, husky: null, jest: null, 'postcss-value-parser': null, prettier: null,
171
170
  stylelint: null, tailwindcss: null, 'ts-jest': null, 'ts-node-dev': null, 'tsc-alias': null,
172
171
  'tsconfig-paths': null, typescript: null,
@@ -187,7 +186,7 @@ const FE_APP_DEPENDENCIES = Object.freeze({
187
186
  * holds no identity SDK: modules/auth talks only to the app's own back-end auth doors.
188
187
  */
189
188
  const LITE_FE_APP_DEPENDENCIES = Object.freeze({
190
- '@heroicons/react': null, '@heroui/react': null, '@heroui/styles': null, '@starci/grammar': null, '@starci/heroicons': null, '@xyflow/react': null, 'react-aria-components': null,
189
+ '@heroicons/react': null, '@heroui/react': null, '@heroui/styles': null, '@starci/auth': null, '@starci/grammar': null, '@starci/heroicons': null, '@xyflow/react': null, 'react-aria-components': null,
191
190
  next: null, 'next-intl': null, react: null, 'react-dom': null, 'server-only': null,
192
191
  });
193
192
  /**
@@ -656,7 +655,8 @@ export function scaffoldApp({ name, into, presets, harness, edition = 'full', re
656
655
  try {
657
656
  for (const file of files)
658
657
  writes.write(file.path, file.content);
659
- const targets = renderTargets(declaration, presets, { manifest, source: appSource(root) });
658
+ const source = appSource(root);
659
+ const targets = renderTargets(declaration, presets, { manifest, source: settleSource(source, renderTargets(declaration, presets, { manifest, source })) });
660
660
  for (const target of targets)
661
661
  writes.claim(target.path);
662
662
  writeTargets(root, targets);
@@ -0,0 +1,19 @@
1
+ /** The folder name prefix of a module package. */
2
+ export declare const MODULE_PREFIX = "module-";
3
+ /** The package that lists every module package. */
4
+ export declare const REGISTRY_DIR = "packages/modules";
5
+ /** The product's agent SDK package folder every module builds on. */
6
+ export declare const SDK_DIR = "packages/agent-sdk";
7
+ /** A file of the registry package: its app-relative path and its content. */
8
+ export interface RegistryFile {
9
+ readonly path: string;
10
+ readonly content: string;
11
+ }
12
+ /** `loyalty-points` as the UPPER_SNAKE prefix of the constants a module exports. */
13
+ export declare const upperOf: (name: string) => string;
14
+ /** The module names under `packages/` of the app at `root` (a folder module-<name> holding a package.json), sorted. */
15
+ export declare function moduleNames(root: string): string[];
16
+ /** The files of the registry package for `names` (empty when no module exists: the package is then absent). */
17
+ export declare function registryFiles(project: string, names: readonly string[]): RegistryFile[];
18
+ /** The PascalCase label of a module, for the messages that name it. */
19
+ export declare const labelOf: (name: string) => string;
@@ -0,0 +1,84 @@
1
+ // module-registry.mts - the module packages of an app (packages/module-<name>) and the registry package (packages/modules) that lists them.
2
+ //
3
+ // The registry is managed: its manifests and its three source files are rendered from the module list, by `starci app add|remove module` and by
4
+ // `starci app sync` (the same function), so a hand edit is drift. A module package is written once by `add module` and is the product's from then on.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { byCodeUnit } from '../report/order.mjs';
8
+ import { pascalOf } from './service.mjs';
9
+ /** The folder name prefix of a module package. */
10
+ export const MODULE_PREFIX = 'module-';
11
+ /** The package that lists every module package. */
12
+ export const REGISTRY_DIR = 'packages/modules';
13
+ /** The product's agent SDK package folder every module builds on. */
14
+ export const SDK_DIR = 'packages/agent-sdk';
15
+ const HEADER = '// Generated by starci app sync (module registry). Do not edit: run "npx starci app sync --execute".';
16
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
17
+ /** `loyalty-points` as the UPPER_SNAKE prefix of the constants a module exports. */
18
+ export const upperOf = (name) => name.replace(/-/g, '_').toUpperCase();
19
+ /** The module names under `packages/` of the app at `root` (a folder module-<name> holding a package.json), sorted. */
20
+ export function moduleNames(root) {
21
+ const folder = path.join(root, 'packages');
22
+ if (!fs.existsSync(folder))
23
+ return [];
24
+ return fs.readdirSync(folder, { withFileTypes: true })
25
+ .filter((entry) => entry.isDirectory() && entry.name.startsWith(MODULE_PREFIX) && fs.existsSync(path.join(folder, entry.name, 'package.json')))
26
+ .map((entry) => entry.name.slice(MODULE_PREFIX.length))
27
+ .sort(byCodeUnit);
28
+ }
29
+ /** The exports map of the registry and of a module: the React-free halves and, for a module, its Grammar workbench. */
30
+ const registryExports = () => ({
31
+ '.': { types: './dist/index.d.ts', default: './dist/index.js' },
32
+ './template': { types: './dist/template/index.d.ts', default: './dist/template/index.js' },
33
+ './manifest': { types: './dist/manifest/index.d.ts', default: './dist/manifest/index.js' },
34
+ });
35
+ /** The files of the registry package for `names` (empty when no module exists: the package is then absent). */
36
+ export function registryFiles(project, names) {
37
+ if (names.length === 0)
38
+ return [];
39
+ const sorted = [...names].sort(byCodeUnit);
40
+ const sdk = `@${project}/agent-sdk`;
41
+ const module = (name) => `@${project}/${MODULE_PREFIX}${name}`;
42
+ const serverImports = sorted.map((name) => `import { ${upperOf(name)}_SERVER } from "${module(name)}/template"`).join('\n');
43
+ const workbenchImports = sorted.map((name) => `import { ${upperOf(name)}_WORKBENCH } from "${module(name)}/manifest"`).join('\n');
44
+ const dependencies = Object.fromEntries([[sdk, '*'], ...sorted.map((name) => [module(name), '*'])].sort(([a], [b]) => byCodeUnit(a, b)));
45
+ return [
46
+ {
47
+ path: `${REGISTRY_DIR}/package.json`,
48
+ content: json({
49
+ name: `@${project}/modules`,
50
+ version: '0.0.0',
51
+ private: true,
52
+ description: 'The one list of the product modules built on the agent SDK, all React-free: ./template (every module\'s server half, for the back end) and ./manifest (every workbench declaration and the spec registry they assemble).',
53
+ exports: registryExports(),
54
+ scripts: { build: 'tsc -p tsconfig.build.json', typecheck: 'tsc --noEmit --pretty false' },
55
+ dependencies,
56
+ }),
57
+ },
58
+ {
59
+ path: `${REGISTRY_DIR}/tsconfig.json`,
60
+ content: json({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: [] }, include: ['src/**/*.ts'], exclude: ['node_modules', 'dist'] }),
61
+ },
62
+ {
63
+ path: `${REGISTRY_DIR}/tsconfig.build.json`,
64
+ content: json({
65
+ extends: './tsconfig.json',
66
+ compilerOptions: { rootDir: 'src', outDir: 'dist', noEmit: false, declaration: true, module: 'node16', moduleResolution: 'node16' },
67
+ }),
68
+ },
69
+ {
70
+ path: `${REGISTRY_DIR}/src/index.ts`,
71
+ content: `${HEADER}\nimport { MODULE_SERVERS } from "./template"\n\n/** The keys of every module this registry lists, for a host that checks its own wiring against it. */\nexport const REGISTERED_MODULE_KEYS: ReadonlyArray<string> = MODULE_SERVERS.map((module) => module.key)\n`,
72
+ },
73
+ {
74
+ path: `${REGISTRY_DIR}/src/template/index.ts`,
75
+ content: `${HEADER}\nimport type { ModuleServer } from "${sdk}/module"\n${serverImports}\n\n/** The server half of every module built on the SDK; the back end reads nothing else of a module. */\nexport const MODULE_SERVERS: ReadonlyArray<ModuleServer> = [${sorted.map((name) => `${upperOf(name)}_SERVER`).join(', ')}]\n`,
76
+ },
77
+ {
78
+ path: `${REGISTRY_DIR}/src/manifest/index.ts`,
79
+ content: `${HEADER}\nimport { specRegistryFrom } from "${sdk}/workbench/contract"\nimport type { WorkbenchDeclaration, WorkbenchSpecRegistry } from "${sdk}/workbench/contract"\n${workbenchImports}\n\n/** The workbench declaration of every module built on the SDK; the front end pairs each with how it draws. */\nexport const MODULE_WORKBENCHES: ReadonlyArray<WorkbenchDeclaration> = [${sorted.map((name) => `${upperOf(name)}_WORKBENCH`).join(', ')}]\n\n/** Everything a stored spec may name: the SDK's blocks and each module's exposed blocks, reads and actions. */\nexport const SPEC_REGISTRY: WorkbenchSpecRegistry = specRegistryFrom(MODULE_WORKBENCHES)\n`,
80
+ },
81
+ ];
82
+ }
83
+ /** The PascalCase label of a module, for the messages that name it. */
84
+ export const labelOf = (name) => pascalOf(name);
package/src/main.mjs CHANGED
@@ -101,6 +101,7 @@ import { emitContractsAsync } from '../emit/contracts.mjs';
101
101
  import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
102
102
  import { EditionRefusal } from '../scaffold/edition-gate.mjs';
103
103
  import { addKind } from '../scaffold/add.mjs';
104
+ import { removeModule } from '../scaffold/add-module.mjs';
104
105
  import { scaffoldApp } from '../scaffold/app.mjs';
105
106
  import { newImages } from '../scaffold/image.mjs';
106
107
  import { cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
@@ -140,7 +141,7 @@ const listFlag = (opts, flag) => { const given = opts[flag]; return Array.isArra
140
141
  /** An argument the parser refuses (an unknown flag, a missing value, a form a verb does not take): the one error `main` follows with the usage. */
141
142
  export class UsageError extends Error {
142
143
  }
143
- export const VERBS = Object.freeze(['scaffold', 'add', 'sync', 'check', 'upgrade', 'stack', 'world', 'release', 'slot', 'emit', 'secret', 'ci', 'db', 'coverage', 'codemod']);
144
+ export const VERBS = Object.freeze(['scaffold', 'add', 'remove', 'sync', 'check', 'upgrade', 'stack', 'world', 'release', 'slot', 'emit', 'secret', 'ci', 'db', 'coverage', 'codemod']);
144
145
  const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['new', dbNewMain], ['drift', dbDriftMain]]);
145
146
  /**
146
147
  * The verbs parsed here, each with the only flags it takes ({ boolean, value, list }); a flag of another verb is refused, never ignored.
@@ -158,12 +159,13 @@ const PARSED = {
158
159
  add: {
159
160
  run: addMain, ...ADD_FLAGS, list: ['--inject'],
160
161
  forms: [
161
- { operands: '<api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|block|frame|composite|branch|leaf|page|overlay|layout> <name>', only: ADD_FLAGS },
162
+ { operands: '<api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app|module|block|frame|composite|branch|leaf|page|overlay|layout> <name>', only: ADD_FLAGS },
162
163
  { operands: 'service <dir> <name>', only: { list: ['--inject'] } },
163
164
  { operands: 'spec <file>.service.ts', flags: [] },
164
165
  { operands: 'image', flags: [] },
165
166
  ],
166
167
  },
168
+ remove: { run: removeMain, forms: [{ operands: 'module <name>', flags: [] }] },
167
169
  check: { run: checkMain, value: ['--coverage-from', '--shard', '--side', '--out'], list: GATE_FLAGS, boolean: ['--json', '--staged', '--no-cache', '--fix'], edition: true, forms: [{ only: { boolean: ['--json', '--no-cache', '--fix'], list: GATE_FLAGS, value: ['--coverage-from', '--shard', '--side', '--out'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
168
170
  slot: { run: slotMain, boolean: ['--json'], forms: [{ operands: '<path>' }] },
169
171
  emit: { run: emitMain, forms: [{}] },
@@ -204,6 +206,11 @@ const VERB_USAGE = {
204
206
  world: ['starci app world <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--edition full|lite]'],
205
207
  stack: [
206
208
  ...STACK_ACTIONS.map((action) => line(`starci app stack ${action}`, STACK_OPERANDS[action], STACK_READ_ONLY.includes(action) ? flagUsage('--env', { value: true }) : '--env <env>', STACK_FLAGS[action].map((flag) => flagUsage(flag, { value: STACK_VALUE.has(flag), repeat: STACK_REPEAT.has(flag) })), '[--json]')),
209
+ // The dev actions of a docker-compose environment (stack/local-cli.mts); prepare, status and verify above answer them too.
210
+ 'starci app stack doctor --env <env> [--json]',
211
+ 'starci app stack up --env <env> [--execute] [--timeout <seconds>] [--json]',
212
+ 'starci app stack down --env <env> [--execute] [--json]',
213
+ 'starci app stack logs --env <env> --service <name> [--lines <n>] [--json]',
207
214
  ],
208
215
  release: Object.entries(RELEASE_FLAGS).map(([action, spec]) => line(`starci app release ${action}`, specUsage(spec))),
209
216
  secret: SECRET_VERBS.map((verb) => line(`starci app secret ${verb}`, secretUsage(verb))),
@@ -680,6 +687,20 @@ function addMain(opts, { cwd: repoRoot, stdout: out }) {
680
687
  out(`starci app add ${noun}: registered patterns ${registered.patterns.join(', ')}${kinds}\n`);
681
688
  return 0;
682
689
  }
690
+ /** `remove module <name>`: deletes the module package and its registrations and re-renders the registry. */
691
+ function removeMain(opts, { cwd: repoRoot, stdout: out }) {
692
+ const [noun, name, ...extra] = opts.positional;
693
+ if (noun !== 'module' || !name || extra.length)
694
+ throw new UsageError('starci app remove takes `module <name>`');
695
+ const { removed, updated, created } = removeModule({ root: repoRoot, name });
696
+ for (const file of removed)
697
+ out(`removed ${file}
698
+ `);
699
+ for (const file of [...updated, ...created])
700
+ out(`updated ${file}
701
+ `);
702
+ return 0;
703
+ }
683
704
  async function scaffoldMain(opts, { cwd, harness, edition = 'full', presets, lock, stdout: out }) {
684
705
  const [name, ...extra] = opts.positional;
685
706
  const registryVisibility = valueFlag(opts, 'registry-visibility');
package/stack/cli.mjs CHANGED
@@ -18,6 +18,7 @@ import { withDockerTransport } from './docker-transport.mjs';
18
18
  import { StackError, EXIT, refusal } from './errors.mjs';
19
19
  import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './target-binding.mjs';
20
20
  import { oneCommand, prepareUrgentRelease } from './urgent-release.mjs';
21
+ import { isLocalEnvironment, isLocalOnly, isLocalShared, runLocal } from './local-cli.mjs';
21
22
  /** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
22
23
  const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune', 'status']);
23
24
  export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status', 'ci-key']);
@@ -185,8 +186,12 @@ export async function stackMain(argv, io = {}) {
185
186
  const ctx = stackContext(io);
186
187
  try {
187
188
  const action = argv[0];
189
+ if (isLocalOnly(action))
190
+ return await runLocal(ctx, action, argv.slice(1), io.localDeps);
188
191
  if (!isAction(action))
189
- throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'up', 'down', 'status'].join(', ')}`);
192
+ throw refusal('STACK_USAGE', `starci app stack takes ${[...ACTIONS, 'doctor', 'up', 'down', 'logs'].join(', ')}`);
193
+ if (isLocalShared(action) && argv.includes('--env') && isLocalEnvironment(ctx.cwd, String(argv[argv.indexOf('--env') + 1])))
194
+ return await runLocal(ctx, action, argv.slice(1), io.localDeps);
190
195
  const { flags, positional } = parseFlags(action, argv.slice(1));
191
196
  if (flags.env === undefined && !READ_ONLY.includes(action))
192
197
  throw refusal('STACK_USAGE', `stack ${action} has an effect: name its environment with --env <env> (only ${READ_ONLY.join(', ')} default to production)`);
@@ -47,6 +47,16 @@ export interface PipeResult {
47
47
  sink: number;
48
48
  stderr: string;
49
49
  }
50
+ /** One heavy command the dev verbs run (`npm ci`, a compose build or up): admitted by the harness's heavy-command governor when it hands one in. */
51
+ export interface HeavyRequest {
52
+ kind: 'install' | 'build';
53
+ label: string;
54
+ cwd: string;
55
+ argv: string[];
56
+ env: NodeJS.ProcessEnv;
57
+ }
58
+ /** The governed runner: waits for a slot of the host-wide semaphore, runs the argv without a shell and returns its result. */
59
+ export type HeavyRun = (request: HeavyRequest) => Promise<RunResult>;
50
60
  /** The secret seam: decrypts the app's sops files. */
51
61
  export interface Sops {
52
62
  decrypt(file: string, format?: string): Record<string, string>;
@@ -82,6 +92,9 @@ export interface StackIo {
82
92
  role?: string | undefined;
83
93
  } | undefined;
84
94
  importer?: ((file: string) => Promise<Record<string, unknown>>) | undefined;
95
+ heavy?: HeavyRun | undefined;
96
+ /** The seams of the dev verbs a spec replaces (stack/local-run.mjs LocalDeps). */
97
+ localDeps?: import('./local-run.mjs').LocalDeps | undefined;
85
98
  }
86
99
  /** The context of one stack invocation (see stackContext). */
87
100
  export interface StackContext {
@@ -98,6 +111,8 @@ export interface StackContext {
98
111
  readonly fetch: typeof fetch;
99
112
  readonly pipe: (source: PipeEnd, sink: PipeEnd) => Promise<PipeResult>;
100
113
  readonly sops: Sops;
114
+ /** The governed runner of heavy commands (the harness CLI hands it in); absent, a dev verb runs them directly. */
115
+ readonly heavy?: HeavyRun | undefined;
101
116
  /** The command-owned SSH connection, set by the Docker transport (docker-transport.mjs). */
102
117
  readonly stackSsh?: SshConnection | undefined;
103
118
  }
package/stack/context.mjs CHANGED
@@ -74,6 +74,7 @@ export function stackContext(io = {}) {
74
74
  stderr: io.stderr ?? ((text) => process.stderr.write(text)),
75
75
  fetch: io.fetch ?? globalThis.fetch,
76
76
  pipe: io.pipe ?? realPipe,
77
+ heavy: io.heavy,
77
78
  /** The secret seam: the app's own sops/age tools, created on first use so verbs that open no custody never need them. */
78
79
  get sops() {
79
80
  const opened = sops ?? io.openSops?.() ?? defaultSops(env, cwd, io.platform ?? process.platform, undefined, io.hostIdentity ?? null);
@@ -0,0 +1,25 @@
1
+ import { isLocalEnvironment } from './local-config.mjs';
2
+ import type { StackContext } from './context.mjs';
3
+ import type { LocalDeps } from './local-run.mjs';
4
+ /** The actions that exist only for a dev environment. */
5
+ export declare const LOCAL_ONLY: readonly ["doctor", "up", "down", "logs"];
6
+ /** The actions a swarm environment already has, which a docker-compose environment answers with its dev implementation. */
7
+ export declare const LOCAL_SHARED: readonly ["prepare", "status", "verify"];
8
+ /** True when `value` is an action only a dev environment has. */
9
+ export declare const isLocalOnly: (value: unknown) => boolean;
10
+ /** True when `value` is an action both kinds have. */
11
+ export declare const isLocalShared: (value: unknown) => boolean;
12
+ /** The parsed flags of a dev action. */
13
+ export interface LocalFlags {
14
+ env?: string;
15
+ json: boolean;
16
+ execute: boolean;
17
+ timeout?: string;
18
+ service?: string;
19
+ lines?: string;
20
+ }
21
+ /** Parses the flags of one dev action; a flag of another action, an unknown flag and a missing value are refusals. */
22
+ export declare function parseLocalFlags(action: string, argv: readonly string[]): LocalFlags;
23
+ /** Runs one dev action and returns the exit code. */
24
+ export declare function runLocal(ctx: StackContext, action: string, argv: readonly string[], deps?: LocalDeps): Promise<number>;
25
+ export { isLocalEnvironment };