@starci/hfs 5.3.0 → 5.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/account-doors.mjs +7 -1
  6. package/codemod/app-prettier.d.mts +5 -0
  7. package/codemod/app-prettier.mjs +27 -0
  8. package/codemod/explicit-surface.mjs +1 -25
  9. package/codemod/main.mjs +14 -5
  10. package/codemod/module-definition.d.mts +10 -3
  11. package/codemod/module-definition.mjs +32 -17
  12. package/codemod/seeds-layout.d.mts +36 -0
  13. package/codemod/seeds-layout.mjs +113 -0
  14. package/codemod/sops-rules.d.mts +1 -1
  15. package/codemod/sops-rules.mjs +8 -3
  16. package/lint/run.d.mts +5 -4
  17. package/lint/run.mjs +53 -27
  18. package/package.json +1 -1
  19. package/release/lint-report.mjs +4 -3
  20. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  21. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  22. package/runtime/knowledge/hfs/rules.yaml +15 -2
  23. package/runtime/knowledge/hfs/slots.yaml +15 -18
  24. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  25. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  26. package/runtime/scripts/hfs/check.mjs +4 -1
  27. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  28. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  29. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  30. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  31. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  32. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  33. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  34. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  35. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  37. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  38. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  39. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  40. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  41. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  43. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  44. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  45. package/runtime/scripts/hfs/slots.d.mts +2 -0
  46. package/runtime/scripts/hfs/slots.mjs +1 -0
  47. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  48. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  49. package/runtime/scripts/hfs/view.d.mts +25 -0
  50. package/runtime/scripts/hfs/view.mjs +41 -0
  51. package/scaffold/add-app.mjs +2 -2
  52. package/scaffold/add-cli-lite.mjs +41 -14
  53. package/scaffold/add-module.mjs +6 -3
  54. package/scaffold/lite-exports.mjs +1 -1
  55. package/scaffold/module-registry.mjs +10 -8
  56. package/src/main.mjs +8 -8
  57. package/stack/deploy.d.mts +3 -0
  58. package/stack/deploy.mjs +4 -1
  59. package/stack/local-up.mjs +25 -3
  60. package/stack/seeds.d.mts +29 -0
  61. package/stack/seeds.mjs +197 -0
  62. package/sync/canon-layout.d.mts +38 -0
  63. package/sync/canon-layout.mjs +62 -0
  64. package/sync/ci-commands.d.mts +2 -1
  65. package/sync/ci-commands.mjs +10 -7
  66. package/sync/compile-guard.d.mts +36 -0
  67. package/sync/compile-guard.mjs +189 -0
  68. package/sync/index.d.mts +22 -3
  69. package/sync/index.mjs +80 -32
  70. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  71. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  72. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  73. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  74. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  76. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  77. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  78. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  79. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  80. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  81. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  82. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  83. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  84. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  85. package/templates/app/skeleton/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  87. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  92. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  93. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  94. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  95. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  96. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  97. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  98. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  99. package/templates/app/skeleton-lite/README.md +2 -2
  100. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
  101. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  107. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  108. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  109. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  110. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  111. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  112. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  113. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  114. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  115. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  116. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  117. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  118. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
  119. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
  120. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  121. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  122. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  123. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  124. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  125. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  126. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  127. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  128. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  129. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  130. package/templates/be/tool-config/jest.config.js +1 -1
  131. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  132. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  133. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  134. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  135. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  136. package/templates/app/module/jest.config.js.tpl +0 -8
  137. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
@@ -1,7 +1,7 @@
1
1
  // stacks.mjs - HFS_STACKS_SHAPE (R10): standard stack shape, Sonar owner and deployment runtime consistency.
2
2
  // - every tracked path under the app root's `.starcistacks/` is one the app.starcistacks slot allows (its `allows` list is the shape:
3
3
  // application-stacks.yaml and <env>/{README.md, environment.json, infra/{compose,swarm,k8s,terraform}, runtime/{config,env},
4
- // secrets/<slug>.enc.yaml, seeds}); a `.enc` outside <env>/secrets/ is never allowed, `runtime/files/`, a root `DESIGN.md`,
4
+ // secrets/<slug>.enc.yaml}, and the seed levels seeds/ and <env>/seeds/); a `.enc` outside <env>/secrets/ is never allowed, `runtime/files/`, a root `DESIGN.md`,
5
5
  // `deployment.json` and `k8s/` are not in the list, so they fall out of it;
6
6
  // - the declaration (read by scripts/lib/stack-declaration.mjs, the one reader of it) states a sonar service, a local
7
7
  // Sonar is owned by the host (`stack.owner: host`, root `.claude/ext/sonar`).
@@ -15,6 +15,8 @@ const STACKS_SHAPE = 'HFS_STACKS_SHAPE';
15
15
  const STACKS_SLOT = 'app.starcistacks';
16
16
  const HOST_SONAR_ROOT = '.claude/ext/sonar';
17
17
  const INSIDE_SECRETS = /^[^/]+\/secrets\/[^/]+\.enc(?:\.yaml)?$/;
18
+ /** The folders of .starcistacks/ that are no environment, each with what it holds. */
19
+ const RESERVED = [['cache', 'the committed check cache'], ['seeds', 'the shared seed level']];
18
20
  /** The anchored expressions of a slot's `allows` entries: `<name>` is one path segment, braces alternate. */
19
21
  const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(entry.replace(/<[a-z][a-z0-9-]*>/g, '*'))).map((variant) => globExpression(variant.endsWith('/') ? `${variant}**` : variant));
20
22
  const shapeFinding = (file, message, extra) => found(STACKS_SHAPE, file, message, extra);
