@starci/hfs 5.4.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.
- package/CHANGELOG.md +19 -0
- package/README.md +4 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/main.mjs +6 -1
- package/codemod/seeds-layout.d.mts +36 -0
- package/codemod/seeds-layout.mjs +113 -0
- package/codemod/sops-rules.d.mts +1 -1
- package/codemod/sops-rules.mjs +8 -3
- package/lint/run.d.mts +5 -4
- package/lint/run.mjs +53 -27
- package/package.json +1 -1
- package/release/lint-report.mjs +4 -3
- package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +15 -2
- package/runtime/knowledge/hfs/slots.yaml +15 -18
- package/runtime/modules/kernel/failure-codes.yaml +64 -3
- package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
- package/runtime/scripts/hfs/check.mjs +4 -1
- package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
- package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
- package/runtime/scripts/hfs/db-squash.mjs +9 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
- package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
- package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
- package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
- package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
- package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
- package/runtime/scripts/hfs/rules/database.d.mts +2 -2
- package/runtime/scripts/hfs/rules/database.mjs +7 -6
- package/runtime/scripts/hfs/rules/docker.mjs +24 -7
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
- package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
- package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
- package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
- package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
- package/runtime/scripts/hfs/slots.d.mts +2 -0
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
- package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
- package/runtime/scripts/hfs/view.d.mts +25 -0
- package/runtime/scripts/hfs/view.mjs +41 -0
- package/scaffold/add-app.mjs +2 -2
- package/scaffold/add-cli-lite.mjs +41 -14
- package/scaffold/add-module.mjs +6 -3
- package/scaffold/lite-exports.mjs +1 -1
- package/scaffold/module-registry.mjs +10 -8
- package/src/main.mjs +8 -8
- package/stack/deploy.d.mts +3 -0
- package/stack/deploy.mjs +4 -1
- package/stack/local-up.mjs +25 -3
- package/stack/seeds.d.mts +29 -0
- package/stack/seeds.mjs +197 -0
- package/sync/canon-layout.d.mts +38 -0
- package/sync/canon-layout.mjs +62 -0
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +10 -7
- package/sync/index.d.mts +13 -2
- package/sync/index.mjs +52 -24
- package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
- package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
- package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton/README.md +1 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
- package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
- package/templates/app/skeleton-lite/README.md +2 -2
- package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
- package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
- package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
- package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
- package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
- package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
- package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
- package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
- package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +59 -9
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +4 -1
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
- package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
- package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
- package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
- package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
- package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
- package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
- package/templates/be/tool-config/jest.config.js +1 -1
- package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
- package/templates/app/module/jest.config.js.tpl +0 -8
- /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
|
@@ -18,7 +18,8 @@ export declare const MIGRATIONS_DIR = "db/migrations";
|
|
|
18
18
|
export declare const MIGRATIONS_ARCHIVE_DIR = "db/migrations/archive";
|
|
19
19
|
/** True for a migration `starci app db migrate` applies: a path under db/migrations/ outside the archive. */
|
|
20
20
|
export declare const isActiveMigration: (file: string) => boolean;
|
|
21
|
-
|
|
21
|
+
/** The shared SQL seed `db baseline --squash --seed-tables` writes the reference rows to, an entry of the shared seed level (R375). */
|
|
22
|
+
export declare const SEED_FILE = ".starcistacks/seeds/sql/reference.sql";
|
|
22
23
|
/** The managed pre-migration bootstrap (slot app.db.bootstrap): `starci app sync` renders it, and `db baseline --squash` owns one marked section. */
|
|
23
24
|
export declare const BOOTSTRAP_FILE = "db/bootstrap.sql";
|
|
24
25
|
export declare const BOOTSTRAP_SQUASH_BEGIN = "-- >>> starci app db baseline --squash: roles, role settings and grants a per-database dump loses (regenerate with the verb; do not edit) >>>";
|
|
@@ -19,7 +19,8 @@ export const MIGRATIONS_DIR = `${DB_DIR}/migrations`;
|
|
|
19
19
|
export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/archive`;
|
|
20
20
|
/** True for a migration `starci app db migrate` applies: a path under db/migrations/ outside the archive. */
|
|
21
21
|
export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
|
|
22
|
-
|
|
22
|
+
/** The shared SQL seed `db baseline --squash --seed-tables` writes the reference rows to, an entry of the shared seed level (R375). */
|
|
23
|
+
export const SEED_FILE = '.starcistacks/seeds/sql/reference.sql';
|
|
23
24
|
/** The managed pre-migration bootstrap (slot app.db.bootstrap): `starci app sync` renders it, and `db baseline --squash` owns one marked section. */
|
|
24
25
|
export const BOOTSTRAP_FILE = `${DB_DIR}/bootstrap.sql`;
|
|
25
26
|
export const BOOTSTRAP_SQUASH_BEGIN = '-- >>> starci app db baseline --squash: roles, role settings and grants a per-database dump loses (regenerate with the verb; do not edit) >>>';
|
|
@@ -4,8 +4,8 @@ export declare const DB_DEFINER_REVOKE = "DB_DEFINER_REVOKE";
|
|
|
4
4
|
export { DB_DEFINER_SAFE, DB_DYNAMIC_DDL, DB_MIGRATION_SHAPE, DB_POLICY_SHAPE, DB_RLS_REQUIRED, DB_STORAGE_POLICY, LITE_DB_MIGRATION, } from './database-constants.mjs';
|
|
5
5
|
import type { Finding, Parsed } from './rule-types.mjs';
|
|
6
6
|
/**
|
|
7
|
-
* LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/
|
|
8
|
-
* .starcistacks
|
|
7
|
+
* LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ but its seed data (the seed
|
|
8
|
+
* levels of .starcistacks/, judged by R375), and every SQL file under db/ is a migration, the archive, the bootstrap or nothing.
|
|
9
9
|
*/
|
|
10
10
|
export declare function liteDbPathFindings(files: string[]): Finding[];
|
|
11
11
|
/**
|
|
@@ -5,7 +5,8 @@ import { contractReleaseFindings, defaultGit, migrationShapeFindings } from './d
|
|
|
5
5
|
import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
|
|
6
6
|
import { migrationSetDefinerFindings } from './database-plpgsql.mjs';
|
|
7
7
|
import { databaseAccessFindings } from './database-access.mjs';
|
|
8
|
-
import { DB_DIR, DB_MIGRATION_SHAPE, isActiveMigration, LITE_DB_MIGRATION, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
|
|
8
|
+
import { BOOTSTRAP_FILE, DB_DIR, DB_MIGRATION_SHAPE, isActiveMigration, LITE_DB_MIGRATION, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
|
|
9
|
+
import { isSeedFile } from './seeds.mjs';
|
|
9
10
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
10
11
|
import { eachInOrder } from '../../lib/in-order.mjs';
|
|
11
12
|
export { migrationStamp } from './database-migrations.mjs';
|
|
@@ -14,11 +15,9 @@ export const DB_DEFINER_REVOKE = 'DB_DEFINER_REVOKE';
|
|
|
14
15
|
export { DB_DEFINER_SAFE, DB_DYNAMIC_DDL, DB_MIGRATION_SHAPE, DB_POLICY_SHAPE, DB_RLS_REQUIRED, DB_STORAGE_POLICY, LITE_DB_MIGRATION, } from './database-constants.mjs';
|
|
15
16
|
/** The schemas the app's tables live in; no schema is exposed to a browser, which reaches data only through the back end. */
|
|
16
17
|
const APP_SCHEMAS = new Set(['public']);
|
|
17
|
-
/** A .starcistacks seed is the one SQL file a lite app keeps outside db/. */
|
|
18
|
-
const STACK_SEED = /^\.starcistacks\/[^/]+\/seeds\/.+\.sql$/;
|
|
19
18
|
/**
|
|
20
|
-
* LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/
|
|
21
|
-
* .starcistacks
|
|
19
|
+
* LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ but its seed data (the seed
|
|
20
|
+
* levels of .starcistacks/, judged by R375), and every SQL file under db/ is a migration, the archive, the bootstrap or nothing.
|
|
22
21
|
*/
|
|
23
22
|
export function liteDbPathFindings(files) {
|
|
24
23
|
const findings = [];
|
|
@@ -27,10 +26,12 @@ export function liteDbPathFindings(files) {
|
|
|
27
26
|
findings.push(found(LITE_DB_MIGRATION, file, `${file} is a Supabase project file; a lite app keeps its schema in ${MIGRATIONS_DIR}/<ts14>_<kebab>.sql (move the migrations, delete supabase/)`, {}));
|
|
28
27
|
continue;
|
|
29
28
|
}
|
|
30
|
-
if (!file.endsWith('.sql') ||
|
|
29
|
+
if (!file.endsWith('.sql') || isSeedFile(file))
|
|
31
30
|
continue;
|
|
32
31
|
if (!file.startsWith(`${DB_DIR}/`))
|
|
33
32
|
findings.push(found(LITE_DB_MIGRATION, file, `${file} is SQL outside ${DB_DIR}/; a schema change is one ${MIGRATIONS_DIR}/<ts14>_<kebab>.sql`, {}));
|
|
33
|
+
else if (!isActiveMigration(file) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`) && file !== BOOTSTRAP_FILE)
|
|
34
|
+
findings.push(found(LITE_DB_MIGRATION, file, `${file} is SQL under ${DB_DIR}/ that is no migration, archive or bootstrap; seed data is an entry of a seed level, .starcistacks/seeds/sql/ or .starcistacks/<env>/seeds/sql/ (starci app codemod seeds-layout moves it)`, {}));
|
|
34
35
|
}
|
|
35
36
|
return findings;
|
|
36
37
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// The Dockerfile is app-owned (starci app scaffold writes it once from templates/<side>/image) and judged here, structurally, from its parsed
|
|
4
4
|
// instructions (scripts/lib/dockerfile.mjs), never by matching the text:
|
|
5
5
|
// HFS_DOCKER_BUILD_CONTEXT the header comment states the app's own build command (`starci docker build <app>` or `docker build -f <side>/apps/<app>/Dockerfile ... .`, the
|
|
6
|
-
// context the app root) and no COPY or ADD source leaves the context (`..`, an absolute path)
|
|
6
|
+
// context the app root) and no COPY or ADD source leaves the context (`..`, an absolute path); a Next image of a front end
|
|
7
|
+
// that reads be/contracts/ copies that folder exactly when the app holds it (its codegen input; a COPY of a missing folder fails the build)
|
|
7
8
|
// HFS_DOCKER_STAGES a `build` stage and a last `runtime` stage; the runtime is unprivileged (`USER node`) and runs no build; every install
|
|
8
9
|
// is `npm ci` (never `npm install`); a back-end runtime installs `--omit=dev --ignore-scripts`, a front-end runtime installs nothing
|
|
9
10
|
// HFS_DOCKER_ENTRY the runtime starts the app's own built entry (be: node be/dist/apps/<app>/src/main.js; fe: node fe/apps/<app>/server.js); an api or
|
|
@@ -40,6 +41,8 @@ const SECRET_FOLDERS = new Set(['.starcistacks', '.secrets', '.volume', '.git',
|
|
|
40
41
|
const SECRET_SUFFIXES = ['.key', '.pem', '.p12', '.pfx', '.crt'];
|
|
41
42
|
const BUILD_TOOLS = new Set(['tsc', 'tsc-alias', 'next', 'turbo', 'nest', 'webpack', 'vite', 'esbuild']);
|
|
42
43
|
const DIGEST = /@sha256:[0-9a-f]{64}$/;
|
|
44
|
+
/** The back-end contracts a front end reads in place (`sides.fe.reads`), its codegen input. */
|
|
45
|
+
const CONTRACTS = 'be/contracts';
|
|
43
46
|
/** The path of the Dockerfile of a be or fe app, app-relative. */
|
|
44
47
|
export const dockerfilePath = (side, app) => `${side}/apps/${app}/Dockerfile`;
|
|
45
48
|
/** The entry a built be app starts (from the app root). */
|
|
@@ -93,14 +96,16 @@ const statesBuildCommand = (parsed, wanted, app) => parsed.comments.some((commen
|
|
|
93
96
|
const flag = parts.indexOf('-f', build);
|
|
94
97
|
return flag > 0 && parts[flag + 1] === wanted && parts.at(-1) === '.';
|
|
95
98
|
});
|
|
99
|
+
/** The context sources of a COPY or ADD (none for a COPY from a stage). */
|
|
100
|
+
const contextSources = (item) => {
|
|
101
|
+
const parts = words(item.text);
|
|
102
|
+
if (parts.some((part) => part.startsWith('--from=')))
|
|
103
|
+
return [];
|
|
104
|
+
return parts.filter((part) => !part.startsWith('--')).slice(0, -1);
|
|
105
|
+
};
|
|
96
106
|
function copySourceFindings(file, item) {
|
|
97
107
|
const findings = [];
|
|
98
|
-
const
|
|
99
|
-
const sources = parts.slice(0, -1);
|
|
100
|
-
const fromStage = words(item.text).some((part) => part.startsWith('--from='));
|
|
101
|
-
if (fromStage)
|
|
102
|
-
return findings;
|
|
103
|
-
for (const source of sources) {
|
|
108
|
+
for (const source of contextSources(item)) {
|
|
104
109
|
if (/^[a-z]+:\/\//i.test(source))
|
|
105
110
|
continue;
|
|
106
111
|
const segments = source.split('/');
|
|
@@ -119,6 +124,15 @@ function contextFindings(file, parsed, side, app) {
|
|
|
119
124
|
findings.push(...copySourceFindings(file, item));
|
|
120
125
|
return findings;
|
|
121
126
|
}
|
|
127
|
+
/** R187: a Next image copies be/contracts, the contract its codegen reads, when the app holds it, and never when it does not. */
|
|
128
|
+
function contractCopyFindings(file, parsed, holdsContracts) {
|
|
129
|
+
const copies = parsed.instructions.filter((entry) => entry.keyword === 'COPY' || entry.keyword === 'ADD').find((entry) => contextSources(entry).some((source) => source.replace(/\/$/u, '') === CONTRACTS));
|
|
130
|
+
if (copies && !holdsContracts)
|
|
131
|
+
return [found(DOCKER_BUILD_CONTEXT, file, `${file}:${copies.line} copies ${CONTRACTS}, which the app does not hold (its back end emits no contract), so the image build fails; drop the COPY until a contract is emitted.`, { line: copies.line, source: CONTRACTS })];
|
|
132
|
+
if (!copies && holdsContracts)
|
|
133
|
+
return [found(DOCKER_BUILD_CONTEXT, file, `${file} does not copy ${CONTRACTS}, the contract the front end's codegen reads; add \`COPY ${CONTRACTS} ${CONTRACTS}\` before the codegen.`, { source: CONTRACTS })];
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
122
136
|
/** R188: the stages and the install, the user and the build discipline of each. */
|
|
123
137
|
function stageInstallFindings(file, stages) {
|
|
124
138
|
const findings = [];
|
|
@@ -330,6 +344,8 @@ export function dockerFindings({ repoRoot, files, repo }) {
|
|
|
330
344
|
...repo.sides.fe.apps.map((entry) => ({ side: 'fe', app: entry.name, kind: entry.kind })),
|
|
331
345
|
];
|
|
332
346
|
const ts = apps.some((entry) => entry.side === 'fe' || entry.kind === 'api') ? typescriptFor(repoRoot) : null;
|
|
347
|
+
const readsContracts = (repo.sides.fe.reads ?? []).includes(`${CONTRACTS}/`);
|
|
348
|
+
const holdsContracts = files.some((name) => name.startsWith(`${CONTRACTS}/`));
|
|
333
349
|
return apps.flatMap(({ side, app, kind }) => {
|
|
334
350
|
const file = dockerfilePath(side, app);
|
|
335
351
|
if (!tracked.has(file))
|
|
@@ -340,6 +356,7 @@ export function dockerFindings({ repoRoot, files, repo }) {
|
|
|
340
356
|
const parsed = parseDockerfile(text);
|
|
341
357
|
return [
|
|
342
358
|
...contextFindings(file, parsed, side, app),
|
|
359
|
+
...(side === 'fe' && kind === 'next' && readsContracts ? contractCopyFindings(file, parsed, holdsContracts) : []),
|
|
343
360
|
...stageFindings(file, parsed, side, kind),
|
|
344
361
|
...entryFindings({ repoRoot, file, parsed, side, app, kind, project: repo.project, ts }),
|
|
345
362
|
...healthReachabilityFindings({ repoRoot, file, parsed, side, app, kind, ts }),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Parsed, Repo } from './rule-types.mjs';
|
|
2
2
|
/** The npm workspaces of every app, in this order. */
|
|
3
3
|
export declare const WORKSPACES: readonly string[];
|
|
4
|
-
/** The workspace of the app's own root packages (slot app.packages: packages
|
|
4
|
+
/** The workspace of the app's own root packages (slot app.packages: packages/<name>, the agent SDK, a block library or a module package), added after the fe ones once one is tracked. */
|
|
5
5
|
export declare const ROOT_PACKAGES_WORKSPACE = "packages/*";
|
|
6
6
|
/** The workspaces the tracked tree `files` calls for: the fe ones, then `packages/*` when a root package tracks its package.json. */
|
|
7
7
|
export declare const workspacesFor: (files: readonly string[]) => readonly string[];
|
|
@@ -22,7 +22,7 @@ const MONO_NEST_PROJECTS = 'HFS_MONO_NEST_PROJECTS';
|
|
|
22
22
|
const MONO_WORKSPACE_DEP = 'HFS_MONO_WORKSPACE_DEP';
|
|
23
23
|
/** The npm workspaces of every app, in this order. */
|
|
24
24
|
export const WORKSPACES = Object.freeze(['fe/apps/*', 'fe/packages/*']);
|
|
25
|
-
/** The workspace of the app's own root packages (slot app.packages: packages
|
|
25
|
+
/** The workspace of the app's own root packages (slot app.packages: packages/<name>, the agent SDK, a block library or a module package), added after the fe ones once one is tracked. */
|
|
26
26
|
export const ROOT_PACKAGES_WORKSPACE = 'packages/*';
|
|
27
27
|
/** The workspaces the tracked tree `files` calls for: the fe ones, then `packages/*` when a root package tracks its package.json. */
|
|
28
28
|
export const workspacesFor = (files) => (files.some((file) => /^packages\/[^/]+\/package\.json$/.test(file)) ? [...WORKSPACES, ROOT_PACKAGES_WORKSPACE] : WORKSPACES);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { Finding } from './rule-types.mjs';
|
|
2
|
+
export declare const HFS_SEEDS_SHAPE = "HFS_SEEDS_SHAPE";
|
|
3
|
+
export declare const HFS_SEED_MANIFEST = "HFS_SEED_MANIFEST";
|
|
4
|
+
export declare const HFS_SEED_DDL = "HFS_SEED_DDL";
|
|
5
|
+
export declare const HFS_SEED_DATA_SCHEMA = "HFS_SEED_DATA_SCHEMA";
|
|
6
|
+
export declare const HFS_SEED_TEST_DATA = "HFS_SEED_TEST_DATA";
|
|
7
|
+
export declare const HFS_SEED_SECRET_REF = "HFS_SEED_SECRET_REF";
|
|
8
|
+
/** True for a tracked file of a seed level (the `.gitkeep` placeholders aside). */
|
|
9
|
+
export declare const isSeedFile: (file: string) => boolean;
|
|
10
|
+
/** The environments of the stack declaration of `repoRoot` with their runtime, or null when it declares none that parses to a mapping. */
|
|
11
|
+
export declare function declaredEnvironments(repoRoot: string): Map<string, string> | null;
|
|
12
|
+
/** The literals of a seed document or SQL file, each with its line: SQL strings and bodies, or every string value of a JSON or YAML document. */
|
|
13
|
+
export declare function seedLiterals(file: string, text: string): Array<{
|
|
14
|
+
v: string;
|
|
15
|
+
line: number;
|
|
16
|
+
}>;
|
|
17
|
+
/** The findings of R375 over the tracked files of an app root. */
|
|
18
|
+
export declare function seedsFindings({ repoRoot, files }: {
|
|
19
|
+
repoRoot: string;
|
|
20
|
+
files: string[];
|
|
21
|
+
}): Finding[];
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// seeds.mjs - R375, the seed data of an app (contexts/project/stacks.md PROJECT-STACKS-12). It lives in two levels of `.starcistacks/`, the
|
|
2
|
+
// shared `seeds/` and `<env>/seeds/` of an environment the stack declaration names, each a manifest (`seeds.yaml`, stack-seeds-config.mjs) and
|
|
3
|
+
// the folders `sql/`, `data/` and `files/`:
|
|
4
|
+
// HFS_SEEDS_SHAPE a seed file outside those folders, a level of an undeclared environment, custody (a sealed member, a key) in a level;
|
|
5
|
+
// HFS_SEED_MANIFEST a manifest that is missing, does not parse or names a path that is not there, a file no entry covers, an after cycle;
|
|
6
|
+
// HFS_SEED_DDL a statement of a sql entry that changes the schema or its grants (seed data is never a migration);
|
|
7
|
+
// HFS_SEED_DATA_SCHEMA a data document that does not validate against the JSON schema its entry names;
|
|
8
|
+
// HFS_SEED_TEST_DATA a test marker (an example.test identity, a uat- or test- prefix) in a level a production environment applies (the
|
|
9
|
+
// shared level and the level of every environment that is not docker-compose), outside its one test-account entry;
|
|
10
|
+
// HFS_SEED_SECRET_REF a document's secret reference `{ "secret": "<member>" }` to a name that is no seeds-<name>, or to a member the
|
|
11
|
+
// environment does not seal (`.starcistacks/<env>/secrets/<member>.enc.yaml`; the shared level: every environment).
|
|
12
|
+
// A plaintext secret on any seed line is R06's and a real identity R259's (HFS_SEED_PII). A finding names the file and line, never a value.
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
15
|
+
import { validateAgainstSchema } from '../../lib/json-schema.mjs';
|
|
16
|
+
import { findStackDeclaration } from '../../lib/stack-declaration.mjs';
|
|
17
|
+
import { databaseExecutableTokens, databaseSqlStatements, databaseSqlTokens } from './database-migrations.mjs';
|
|
18
|
+
import { envelopeOf } from '../secret-document.mjs';
|
|
19
|
+
import { entryFiles, isDocumentEntry, readSeedLevel, resolveSecrets, SEED_SECRET_MEMBER, secretReferences, seedDocuments, seedMemberFile, SEEDS_DIR, SEEDS_KEEP, SEEDS_MANIFEST } from '../stack-seeds-config.mjs';
|
|
20
|
+
import { found, readText } from './read.mjs';
|
|
21
|
+
export const HFS_SEEDS_SHAPE = 'HFS_SEEDS_SHAPE';
|
|
22
|
+
export const HFS_SEED_MANIFEST = 'HFS_SEED_MANIFEST';
|
|
23
|
+
export const HFS_SEED_DDL = 'HFS_SEED_DDL';
|
|
24
|
+
export const HFS_SEED_DATA_SCHEMA = 'HFS_SEED_DATA_SCHEMA';
|
|
25
|
+
export const HFS_SEED_TEST_DATA = 'HFS_SEED_TEST_DATA';
|
|
26
|
+
export const HFS_SEED_SECRET_REF = 'HFS_SEED_SECRET_REF';
|
|
27
|
+
/** What a secret's value reads as where no value is opened: the schema check and the dry run. */
|
|
28
|
+
const SEALED = '[sealed]';
|
|
29
|
+
/** A seed file: `.starcistacks/seeds/<rest>` or `.starcistacks/<env>/seeds/<rest>`, captured as [level folder, rest]. */
|
|
30
|
+
const SEED_PATH = /^(\.starcistacks\/(?:[^/]+\/)?seeds)\/(.+)$/u;
|
|
31
|
+
/** Where a file of a level may sit: the manifest, or under one of the kind folders. */
|
|
32
|
+
const LEVEL_FILE = /^(?:seeds\.yaml|(?:sql|data|files)\/.+)$/u;
|
|
33
|
+
/** Custody by its shape: a sealed member, a key or certificate, an env file. */
|
|
34
|
+
const CUSTODY = /(?:\.enc(?:\.yaml)?|\.pem|\.key|\.p12|\.pfx|(?:^|\/)\.env(?:\..*)?)$/u;
|
|
35
|
+
/** The statements a seed may not hold: each one changes the schema or its grants, which only a migration does. */
|
|
36
|
+
const SCHEMA_WORDS = new Set(['create', 'alter', 'drop', 'truncate', 'grant', 'revoke', 'comment']);
|
|
37
|
+
/** The words that open a block around statements (a DO block's body is read as statements, so `do begin drop ...` is a DROP). */
|
|
38
|
+
const BLOCK_WORDS = new Set(['do', 'begin']);
|
|
39
|
+
/** A test marker: an identity of the example.test domain, or a value that starts uat- or test-. */
|
|
40
|
+
const TEST_MARKER = /@example\.test\b|^(?:uat|test)[-_]/iu;
|
|
41
|
+
const DOCUMENT = /\.(?:json|ya?ml)$/u;
|
|
42
|
+
/** True for a tracked file of a seed level (the `.gitkeep` placeholders aside). */
|
|
43
|
+
export const isSeedFile = (file) => SEED_PATH.test(file) && !file.endsWith(`/${SEEDS_KEEP}`);
|
|
44
|
+
/** The environments of the stack declaration of `repoRoot` with their runtime, or null when it declares none that parses to a mapping. */
|
|
45
|
+
export function declaredEnvironments(repoRoot) {
|
|
46
|
+
const environments = findStackDeclaration(repoRoot).doc?.environments;
|
|
47
|
+
if (environments === null || typeof environments !== 'object' || Array.isArray(environments))
|
|
48
|
+
return null;
|
|
49
|
+
return new Map(Object.entries(environments).map(([name, entry]) => [name, String(entry?.runtime ?? '')]));
|
|
50
|
+
}
|
|
51
|
+
/** The literals of a seed document or SQL file, each with its line: SQL strings and bodies, or every string value of a JSON or YAML document. */
|
|
52
|
+
export function seedLiterals(file, text) {
|
|
53
|
+
if (!DOCUMENT.test(file))
|
|
54
|
+
return databaseSqlTokens(text).flatMap((token) => (['string', 'body'].includes(token.t) ? [{ v: String(token.v), line: token.line }] : []));
|
|
55
|
+
let doc;
|
|
56
|
+
try {
|
|
57
|
+
doc = file.endsWith('.json') ? JSON.parse(text) : parseYaml(text);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const out = [];
|
|
63
|
+
const visit = (value) => {
|
|
64
|
+
if (typeof value === 'string')
|
|
65
|
+
out.push({ v: value, line: Math.max(1, text.slice(0, text.indexOf(value)).split('\n').length) });
|
|
66
|
+
else if (value !== null && typeof value === 'object')
|
|
67
|
+
for (const item of Object.values(value))
|
|
68
|
+
visit(item);
|
|
69
|
+
};
|
|
70
|
+
visit(doc);
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
/** The schema statements of one SQL seed: the line of each statement whose first word (past a block opener) changes the schema. */
|
|
74
|
+
function schemaStatements(text) {
|
|
75
|
+
return databaseSqlStatements(databaseExecutableTokens(text)).flatMap((statement) => {
|
|
76
|
+
const first = statement.find((token) => !(token.t === 'word' && BLOCK_WORDS.has(token.v)));
|
|
77
|
+
return first?.t === 'word' && SCHEMA_WORDS.has(first.v) ? [{ line: first.line, word: String(first.v).toUpperCase() }] : [];
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/** Where one seed file sits: the shape finding of a file outside the kind folders, of an undeclared environment's level, or of custody. */
|
|
81
|
+
function shapeFinding(file, environments) {
|
|
82
|
+
const [, level = '', rest = ''] = SEED_PATH.exec(file) ?? [];
|
|
83
|
+
const env = level.split('/')[1];
|
|
84
|
+
if (CUSTODY.test(file))
|
|
85
|
+
return found(HFS_SEEDS_SHAPE, file, `${file} is custody, not seed data; a secret is sealed with sops at .starcistacks/<env>/secrets/<slug>.enc.yaml and read by *_FILE`);
|
|
86
|
+
if (env !== SEEDS_DIR && environments !== null && !environments.has(String(env)))
|
|
87
|
+
return found(HFS_SEEDS_SHAPE, file, `${file} is in the seed level of ${String(env)}, which is no environment of .starcistacks/application-stacks.yaml`);
|
|
88
|
+
if (!LEVEL_FILE.test(rest))
|
|
89
|
+
return found(HFS_SEEDS_SHAPE, file, `${file} is no part of a seed level (${SEEDS_MANIFEST}, sql/, data/, files/); starci app codemod seeds-layout moves a loose seed into it`);
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
/** The manifest findings of one level: its problems, and every file of its kind folders that no entry covers. */
|
|
93
|
+
function manifestFindings(repoRoot, level, files) {
|
|
94
|
+
const manifest = `${level.dir}/${SEEDS_MANIFEST}`;
|
|
95
|
+
const own = files.filter((file) => file.startsWith(`${level.dir}/`) && file !== manifest);
|
|
96
|
+
if (own.length > 0 && !files.includes(manifest))
|
|
97
|
+
return [found(HFS_SEED_MANIFEST, manifest, `${level.dir} holds seed files but no ${SEEDS_MANIFEST}; every seed is an entry of its level's manifest`)];
|
|
98
|
+
const covered = new Set(level.entries.flatMap((entry) => entryFiles(path.join(repoRoot, level.dir), entry).map((file) => `${level.dir}/${file}`)));
|
|
99
|
+
return [
|
|
100
|
+
...level.problems.map((problem) => found(HFS_SEED_MANIFEST, manifest, `${manifest} ${problem.at}: ${problem.message}`)),
|
|
101
|
+
...own.filter((file) => !covered.has(file)).map((file) => found(HFS_SEED_MANIFEST, file, `${file} is listed by no entry of ${manifest}`)),
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
/** The parsed JSON schema an entry names, or null when it names none or it cannot be read. */
|
|
105
|
+
function entrySchema(repoRoot, entry) {
|
|
106
|
+
const text = entry.schema === null ? null : readText(repoRoot, entry.schema);
|
|
107
|
+
try {
|
|
108
|
+
return text === null ? null : parseYaml(text);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** The findings of the secret references of one document: a name that is no seed member, or a member an environment of the level lacks. */
|
|
115
|
+
function referenceFindings(file, doc, environments, files) {
|
|
116
|
+
return secretReferences(doc).flatMap((member) => {
|
|
117
|
+
if (!SEED_SECRET_MEMBER.test(member))
|
|
118
|
+
return [found(HFS_SEED_SECRET_REF, file, `${file} references the secret ${member}; a seed references a sealed member named seeds-<name> of its environment`)];
|
|
119
|
+
const missing = environments.filter((env) => !files.has(seedMemberFile(env, member)));
|
|
120
|
+
return missing.map((env) => found(HFS_SEED_SECRET_REF, file, `${file} references ${member}, which ${seedMemberFile(env, member)} does not hold; seal it with starci app secret set --env ${env}`));
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** What the schema sees for a secret reference: the member's keys, each a sealed placeholder (the envelope names them; no value is opened). */
|
|
124
|
+
function sealedShape(repoRoot, environments, member) {
|
|
125
|
+
const text = environments.map((env) => readText(repoRoot, seedMemberFile(env, member))).find((item) => item !== null) ?? null;
|
|
126
|
+
let keys = [];
|
|
127
|
+
try {
|
|
128
|
+
keys = text === null ? [] : envelopeOf(text).keys;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
keys = [];
|
|
132
|
+
}
|
|
133
|
+
return Object.fromEntries(keys.map((key) => [key, SEALED]));
|
|
134
|
+
}
|
|
135
|
+
/** The findings of a document entry: a document off its schema (secret references read as their sealed keys) and its secret references. */
|
|
136
|
+
function documentFindings(repoRoot, level, entry, scope) {
|
|
137
|
+
const manifest = `${level.dir}/${SEEDS_MANIFEST}`;
|
|
138
|
+
const schema = entrySchema(repoRoot, entry);
|
|
139
|
+
if (schema === null)
|
|
140
|
+
return [found(HFS_SEED_DATA_SCHEMA, manifest, `entry ${entry.id} names the schema ${String(entry.schema)}, which is not a readable JSON or YAML schema`)];
|
|
141
|
+
return seedDocuments(path.join(repoRoot, level.dir), entry).flatMap((document) => {
|
|
142
|
+
const file = `${level.dir}/${document.path}`;
|
|
143
|
+
if (document.error !== undefined)
|
|
144
|
+
return [found(HFS_SEED_DATA_SCHEMA, file, `${file} does not parse: ${document.error}`)];
|
|
145
|
+
const errors = validateAgainstSchema(resolveSecrets(document.doc, (member) => sealedShape(repoRoot, scope.environments, member)), schema);
|
|
146
|
+
return [
|
|
147
|
+
...(errors.length > 0 ? [found(HFS_SEED_DATA_SCHEMA, file, `${file} does not validate against ${String(entry.schema)}: ${errors[0] ?? ''}`)] : []),
|
|
148
|
+
...referenceFindings(file, document.doc, scope.environments, scope.files),
|
|
149
|
+
];
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/** The findings of one entry's files: DDL in SQL, documents and secret references, test markers where production applies the level. */
|
|
153
|
+
function entryFindings(repoRoot, level, entry, scope) {
|
|
154
|
+
const findings = isDocumentEntry(entry) ? documentFindings(repoRoot, level, entry, scope) : [];
|
|
155
|
+
// The one test account a level declares holds test data by design; every other entry of a production level holds none.
|
|
156
|
+
const judged = scope.production && entry.kind !== 'test-account';
|
|
157
|
+
for (const file of entryFiles(path.join(repoRoot, level.dir), entry).map((name) => `${level.dir}/${name}`)) {
|
|
158
|
+
if (judged && TEST_MARKER.test(path.posix.basename(file)))
|
|
159
|
+
findings.push(found(HFS_SEED_TEST_DATA, file, `${file} is named as test data in a level a production environment applies`));
|
|
160
|
+
const text = entry.kind === 'files' ? null : readText(repoRoot, file);
|
|
161
|
+
if (text === null)
|
|
162
|
+
continue;
|
|
163
|
+
if (entry.kind === 'sql')
|
|
164
|
+
for (const { line, word } of schemaStatements(text))
|
|
165
|
+
findings.push(found(HFS_SEED_DDL, file, `${file}:${line} runs ${word}, a schema change; seed data is never a migration: the schema change is a migration (BE-PERSISTENCE-1) and a seed only writes rows`, { line }));
|
|
166
|
+
const marker = judged ? seedLiterals(file, text).find((literal) => TEST_MARKER.test(literal.v)) : undefined;
|
|
167
|
+
if (marker)
|
|
168
|
+
findings.push(found(HFS_SEED_TEST_DATA, file, `${file}:${marker.line} holds test data (an example.test identity or a uat- or test- prefix) in a level a production environment applies; the one test account of an environment is its test-account entry`, { line: marker.line }));
|
|
169
|
+
}
|
|
170
|
+
return findings;
|
|
171
|
+
}
|
|
172
|
+
/** The findings of R375 over the tracked files of an app root. */
|
|
173
|
+
export function seedsFindings({ repoRoot, files }) {
|
|
174
|
+
const seeds = files.filter((file) => SEED_PATH.test(file));
|
|
175
|
+
if (seeds.length === 0)
|
|
176
|
+
return [];
|
|
177
|
+
const environments = declaredEnvironments(repoRoot);
|
|
178
|
+
const findings = seeds.flatMap((file) => shapeFinding(file, environments) ?? []);
|
|
179
|
+
const placed = seeds.filter((file) => !findings.some((finding) => finding.path === file) && !file.endsWith(`/${SEEDS_KEEP}`));
|
|
180
|
+
const shared = readSeedLevel(repoRoot, null);
|
|
181
|
+
const levels = [shared, ...[...(environments ?? new Map()).keys()].map((env) => readSeedLevel(repoRoot, env, new Set(shared.entries.map((entry) => entry.id))))];
|
|
182
|
+
const tracked = new Set(files);
|
|
183
|
+
for (const level of levels) {
|
|
184
|
+
findings.push(...manifestFindings(repoRoot, level, placed));
|
|
185
|
+
const scope = {
|
|
186
|
+
environments: level.env === null ? [...(environments?.keys() ?? [])] : [level.env],
|
|
187
|
+
files: tracked,
|
|
188
|
+
production: level.env === null || environments?.get(level.env) !== 'docker-compose',
|
|
189
|
+
};
|
|
190
|
+
for (const entry of level.entries)
|
|
191
|
+
findings.push(...entryFindings(repoRoot, level, entry, scope));
|
|
192
|
+
}
|
|
193
|
+
return findings;
|
|
194
|
+
}
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
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[];
|