@@ -26,7 +28,7 @@ function treeFindings(files, allowed, prefix) {
26
28
  if (/\.enc(?:\.yaml)?$/.test(rel) && !INSIDE_SECRETS.test(rel))
27
29
  findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc.yaml; move it to .starcistacks/<env>/secrets/`));
28
30
  else if (!allowed.some((expression) => expression.test(rel)))
29
- findings.push(shapeFinding(file, `${file} is not part of the standard .starcistacks shape (application-stacks.yaml, images/db/Dockerfile in lite and <env>/{README.md, environment.json, infra, runtime/{config,env}, secrets/<slug>.enc.yaml, seeds}); move or delete it`));
31
+ findings.push(shapeFinding(file, `${file} is not part of the standard .starcistacks shape (application-stacks.yaml, images/db/Dockerfile in lite and <env>/{README.md, environment.json, infra, runtime/{config,env}, secrets/<slug>.enc.yaml}, and the seed levels seeds/ and <env>/seeds/ with their seeds.yaml, sql/, data/ and files/); move or delete it`));
30
32
  }
31
33
  return findings;
32
34
  }
@@ -36,8 +38,10 @@ function declarationFindings(files, prefix, declaration) {
36
38
  return [];
37
39
  const findings = [];
38
40
  const environments = declaration.doc?.environments;
39
- if (environments !== null && typeof environments === 'object' && Object.hasOwn(environments, 'cache'))
40
- findings.push(shapeFinding(declared, `${declared} declares an environment named cache; the name is reserved for the committed check cache (.starcistacks/cache/)`));
41
+ for (const [name, holds] of RESERVED) {
42
+ if (environments !== null && typeof environments === 'object' && Object.hasOwn(environments, name))
43
+ findings.push(shapeFinding(declared, `${declared} declares an environment named ${name}; the name is reserved for ${holds} (.starcistacks/${name}/)`));
44
+ }
41
45
  if (declaration.error)
42
46
  return [shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
43
47
  const services = declaration.doc?.services;
@@ -34,6 +34,8 @@ export interface RepoView {
34
34
  profile: string;
35
35
  apps: readonly AppRef[];
36
36
  optionalSlots: readonly string[];
37
+ /** How the app keeps its agent modules (app view only): `packages` (module packages and the rendered registry) unless hfs.json declares `modules: "data"`. */
38
+ modules?: 'packages' | 'data';
37
39
  patterns?: readonly string[];
38
40
  kinds?: readonly string[];
39
41
  backing?: readonly string[];
@@ -177,6 +177,7 @@ export function resolveRepoDeclaration(manifest, declaration, { file = HFS_DECLA
177
177
  // tsconfig.json that type-checks it, both switched on by `browser: true`.
178
178
  apps: Object.freeze([]),
179
179
  optionalSlots: Object.freeze(declaration.browser === true ? ['app.browser', 'app.browser-config'] : []),
180
+ modules: declaration.modules === 'data' ? 'data' : 'packages',
180
181
  connections: Object.freeze([]),
181
182
  providers,
182
183
  reads: Object.freeze([]),
@@ -0,0 +1,88 @@
1
+ import type { StackProblem } from './stack-config-kit.mjs';
2
+ export declare const SEEDS_SCHEMA = "starci/seeds@1";
3
+ /** The folder name of a seed level, at `.starcistacks/` and under each environment. */
4
+ export declare const SEEDS_DIR = "seeds";
5
+ /** The manifest of a level. */
6
+ export declare const SEEDS_MANIFEST = "seeds.yaml";
7
+ /** The kinds of entry; each lives in the folder `kindFolder` names. */
8
+ export declare const SEED_KINDS: readonly ["sql", "data", "test-account", "files"];
9
+ /** The folders of a level. */
10
+ export declare const SEED_FOLDERS: readonly ["sql", "data", "files"];
11
+ /** `once`: applied a single time; `upsert`: applied again whenever its content hash changes. */
12
+ export declare const SEED_MODES: readonly ["once", "upsert"];
13
+ /** The placeholder that keeps an empty seed folder tracked. */
14
+ export declare const SEEDS_KEEP = ".gitkeep";
15
+ /** The kind of one entry. */
16
+ export type SeedKind = (typeof SEED_KINDS)[number];
17
+ /** One entry of a level's manifest, normalised; `target` is the database (`sql`), the app command argv (`data`, `test-account`) or the bucket (`files`). */
18
+ export interface SeedEntry {
19
+ id: string;
20
+ kind: SeedKind;
21
+ path: string;
22
+ target: string | string[];
23
+ mode: (typeof SEED_MODES)[number];
24
+ after: string[];
25
+ schema: string | null;
26
+ reason: string | null;
27
+ }
28
+ /** One document of a data or test-account entry, level-relative, parsed or with the reason it does not parse. */
29
+ export type SeedDocument = {
30
+ path: string;
31
+ doc: unknown;
32
+ error?: undefined;
33
+ } | {
34
+ path: string;
35
+ error: string;
36
+ doc?: undefined;
37
+ };
38
+ /** A level as the check and the seed step see it: its app-relative folder, its entries in apply order and its problems. */
39
+ export interface SeedLevel {
40
+ dir: string;
41
+ env: string | null;
42
+ entries: SeedEntry[];
43
+ problems: StackProblem[];
44
+ }
45
+ /** The name of a sealed member a seed document may reference. */
46
+ export declare const SEED_SECRET_MEMBER: RegExp;
47
+ /** The folder of a level a kind's paths live in: a test account is data. */
48
+ export declare const kindFolder: (kind: SeedKind) => (typeof SEED_FOLDERS)[number];
49
+ /** True for an entry the app command loads as documents: `data` and `test-account`. */
50
+ export declare const isDocumentEntry: (entry: Pick<SeedEntry, "kind">) => boolean;
51
+ /** The sealed member file of `member` in the environment `env`, app-relative. */
52
+ export declare const seedMemberFile: (env: string, member: string) => string;
53
+ /** The app-relative folder of a level: the shared one when `env` is null. */
54
+ export declare const seedLevelDir: (env: string | null) => string;
55
+ /** Every file under `folder` (absolute) as a path relative to `base`, posix and sorted, the placeholders aside; a missing folder holds none. */
56
+ export declare function filesBelow(base: string, folder: string): string[];
57
+ /** The level-relative files an entry covers: its path when it is a file, every file below it when it is a folder. */
58
+ export declare function entryFiles(levelRoot: string, entry: Pick<SeedEntry, 'path'>): string[];
59
+ /** The content hash of an entry: its kind, target, mode and every file it covers, by path and bytes. */
60
+ export declare function entryDigest(levelRoot: string, entry: SeedEntry): string;
61
+ /**
62
+ * Normalises the parsed manifest of the level at `levelRoot`; `shared` holds the ids of the shared level an environment's entries may follow,
63
+ * and is null for the shared level itself, which declares no test account (an environment's level declares at most one).
64
+ */
65
+ export declare function normalizeSeeds(doc: unknown, levelRoot: string, shared?: ReadonlySet<string> | null): {
66
+ entries: SeedEntry[];
67
+ problems: StackProblem[];
68
+ };
69
+ /** Reads and judges the level of `env` (null: the shared level) at `repoRoot`; a level without a manifest has no entries. */
70
+ export declare function readSeedLevel(repoRoot: string, env: string | null, shared?: ReadonlySet<string>): SeedLevel;
71
+ /** The documents of a data or test-account entry: every JSON or YAML file it covers, parsed. */
72
+ export declare function seedDocuments(levelRoot: string, entry: Pick<SeedEntry, 'path'>): SeedDocument[];
73
+ /** The member a value references when it is a secret reference (`{ "secret": "<member>" }` and nothing else), else null. */
74
+ export declare function secretReference(value: unknown): string | null;
75
+ /** Every member the secret references of a document name, in document order, once each. */
76
+ export declare function secretReferences(doc: unknown): string[];
77
+ /** A copy of `doc` with every secret reference replaced by what `resolve` answers for its member. */
78
+ export declare function resolveSecrets(doc: unknown, resolve: (member: string) => unknown): unknown;
79
+ /** An entry to declare in a manifest: `after` and `schema` as the manifest spells them, left out when empty. */
80
+ export type SeedEntryInput = Pick<SeedEntry, 'id' | 'kind' | 'path' | 'target' | 'mode'> & {
81
+ after?: string[];
82
+ schema?: string;
83
+ };
84
+ /**
85
+ * Declares `entries` in the manifest of the level of `env` (null: the shared level), creating the manifest when the level has none; an id the
86
+ * manifest already holds is kept as it is. Answers the ids it added (none are written when `write` is false).
87
+ */
88
+ export declare function addSeedEntries(repoRoot: string, env: string | null, entries: SeedEntryInput[], write?: boolean): string[];
@@ -0,0 +1,239 @@
1
+ // stack-seeds-config.mjs - reads and judges a seed level's manifest (`seeds.yaml`, schema starci/seeds@1), the declaration of the data an app
2
+ // seeds (contexts/project/stacks.md PROJECT-STACKS-12). A level is `.starcistacks/seeds/` (shared, every environment, first) or
3
+ // `.starcistacks/<env>/seeds/` (that environment only); each holds the manifest and the folders `sql/`, `data/` and `files/`. One entry is
4
+ // `{ id, kind, path, target, mode, after?, schema?, reason? }`: a `sql` entry is idempotent DML the harness runs on the database `target`
5
+ // names, a `data` entry is a document (or a folder of them) an app command (`target`, an argv list) loads after it validates against the JSON
6
+ // schema `schema` names, a `test-account` entry is the one data entry of an environment's level that declares a test account (with its
7
+ // `reason`), a `files` entry is assets for the bucket `target` names. A document names a secret as `{ "secret": "seeds-<name>" }`, a sealed
8
+ // member of the environment (`.starcistacks/<env>/secrets/seeds-<name>.enc.yaml`) the seed step opens in memory, never a value. The same
9
+ // normaliser serves `starci app check` (R375) and the seed step of the stack verbs, so a manifest the check passes is one the step applies.
10
+ import crypto from 'node:crypto';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { parseYaml, stringifyYaml } from '../../engine/yaml.mjs';
14
+ import { closedFields, collector, patternField } from './stack-config-kit.mjs';
15
+ export const SEEDS_SCHEMA = 'starci/seeds@1';
16
+ /** The folder name of a seed level, at `.starcistacks/` and under each environment. */
17
+ export const SEEDS_DIR = 'seeds';
18
+ /** The manifest of a level. */
19
+ export const SEEDS_MANIFEST = 'seeds.yaml';
20
+ /** The kinds of entry; each lives in the folder `kindFolder` names. */
21
+ export const SEED_KINDS = Object.freeze(['sql', 'data', 'test-account', 'files']);
22
+ /** The folders of a level. */
23
+ export const SEED_FOLDERS = Object.freeze(['sql', 'data', 'files']);
24
+ /** `once`: applied a single time; `upsert`: applied again whenever its content hash changes. */
25
+ export const SEED_MODES = Object.freeze(['once', 'upsert']);
26
+ /** The placeholder that keeps an empty seed folder tracked. */
27
+ export const SEEDS_KEEP = '.gitkeep';
28
+ const ID = /^[a-z0-9][a-z0-9-]{0,62}$/u;
29
+ const NAME = /^[a-z][a-z0-9_-]{0,62}$/u;
30
+ const ENTRY_FIELDS = ['id', 'kind', 'path', 'target', 'mode', 'after', 'schema', 'reason'];
31
+ const DOCUMENT = /\.(?:json|ya?ml)$/u;
32
+ /** The name of a sealed member a seed document may reference. */
33
+ export const SEED_SECRET_MEMBER = /^seeds-[a-z0-9][a-z0-9-]{0,62}$/u;
34
+ /** The folder of a level a kind's paths live in: a test account is data. */
35
+ export const kindFolder = (kind) => (kind === 'test-account' ? 'data' : kind);
36
+ /** True for an entry the app command loads as documents: `data` and `test-account`. */
37
+ export const isDocumentEntry = (entry) => entry.kind === 'data' || entry.kind === 'test-account';
38
+ /** The sealed member file of `member` in the environment `env`, app-relative. */
39
+ export const seedMemberFile = (env, member) => `.starcistacks/${env}/secrets/${member}.enc.yaml`;
40
+ /** The app-relative folder of a level: the shared one when `env` is null. */
41
+ export const seedLevelDir = (env) => (env === null ? `.starcistacks/${SEEDS_DIR}` : `.starcistacks/${env}/${SEEDS_DIR}`);
42
+ const isFile = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isFile() === true;
43
+ const isFolder = (target) => fs.statSync(target, { throwIfNoEntry: false })?.isDirectory() === true;
44
+ /** Every file under `folder` (absolute) as a path relative to `base`, posix and sorted, the placeholders aside; a missing folder holds none. */
45
+ export function filesBelow(base, folder) {
46
+ if (!isFolder(folder))
47
+ return [];
48
+ return fs.readdirSync(folder, { recursive: true, withFileTypes: true })
49
+ .filter((entry) => entry.isFile() && entry.name !== SEEDS_KEEP)
50
+ .map((entry) => path.relative(base, path.join(entry.parentPath, entry.name)).split(path.sep).join('/'))
51
+ .sort((a, b) => (a < b ? -1 : Number(a > b)));
52
+ }
53
+ /** The level-relative files an entry covers: its path when it is a file, every file below it when it is a folder. */
54
+ export function entryFiles(levelRoot, entry) {
55
+ const target = path.join(levelRoot, entry.path);
56
+ return isFile(target) ? [entry.path] : filesBelow(levelRoot, target);
57
+ }
58
+ /** The content hash of an entry: its kind, target, mode and every file it covers, by path and bytes. */
59
+ export function entryDigest(levelRoot, entry) {
60
+ const hash = crypto.createHash('sha256').update(JSON.stringify([entry.kind, entry.target, entry.mode]));
61
+ for (const file of entryFiles(levelRoot, entry))
62
+ hash.update('\0').update(file).update('\0').update(fs.readFileSync(path.join(levelRoot, file)));
63
+ return hash.digest('hex');
64
+ }
65
+ function targetOf(c, at, kind, value) {
66
+ if (kind === 'data' || kind === 'test-account') {
67
+ if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string' || item === ''))
68
+ c.problem(at, 'must be the argv list of the app command that loads the data (never a shell line)');
69
+ return Array.isArray(value) ? value.map(String) : [];
70
+ }
71
+ patternField(c, at, value, NAME);
72
+ return String(value ?? '');
73
+ }
74
+ function entryOf(c, at, value, levelRoot, ids) {
75
+ if (!closedFields(c, at, value, ENTRY_FIELDS, 'seed entry'))
76
+ return null;
77
+ const raw = value;
78
+ if (patternField(c, `${at}.id`, raw.id, ID) && ids.has(String(raw.id)))
79
+ c.problem(`${at}.id`, `${String(raw.id)} is declared twice`);
80
+ ids.add(String(raw.id));
81
+ const kind = SEED_KINDS.find((item) => item === raw.kind);
82
+ if (kind === undefined) {
83
+ c.problem(`${at}.kind`, `must be one of ${SEED_KINDS.join(', ')}`);
84
+ return null;
85
+ }
86
+ const file = typeof raw.path === 'string' ? raw.path : '';
87
+ const folder = kindFolder(kind);
88
+ if (!file.startsWith(`${folder}/`) || path.posix.normalize(file) !== file || file.includes('\\'))
89
+ c.problem(`${at}.path`, `must be a path under ${folder}/ of the level`);
90
+ else if (!fs.existsSync(path.join(levelRoot, file)))
91
+ c.problem(`${at}.path`, `${file} does not exist in the level`);
92
+ const mode = SEED_MODES.find((item) => item === raw.mode);
93
+ if (mode === undefined)
94
+ c.problem(`${at}.mode`, `must be one of ${SEED_MODES.join(', ')}`);
95
+ const after = raw.after === undefined ? [] : raw.after;
96
+ if (!Array.isArray(after) || after.some((item) => typeof item !== 'string'))
97
+ c.problem(`${at}.after`, 'must be a list of entry ids');
98
+ const documents = isDocumentEntry({ kind });
99
+ if (documents && (typeof raw.schema !== 'string' || raw.schema === ''))
100
+ c.problem(`${at}.schema`, 'must name the JSON schema the app declares for the data, app-root relative');
101
+ if (!documents && raw.schema !== undefined)
102
+ c.problem(`${at}.schema`, 'is a field of a data or test-account entry only');
103
+ if (kind === 'test-account' && (typeof raw.reason !== 'string' || raw.reason.trim() === ''))
104
+ c.problem(`${at}.reason`, 'must say why this environment holds a test account');
105
+ if (kind !== 'test-account' && raw.reason !== undefined)
106
+ c.problem(`${at}.reason`, 'is a field of a test-account entry only');
107
+ return {
108
+ id: String(raw.id), kind, path: file, target: targetOf(c, `${at}.target`, kind, raw.target), mode: mode ?? 'upsert',
109
+ after: Array.isArray(after) ? after.map(String) : [], schema: documents && typeof raw.schema === 'string' ? raw.schema : null,
110
+ reason: kind === 'test-account' && typeof raw.reason === 'string' ? raw.reason : null,
111
+ };
112
+ }
113
+ /** The entries in apply order: manifest order, each after the entries it names; an unknown id or a cycle is a problem. */
114
+ function applyOrder(c, entries, shared) {
115
+ const own = new Set(entries.map((entry) => entry.id));
116
+ for (const entry of entries)
117
+ for (const id of entry.after)
118
+ if (!own.has(id) && !shared.has(id))
119
+ c.problem(`entries.${entry.id}.after`, `${id} is no entry of this level or the shared level`);
120
+ const done = new Set();
121
+ const order = [];
122
+ let pending = entries;
123
+ while (pending.length > 0) {
124
+ const ready = pending.filter((entry) => entry.after.every((id) => !own.has(id) || done.has(id)));
125
+ if (ready.length === 0) {
126
+ c.problem('entries', `the after lists of ${pending.map((entry) => entry.id).join(', ')} form a cycle`);
127
+ return [...order, ...pending];
128
+ }
129
+ for (const entry of ready) {
130
+ order.push(entry);
131
+ done.add(entry.id);
132
+ }
133
+ pending = pending.filter((entry) => !done.has(entry.id));
134
+ }
135
+ return order;
136
+ }
137
+ /**
138
+ * Normalises the parsed manifest of the level at `levelRoot`; `shared` holds the ids of the shared level an environment's entries may follow,
139
+ * and is null for the shared level itself, which declares no test account (an environment's level declares at most one).
140
+ */
141
+ export function normalizeSeeds(doc, levelRoot, shared = new Set()) {
142
+ const c = collector();
143
+ if (!closedFields(c, 'manifest', doc, ['schema', 'entries'], 'seeds'))
144
+ return { entries: [], problems: c.problems };
145
+ const raw = doc;
146
+ if (raw.schema !== SEEDS_SCHEMA)
147
+ c.problem('schema', `must be ${SEEDS_SCHEMA}`);
148
+ const list = raw.entries ?? [];
149
+ if (!Array.isArray(list)) {
150
+ c.problem('entries', 'must be a list');
151
+ return { entries: [], problems: c.problems };
152
+ }
153
+ const ids = new Set();
154
+ const entries = list.flatMap((value, index) => entryOf(c, `entries[${index}]`, value, levelRoot, ids) ?? []);
155
+ const accounts = entries.filter((entry) => entry.kind === 'test-account').map((entry) => entry.id);
156
+ if (shared === null && accounts.length > 0)
157
+ c.problem('entries', `${accounts.join(', ')}: the shared level declares no test account; it belongs to the one environment that holds it`);
158
+ if (accounts.length > 1)
159
+ c.problem('entries', `${accounts.join(', ')}: a level declares at most one test account`);
160
+ return { entries: applyOrder(c, entries, shared ?? new Set()), problems: c.problems };
161
+ }
162
+ /** Reads and judges the level of `env` (null: the shared level) at `repoRoot`; a level without a manifest has no entries. */
163
+ export function readSeedLevel(repoRoot, env, shared = new Set()) {
164
+ const dir = seedLevelDir(env);
165
+ const file = path.join(repoRoot, dir, SEEDS_MANIFEST);
166
+ if (!isFile(file))
167
+ return { dir, env, entries: [], problems: [] };
168
+ let doc;
169
+ try {
170
+ doc = parseYaml(fs.readFileSync(file, 'utf8'));
171
+ }
172
+ catch (error) {
173
+ return { dir, env, entries: [], problems: [{ at: 'manifest', message: `does not parse: ${String(error).split('\n')[0]}` }] };
174
+ }
175
+ return { dir, env, ...normalizeSeeds(doc ?? {}, path.join(repoRoot, dir), env === null ? null : shared) };
176
+ }
177
+ /** The documents of a data or test-account entry: every JSON or YAML file it covers, parsed. */
178
+ export function seedDocuments(levelRoot, entry) {
179
+ return entryFiles(levelRoot, entry).filter((file) => DOCUMENT.test(file)).map((file) => {
180
+ const text = fs.readFileSync(path.join(levelRoot, file), 'utf8');
181
+ try {
182
+ return { path: file, doc: file.endsWith('.json') ? JSON.parse(text) : parseYaml(text) };
183
+ }
184
+ catch (error) {
185
+ return { path: file, error: String(error).split('\n')[0] ?? '' };
186
+ }
187
+ });
188
+ }
189
+ /** The member a value references when it is a secret reference (`{ "secret": "<member>" }` and nothing else), else null. */
190
+ export function secretReference(value) {
191
+ if (value === null || typeof value !== 'object' || Array.isArray(value))
192
+ return null;
193
+ const keys = Object.keys(value);
194
+ const member = value.secret;
195
+ return keys.length === 1 && typeof member === 'string' ? member : null;
196
+ }
197
+ /** Every member the secret references of a document name, in document order, once each. */
198
+ export function secretReferences(doc) {
199
+ const found = [];
200
+ const visit = (value) => {
201
+ const member = secretReference(value);
202
+ if (member !== null) {
203
+ if (!found.includes(member))
204
+ found.push(member);
205
+ return;
206
+ }
207
+ if (value !== null && typeof value === 'object')
208
+ for (const item of Object.values(value))
209
+ visit(item);
210
+ };
211
+ visit(doc);
212
+ return found;
213
+ }
214
+ /** A copy of `doc` with every secret reference replaced by what `resolve` answers for its member. */
215
+ export function resolveSecrets(doc, resolve) {
216
+ const member = secretReference(doc);
217
+ if (member !== null)
218
+ return resolve(member);
219
+ if (Array.isArray(doc))
220
+ return doc.map((item) => resolveSecrets(item, resolve));
221
+ if (doc !== null && typeof doc === 'object')
222
+ return Object.fromEntries(Object.entries(doc).map(([key, item]) => [key, resolveSecrets(item, resolve)]));
223
+ return doc;
224
+ }
225
+ /**
226
+ * Declares `entries` in the manifest of the level of `env` (null: the shared level), creating the manifest when the level has none; an id the
227
+ * manifest already holds is kept as it is. Answers the ids it added (none are written when `write` is false).
228
+ */
229
+ export function addSeedEntries(repoRoot, env, entries, write = true) {
230
+ const file = path.join(repoRoot, seedLevelDir(env), SEEDS_MANIFEST);
231
+ const doc = (isFile(file) ? parseYaml(fs.readFileSync(file, 'utf8')) : null);
232
+ const held = Array.isArray(doc?.entries) ? doc.entries : [];
233
+ const added = entries.filter((entry) => !held.some((item) => item.id === entry.id));
234
+ if (added.length > 0 && write) {
235
+ fs.mkdirSync(path.dirname(file), { recursive: true });
236
+ fs.writeFileSync(file, stringifyYaml({ schema: SEEDS_SCHEMA, ...doc, entries: [...held, ...added] }));
237
+ }
238
+ return added.map((entry) => entry.id);
239
+ }
@@ -19,6 +19,22 @@ export interface HfsView {
19
19
  ownerOf: (file: string) => string | null;
20
20
  allows: (file: string) => AllowsResult | null;
21
21
  slot: OpenedHfs['slot'];
22
+ /** The vocabulary a root workspace package declares for itself (`starci` of its manifest); only the view of the root packages has it. */
23
+ packageContract?: (file: string) => PackageContract | null;
24
+ }
25
+ /** A type a root package declares: its name and the file of the package that declares it, relative to the app root. */
26
+ export interface PackageDeclaredType {
27
+ readonly name: string;
28
+ readonly file: string;
29
+ }
30
+ /**
31
+ * What a root workspace package declares in the `starci` key of its own `package.json`, since it cannot import the app's
32
+ * `platform/errors` or its `GraphQLResponse<T>`: `errorBase` is the class its errors derive from, `outcome` its one result union, each
33
+ * `{ name, file }` with `file` relative to the package folder.
34
+ */
35
+ export interface PackageContract {
36
+ readonly errorBase?: PackageDeclaredType;
37
+ readonly outcome?: PackageDeclaredType;
22
38
  }
23
39
  /**
24
40
  * The view of the app folder on disk at `repoRoot`, against the manifest under `runtimeRoot`: a side folder (be/, fe/) finds the
@@ -43,6 +59,15 @@ export declare const declaredHfsView: ({ runtimeRoot, declaration, repoRoot, sid
43
59
  repoRoot: string;
44
60
  side: string;
45
61
  }) => HfsView;
62
+ /**
63
+ * The contract of the package at `packageDir` (app-root relative, `packages/<pkg>`), read from the `starci` key of its manifest. A
64
+ * declaration that is not `{ name, file }` with an identifier name and a file inside `src/` stops the lint, because a rule must not guess.
65
+ *
66
+ * @param {string} repoRoot - The absolute app root.
67
+ * @param {string} packageDir - The package folder, relative to the app root.
68
+ * @returns {object} The declared error base and outcome union, each with its file relative to the app root.
69
+ */
70
+ export declare const readPackageContract: (repoRoot: string, packageDir: string) => PackageContract;
46
71
  /**
47
72
  * The view of the root packages of the app at `repoRoot` (the app root, where the root `eslint.config.mjs` lives), for the laws of `side`.
48
73
  *
@@ -2,6 +2,7 @@
2
2
  // their rules (`settings.starci.hfs`). Both @starci/eslint-canon-be and @starci/eslint-canon-fe ship a byte copy of this file in their
3
3
  // runtime/ bundle (scripts/hfs/sync-runtime.mjs), so a path-scoped rule in either package asks the SAME resolver the
4
4
  // architecture machine and `starci app check` use, and never tests a path with a regular expression of its own.
5
+ import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { allowsFile } from './allows.mjs';
7
8
  import { openHfs } from './slots.mjs';
@@ -70,6 +71,36 @@ export const openHfsView = ({ runtimeRoot, repoRoot }) => hfsView(openHfs({ root
70
71
  * @returns {object} The frozen view.
71
72
  */
72
73
  export const declaredHfsView = ({ runtimeRoot, declaration, repoRoot, side }) => hfsView(openHfs({ root: runtimeRoot, declaration, side }), repoRoot);
74
+ const CONTRACT_KEYS = ['errorBase', 'outcome'];
75
+ /**
76
+ * The contract of the package at `packageDir` (app-root relative, `packages/<pkg>`), read from the `starci` key of its manifest. A
77
+ * declaration that is not `{ name, file }` with an identifier name and a file inside `src/` stops the lint, because a rule must not guess.
78
+ *
79
+ * @param {string} repoRoot - The absolute app root.
80
+ * @param {string} packageDir - The package folder, relative to the app root.
81
+ * @returns {object} The declared error base and outcome union, each with its file relative to the app root.
82
+ */
83
+ export const readPackageContract = (repoRoot, packageDir) => {
84
+ const manifestPath = path.join(repoRoot, packageDir, 'package.json');
85
+ const manifest = (fs.existsSync(manifestPath) ? JSON.parse(fs.readFileSync(manifestPath, 'utf8')) : null);
86
+ const declared = manifest?.starci;
87
+ if (declared === undefined)
88
+ return {};
89
+ if (typeof declared !== 'object' || declared === null)
90
+ throw new Error(`${packageDir}/package.json: "starci" is an object ({ errorBase, outcome })`);
91
+ const contract = {};
92
+ for (const key of CONTRACT_KEYS) {
93
+ const entry = declared[key];
94
+ if (entry === undefined)
95
+ continue;
96
+ const { name, file } = (typeof entry === 'object' && entry !== null ? entry : {});
97
+ if (typeof name !== 'string' || !/^[A-Za-z_$][\w$]*$/.test(name) || typeof file !== 'string' || !/^src\/(?:[\w.-]+\/)*[\w.-]+\.ts$/.test(file)) {
98
+ throw new Error(`${packageDir}/package.json: "starci.${key}" is { "name": "<Identifier>", "file": "src/<path>.ts" }`);
99
+ }
100
+ contract[key] = Object.freeze({ name, file: `${packageDir}/${file}` });
101
+ }
102
+ return Object.freeze(contract);
103
+ };
73
104
  /**
74
105
  * The view of the root workspace packages of an app (`packages/<pkg>/src/**`, slots app.packages and app.module-registry), as the lint of one
75
106
  * side sees them: the rule parameters, the apps and the Grammar of `side` (a package is judged by the laws of the side whose subpath a file
@@ -103,6 +134,15 @@ function packagesView(opened, repoRoot, side) {
103
134
  const relative = rel(file);
104
135
  return slot !== undefined && ROOT_PACKAGE_SLOTS.has(slot) ? relative.split('/').slice(0, 2).join('/') : null;
105
136
  };
137
+ const contracts = new Map();
138
+ const packageContract = (file) => {
139
+ const dir = packageOf(file);
140
+ if (dir === null)
141
+ return null;
142
+ if (!contracts.has(dir))
143
+ contracts.set(dir, readPackageContract(repoRoot, dir));
144
+ return contracts.get(dir);
145
+ };
106
146
  return Object.freeze({
107
147
  repoRoot,
108
148
  profile: side,
@@ -121,6 +161,7 @@ function packagesView(opened, repoRoot, side) {
121
161
  ownerOf: () => null,
122
162
  allows: (file) => allowsFile(opened, rel(file)),
123
163
  slot: (id) => opened.slot(id),
164
+ packageContract,
124
165
  });
125
166
  }
126
167
  /**
@@ -1,7 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { loadSlotManifest, readRepoDeclaration, resolveAppDeclaration, } from "../runtime/scripts/hfs/slots.mjs";
4
- import { imageFiles, render, TEMPLATES_DIR } from "../sync/index.mjs";
4
+ import { appSource, imageFiles, render, TEMPLATES_DIR } from "../sync/index.mjs";
5
5
  import { ScaffoldError, pascalOf } from "./service.mjs";
6
6
  import { PARTIAL_VARIABLES, coreFeApps, jsonText, packageJsonText } from "./app.mjs";
7
7
  /** The shared fe app core (modules/api, auth, toast, redux, the providers, the hooks): the base tree, then the edition overlay. */
@@ -121,7 +121,7 @@ export function addApp({ root, name }) {
121
121
  const planned = [...planAppTemplates(repo, name), ...planWorkspaceManifests(root, repo, repo.sides.fe.apps, name)];
122
122
  const declaration = JSON.parse(fs.readFileSync(declarationFile, "utf8"));
123
123
  declaration.sides.fe.apps.push({ name, kind: "next" });
124
- const image = imageFiles(resolveAppDeclaration(manifest, declaration)).find((file) => file.path === `${targetBase}/Dockerfile`);
124
+ const image = imageFiles(resolveAppDeclaration(manifest, declaration), appSource(root)).find((file) => file.path === `${targetBase}/Dockerfile`);
125
125
  if (!image && repo.edition !== "lite")
126
126
  throw new ScaffoldError("HFS_ADD_TEMPLATE_MISSING", `the Dockerfile of front-end app ${name} did not render`);
127
127
  if (image)
@@ -43,19 +43,31 @@ export class RunCli extends CommandRunner {
43
43
  }
44
44
  }
45
45
  `;
46
- const SEED_RUN = `import { CommandRunner, SubCommand } from "nest-commander"
47
- import { SeedRunnerService } from "@modules/platform/database"
46
+ const SEED_RUN = `import { CommandRunner, Option, SubCommand } from "nest-commander"
47
+ import { DEFAULT_SEED_ENV, SeedRunnerService } from "@modules/platform/database"
48
+
49
+ /** The options of \`cli seed run\`. */
50
+ export interface SeedRunOptions {
51
+ /** The stack environment whose \`.starcistacks/<env>/seeds/sql\` runs after \`.starcistacks/seeds/sql\`. */
52
+ readonly env?: string
53
+ }
48
54
 
49
- @SubCommand({ name: "run", description: "Run the tracked seed through the application role" })
50
- /** \`cli seed run\`: executes the one tracked seed through the shared least-privilege EntityManager. */
55
+ @SubCommand({ name: "run", description: "Run the tracked SQL seeds through the application role" })
56
+ /** \`cli seed run [--env <name>]\`: executes the shared SQL seeds, then the environment's, through the shared least-privilege EntityManager. */
51
57
  export class RunSeedsCli extends CommandRunner {
52
58
  constructor(private readonly seeds: SeedRunnerService) {
53
59
  super()
54
60
  }
55
61
 
56
- /** Executes the tracked seed when it contains at least one statement. */
57
- async run(): Promise<void> {
58
- await this.seeds.run()
62
+ /** \`--env <name>\`: the stack environment whose seed runs after the shared one. */
63
+ @Option({ flags: "--env <name>", description: "The stack environment whose seed runs", defaultValue: DEFAULT_SEED_ENV })
64
+ parseEnv(value: string): string {
65
+ return value
66
+ }
67
+
68
+ /** Executes the shared seed and the environment's own. */
69
+ async run(_passed: Array<string>, options?: SeedRunOptions): Promise<void> {
70
+ await this.seeds.run(options?.env)
59
71
  }
60
72
  }
61
73
  `;
@@ -64,17 +76,32 @@ import { Test } from "@nestjs/testing"
64
76
  import { SeedRunnerService } from "@modules/platform/database"
65
77
  import { RunSeedsCli } from "./run.cli"
66
78
 
79
+ const build = async () => {
80
+ const seeds = mock<SeedRunnerService>()
81
+ const moduleRef = await Test.createTestingModule({
82
+ providers: [RunSeedsCli, { provide: SeedRunnerService, useValue: seeds }],
83
+ }).compile()
84
+ return { command: moduleRef.get(RunSeedsCli), seeds }
85
+ }
86
+
67
87
  describe("RunSeedsCli", () => {
68
- it("delegates the tracked seed run to the seed runner once", async () => {
69
- const seeds = mock<SeedRunnerService>()
70
- const moduleRef = await Test.createTestingModule({
71
- providers: [RunSeedsCli, { provide: SeedRunnerService, useValue: seeds }],
72
- }).compile()
88
+ it("delegates the seed run of the named environment to the seed runner once", async () => {
89
+ const { command, seeds } = await build()
73
90
 
74
- await expect(moduleRef.get(RunSeedsCli).run()).resolves.toBeUndefined()
91
+ await expect(command.run([], { env: "production" })).resolves.toBeUndefined()
75
92
 
93
+ expect(seeds.run).toHaveBeenCalledWith("production")
76
94
  expect(seeds.run).toHaveBeenCalledTimes(1)
77
95
  })
96
+
97
+ it("delegates with no environment when none is named, and takes --env as given", async () => {
98
+ const { command, seeds } = await build()
99
+
100
+ await command.run([])
101
+
102
+ expect(seeds.run).toHaveBeenCalledWith(undefined)
103
+ expect(command.parseEnv("production")).toBe("production")
104
+ })
78
105
  })
79
106
  `;
80
107
  const SEED_MODULE = `import { Module } from "@nestjs/common"
@@ -82,7 +109,7 @@ import { SeedCli } from "./seed.cli"
82
109
  import { RunSeedsCli } from "./subs/run.cli"
83
110
 
84
111
  @Module({ providers: [SeedCli, RunSeedsCli] })
85
- /** The lite seed group: the command and its one tracked seed runner. */
112
+ /** The lite seed group: the command and its runner of the tracked SQL seeds. */
86
113
  export class SeedModule {}
87
114
  `;
88
115
  function enableLiteCliDatabase(root) {
@@ -46,10 +46,11 @@ function manifests(project, name, versions) {
46
46
  private: true,
47
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
48
  exports,
49
- scripts: { build: 'tsc -p tsconfig.build.json && tsc -p tsconfig.build.workbench.json', typecheck: 'tsc --noEmit --pretty false', test: 'jest --coverage' },
49
+ // No test script and no jest config of its own: its specs run in the back end's unit run (the root packages of be/jest.config.js, BE-TEST-13).
50
+ scripts: { build: 'tsc -p tsconfig.build.json && tsc -p tsconfig.build.workbench.json', typecheck: 'tsc --noEmit --pretty false' },
50
51
  dependencies: { [sdk]: '*' },
51
52
  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
+ devDependencies: sortedDependencies({ '@types/jest': versions['@types/jest'] ?? '', '@types/react': versions['@types/react'] ?? '', typescript: versions['typescript'] ?? '' }),
53
54
  };
54
55
  const build = { rootDir: 'src', noEmit: false, declaration: true, types: [] };
55
56
  return [
@@ -65,7 +66,7 @@ function versionsOf(root, feAppFiles) {
65
66
  const all = { ...rootPackage.devDependencies, ...rootPackage.dependencies };
66
67
  for (const file of feAppFiles)
67
68
  Object.assign(all, read(file).dependencies);
68
- const wanted = ['@starci/grammar', 'react', '@types/jest', '@types/react', 'jest', 'ts-jest', 'typescript'];
69
+ const wanted = ['@starci/grammar', 'react', '@types/jest', '@types/react', 'typescript'];
69
70
  const missing = wanted.filter((name) => all[name] === undefined);
70
71
  if (missing.length > 0)
71
72
  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`);
@@ -117,6 +118,8 @@ function context(root, name) {
117
118
  if (!fs.existsSync(declarationFile) || !fs.existsSync(packageFile))
118
119
  throw new ScaffoldError('HFS_ADD_NOT_AN_APP', 'a module verb runs at the app root containing hfs.json and package.json');
119
120
  const repo = readRepoDeclaration(loadSlotManifest(), root);
121
+ if (repo.modules === 'data')
122
+ throw new ScaffoldError('HFS_MODULES_ARE_DATA', 'hfs.json declares modules: "data": a module of this app is a seed under .starcistacks/seeds/data/modules, not a package, so no module verb applies');
120
123
  return { project: repo.project, declarationFile, packageFile };
121
124
  }
122
125
  /** Sets or deletes one dependency of a manifest file, keeping the section sorted; answers whether the text changed. */