@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.
- package/CHANGELOG.md +28 -0
- package/README.md +5 -3
- package/cache/test-family.d.mts +1 -1
- package/cache/test-family.mjs +20 -15
- package/codemod/account-doors.mjs +7 -1
- package/codemod/app-prettier.d.mts +5 -0
- package/codemod/app-prettier.mjs +27 -0
- package/codemod/explicit-surface.mjs +1 -25
- package/codemod/main.mjs +14 -5
- package/codemod/module-definition.d.mts +10 -3
- package/codemod/module-definition.mjs +32 -17
- 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/compile-guard.d.mts +36 -0
- package/sync/compile-guard.mjs +189 -0
- package/sync/index.d.mts +22 -3
- package/sync/index.mjs +80 -32
- 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/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
- 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/identity-refresh-token.entity.ts +4 -0
- 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 +63 -10
- package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
- 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
|
@@ -173,7 +173,7 @@ const REGISTRATIONS = Object.freeze({
|
|
|
173
173
|
{
|
|
174
174
|
file: "be/src/modules/platform/database/index.ts",
|
|
175
175
|
source: "./seed-runner.service",
|
|
176
|
-
names: ["SeedRunnerService"],
|
|
176
|
+
names: ["DEFAULT_SEED_ENV", "SeedRunnerService"],
|
|
177
177
|
beforeSource: "./database.options",
|
|
178
178
|
},
|
|
179
179
|
],
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { byCodeUnit } from '../report/order.mjs';
|
|
8
|
+
import { canonImport, canonJson, canonList } from '../sync/canon-layout.mjs';
|
|
8
9
|
import { pascalOf } from './service.mjs';
|
|
9
10
|
/** The folder name prefix of a module package. */
|
|
10
11
|
export const MODULE_PREFIX = 'module-';
|
|
@@ -13,7 +14,8 @@ export const REGISTRY_DIR = 'packages/modules';
|
|
|
13
14
|
/** The product's agent SDK package folder every module builds on. */
|
|
14
15
|
export const SDK_DIR = 'packages/agent-sdk';
|
|
15
16
|
const HEADER = '// Generated by starci app sync (module registry). Do not edit: run "npx starci app sync --execute".';
|
|
16
|
-
|
|
17
|
+
// A package.json is printed by Prettier the way JSON.stringify prints it; every other JSON file of the registry follows the canon layout.
|
|
18
|
+
const packageJson = (value) => `${JSON.stringify(value, null, 2)}\n`;
|
|
17
19
|
/** `loyalty-points` as the UPPER_SNAKE prefix of the constants a module exports. */
|
|
18
20
|
export const upperOf = (name) => name.replace(/-/g, '_').toUpperCase();
|
|
19
21
|
/** The module names under `packages/` of the app at `root` (a folder module-<name> holding a package.json), sorted. */
|
|
@@ -39,13 +41,13 @@ export function registryFiles(project, names) {
|
|
|
39
41
|
const sorted = [...names].sort(byCodeUnit);
|
|
40
42
|
const sdk = `@${project}/agent-sdk`;
|
|
41
43
|
const module = (name) => `@${project}/${MODULE_PREFIX}${name}`;
|
|
42
|
-
const serverImports = sorted.map((name) =>
|
|
43
|
-
const workbenchImports = sorted.map((name) =>
|
|
44
|
+
const serverImports = sorted.map((name) => canonImport({ names: [`${upperOf(name)}_SERVER`], from: `${module(name)}/template` })).join('\n');
|
|
45
|
+
const workbenchImports = sorted.map((name) => canonImport({ names: [`${upperOf(name)}_WORKBENCH`], from: `${module(name)}/manifest` })).join('\n');
|
|
44
46
|
const dependencies = Object.fromEntries([[sdk, '*'], ...sorted.map((name) => [module(name), '*'])].sort(([a], [b]) => byCodeUnit(a, b)));
|
|
45
47
|
return [
|
|
46
48
|
{
|
|
47
49
|
path: `${REGISTRY_DIR}/package.json`,
|
|
48
|
-
content:
|
|
50
|
+
content: packageJson({
|
|
49
51
|
name: `@${project}/modules`,
|
|
50
52
|
version: '0.0.0',
|
|
51
53
|
private: true,
|
|
@@ -57,11 +59,11 @@ export function registryFiles(project, names) {
|
|
|
57
59
|
},
|
|
58
60
|
{
|
|
59
61
|
path: `${REGISTRY_DIR}/tsconfig.json`,
|
|
60
|
-
content:
|
|
62
|
+
content: canonJson({ extends: '@starci/tsconfig/next.json', compilerOptions: { incremental: false, types: [] }, include: ['src/**/*.ts'], exclude: ['node_modules', 'dist'] }),
|
|
61
63
|
},
|
|
62
64
|
{
|
|
63
65
|
path: `${REGISTRY_DIR}/tsconfig.build.json`,
|
|
64
|
-
content:
|
|
66
|
+
content: canonJson({
|
|
65
67
|
extends: './tsconfig.json',
|
|
66
68
|
compilerOptions: { rootDir: 'src', outDir: 'dist', noEmit: false, declaration: true, module: 'node16', moduleResolution: 'node16' },
|
|
67
69
|
}),
|
|
@@ -72,11 +74,11 @@ export function registryFiles(project, names) {
|
|
|
72
74
|
},
|
|
73
75
|
{
|
|
74
76
|
path: `${REGISTRY_DIR}/src/template/index.ts`,
|
|
75
|
-
content: `${HEADER}\
|
|
77
|
+
content: `${HEADER}\n${canonImport({ names: ['ModuleServer'], from: `${sdk}/module`, type: true })}\n${serverImports}\n\n/** The server half of every module built on the SDK; the back end reads nothing else of a module. */\n${canonList({ head: 'export const MODULE_SERVERS: ReadonlyArray<ModuleServer> = ', items: sorted.map((name) => `${upperOf(name)}_SERVER`) })}\n`,
|
|
76
78
|
},
|
|
77
79
|
{
|
|
78
80
|
path: `${REGISTRY_DIR}/src/manifest/index.ts`,
|
|
79
|
-
content: `${HEADER}\
|
|
81
|
+
content: `${HEADER}\n${canonImport({ names: ['specRegistryFrom'], from: `${sdk}/workbench/contract` })}\n${canonImport({ names: ['WorkbenchDeclaration', 'WorkbenchSpecRegistry'], from: `${sdk}/workbench/contract`, type: true })}\n${workbenchImports}\n\n/** The workbench declaration of every module built on the SDK; the front end pairs each with how it draws. */\n${canonList({ head: 'export const MODULE_WORKBENCHES: ReadonlyArray<WorkbenchDeclaration> = ', items: sorted.map((name) => `${upperOf(name)}_WORKBENCH`) })}\n\n/** Everything a stored spec may name: the SDK's blocks and each module's exposed blocks, reads and actions. */\nexport const SPEC_REGISTRY: WorkbenchSpecRegistry = specRegistryFrom(MODULE_WORKBENCHES)\n`,
|
|
80
82
|
},
|
|
81
83
|
];
|
|
82
84
|
}
|
package/src/main.mjs
CHANGED
|
@@ -8,7 +8,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
8
8
|
};
|
|
9
9
|
// The @starci/hfs implementation behind `starci app`: one repository, `<app>/`, with one package.json, lockfile and node_modules
|
|
10
10
|
// at its root and two sides, be/ and fe/, declared by the one hfs.json of kind app. Every verb runs at the app root.
|
|
11
|
-
// starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--side be|fe] [--fix] [--out <sonar file>] [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
|
|
11
|
+
// starci app check [--cwd <dir>] [--json] [--gate <hfs|format|db|secrets|wiring|types|lint|test|sonar|codecov>]... [--side be|fe|packages] [--fix] [--out <sonar file>] [--no-cache] [--coverage-from <lcov>] [--shard <i>/<n>] | check --staged
|
|
12
12
|
// every tracked path has a slot; required files exist; nothing forbidden or
|
|
13
13
|
// tracked-that-must-be-ignored; pins match; every managed file equals its render
|
|
14
14
|
// (sync/managed.mjs, the .gitignore block and sonar-project.properties included); no empty or ghost
|
|
@@ -29,7 +29,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
29
29
|
// progress goes to stderr (--quiet silences it). STARCI_APP_CHECK_CACHE=verify runs diff then full and fails (exit 2)
|
|
30
30
|
// on any difference; =full never reads the cache (the release gate).
|
|
31
31
|
// --coverage-from <lcov> judges a coverage report produced elsewhere (the merge of a sharded unit run) and records nothing.
|
|
32
|
-
// --side be|fe limits the lint gate to one side (a CI matrix runs one per
|
|
32
|
+
// --side be|fe|packages limits the lint gate to one side or to the root packages (a CI matrix runs one per scope). --out <file> writes every finding of the run as ONE
|
|
33
33
|
// Sonar Generic Issue Import document (engine ids starci-hfs, eslint, stylelint). --fix runs ESLint and stylelint with --fix before the
|
|
34
34
|
// check and is refused unless --gate lint is selected.
|
|
35
35
|
// --staged: the pre-commit guard alone (sync/hygiene.mjs: staged .starciwork and .starcistacks paths, the secrets
|
|
@@ -104,7 +104,7 @@ import { addKind } from '../scaffold/add.mjs';
|
|
|
104
104
|
import { removeModule } from '../scaffold/add-module.mjs';
|
|
105
105
|
import { scaffoldApp } from '../scaffold/app.mjs';
|
|
106
106
|
import { newImages } from '../scaffold/image.mjs';
|
|
107
|
-
import { cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
|
|
107
|
+
import { PACKAGES, cachedLinters, eslintRepository, runLinter as nativeLinter } from '../lint/run.mjs';
|
|
108
108
|
import { runSonarForApp, runTestForApp, runTestShard, runTypesForApp } from '../cache/app-families.mjs';
|
|
109
109
|
import { CACHE_ENV, createDiskSession } from '../cache/session.mjs';
|
|
110
110
|
import { DEFAULT_FAMILIES, runAppFamilies } from '../cache/run.mjs';
|
|
@@ -177,7 +177,7 @@ const PARSED_FLAGS = new Set([...Object.values(PARSED).flatMap(flagsOf), '--edit
|
|
|
177
177
|
/** The placeholder of a value flag in the usage; any other prints as <name>. */
|
|
178
178
|
const PLACEHOLDERS = {
|
|
179
179
|
'--gate': `<${CHECK_GATES.join('|')}>`,
|
|
180
|
-
'--registry-visibility': 'public|private', '--side': 'be|fe', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
|
|
180
|
+
'--registry-visibility': 'public|private', '--side': 'be|fe|packages', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
|
|
181
181
|
'--event': '<event>', '--from': '<service>', '--service': '<Class>=<module>', '--owner': '<service>', '--failed': '<event>', '--done': '<event>',
|
|
182
182
|
'--shapes': '<a,b,c>', '--blocks': '<a,b>', '--tabs': '<a,b>', '--kind': 'modal|drawer', '--image': '<service>=<ref>', '--sha': '<sha>',
|
|
183
183
|
'--previous': '<release>', '--release': '<release>', '--out': '<path>', '--since': '<ISO>', '--timeout': '<seconds>', '--retry': '<seconds>',
|
|
@@ -478,7 +478,7 @@ function familyOf(finding) {
|
|
|
478
478
|
async function fixLint({ repoRoot, side, run }) {
|
|
479
479
|
const app = readRepoDeclaration(loadSlotManifest(), repoRoot);
|
|
480
480
|
await eslintRepository({ repoRoot, app, opts: { ...(side === undefined ? {} : { side }), fix: true }, run });
|
|
481
|
-
if (side
|
|
481
|
+
if (side === undefined || side === 'fe')
|
|
482
482
|
await (run ?? nativeLinter)({ cwd: path.join(repoRoot, 'fe'), pkg: 'stylelint', bin: 'stylelint', args: [STYLE_GLOB, '--formatter', 'json', '--fix'] });
|
|
483
483
|
}
|
|
484
484
|
/** One run of the selected families against the cache, then the coverage proof on top; returns the result, what it took and the raw linter results. */
|
|
@@ -543,7 +543,7 @@ function writeSonarImport({ repoRoot, file, findings, raw, side }) {
|
|
|
543
543
|
const onSide = (candidate) => {
|
|
544
544
|
if (side === undefined)
|
|
545
545
|
return true;
|
|
546
|
-
const owner = SIDES.find((each) => candidate === each || candidate.startsWith(`${each}/`));
|
|
546
|
+
const owner = [...SIDES, PACKAGES].find((each) => candidate === each || candidate.startsWith(`${each}/`));
|
|
547
547
|
return (owner ?? 'be') === side;
|
|
548
548
|
};
|
|
549
549
|
const sourceRoots = sourceRootsOf(properties).filter(onSide);
|
|
@@ -591,8 +591,8 @@ async function checkMain(opts, io) {
|
|
|
591
591
|
const sonarOut = valueFlag(opts, 'out');
|
|
592
592
|
const fix = booleanFlag(opts, 'fix') === true;
|
|
593
593
|
const explicit = selectedFamilies(opts, []);
|
|
594
|
-
if (side !== undefined && !['be', 'fe'].includes(side))
|
|
595
|
-
throw new UsageError('LINT_SIDE_INVALID: --side is be or
|
|
594
|
+
if (side !== undefined && !['be', 'fe', PACKAGES].includes(side))
|
|
595
|
+
throw new UsageError('LINT_SIDE_INVALID: --side is be, fe or packages');
|
|
596
596
|
if (fix && !explicit.has('lint'))
|
|
597
597
|
throw new UsageError('--fix runs the linters and needs --gate lint');
|
|
598
598
|
if (shard !== undefined) {
|
package/stack/deploy.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DeployConfig } from './config.mjs';
|
|
2
2
|
import type { StackContext } from './context.mjs';
|
|
3
|
+
import type { DeployDatabase } from './db-phases.mjs';
|
|
3
4
|
import type { ProvisionOptions } from './provision.mjs';
|
|
4
5
|
import type { ObservedService } from './status.mjs';
|
|
5
6
|
import type { ActionResult, Runner, SshOptions } from './remote.mjs';
|
|
@@ -62,6 +63,8 @@ export interface DeployDeps {
|
|
|
62
63
|
}) => Promise<ActionResult>;
|
|
63
64
|
reconcileEdge: (ctx: StackContext, config: DeployConfig, file: string) => ActionResult | Promise<ActionResult>;
|
|
64
65
|
postDeployStack: (ctx: StackContext, config: DeployConfig) => Promise<ActionResult>;
|
|
66
|
+
/** The seed step after convergence (seeds.mjs seedDeployed). */
|
|
67
|
+
seedRelease: (ctx: StackContext, config: DeployConfig, database: DeployDatabase, f: FloorDeps) => Promise<ActionResult>;
|
|
65
68
|
verifyStack: (ctx: StackContext, config: DeployConfig, options: {
|
|
66
69
|
retrySeconds?: number | undefined;
|
|
67
70
|
skipPublic?: boolean;
|
package/stack/deploy.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import { describeClash, edgeCollisions, reconcileEdgeAliases } from './edge-alia
|
|
|
35
35
|
import { firstLine, tool } from './context.mjs';
|
|
36
36
|
import { hasPlaceholderDigest, isReleaseId } from './digest.mjs';
|
|
37
37
|
import { contractPhase, declaredDatabase, expandPhase, firstStartHooks, FLOOR_DEPS, unknownDatabaseService } from './db-phases.mjs';
|
|
38
|
+
import { hasSeedEntries, seedPlanLine } from './seeds.mjs';
|
|
38
39
|
import { failure, refusal } from './errors.mjs';
|
|
39
40
|
import { recordedAttempt } from './journal.mjs';
|
|
40
41
|
import { postDeployStack } from './post-deploy.mjs';
|
|
@@ -57,6 +58,7 @@ export const DEFAULT_DEPS = Object.freeze({
|
|
|
57
58
|
convergeStack,
|
|
58
59
|
reconcileEdge: reconcileEdgeAliases,
|
|
59
60
|
postDeployStack,
|
|
61
|
+
seedRelease: lazy('./seeds.mjs', 'seedDeployed'),
|
|
60
62
|
verifyStack,
|
|
61
63
|
pruneStack,
|
|
62
64
|
releaseStore: createReleaseStore,
|
|
@@ -340,6 +342,7 @@ async function deployExecute(ctx, config, options, d, out, seams) {
|
|
|
340
342
|
['deploy', () => stackDeploy(ctx, config, stackFile)],
|
|
341
343
|
['edge', async () => d.reconcileEdge(ctx, config, stackFile)],
|
|
342
344
|
['converge', async () => { const converged = await d.convergeStack(ctx, config, { since, timeoutSeconds: options.timeoutSeconds }); snapshot(ctx, config, d, rec, 'converge'); return converged; }],
|
|
345
|
+
...(database && hasSeedEntries(config.repoRoot, config.env) ? [['seed', () => d.seedRelease(ctx, config, database, { ...FLOOR_DEPS, ...d.floor })]] : []),
|
|
343
346
|
...(config.postDeploy.length > 0 ? [['post-deploy', () => d.postDeployStack(ctx, config)]] : []),
|
|
344
347
|
['verify', () => d.verifyStack(ctx, config, { retrySeconds: options.retrySeconds, skipPublic: skipPublic === true, stackFile: path.join(out, 'stack.yaml') })],
|
|
345
348
|
...(skipPublic ? [] : [['accept', async () => { await acceptObserved(ctx, config, d, store, rec, { out, releaseId }); return { ok: true, lines: [`accepted release ${releaseId}`] }; }]]),
|
|
@@ -400,7 +403,7 @@ export async function deployRelease(ctx, config, options = {}, deps = {}) {
|
|
|
400
403
|
const { rendered, releaseId } = await render();
|
|
401
404
|
const post = config.postDeploy.length > 0 ? ` post-deploy tasks (${config.postDeploy.join(', ')}),` : '';
|
|
402
405
|
const database = options.urgent === true ? null : await declaredDatabase(ctx, config);
|
|
403
|
-
const databasePlan = database ? `; database (${database.target === 'stack' ? `inside the ${database.service} container, no URL held` : 'external, by --db-url-env'}): migrate preview, a verified backup only when a migration is pending, the expand migrations before the deploy, the contract migrations after the accept, and an automatic first start when no release was ever accepted` : '';
|
|
406
|
+
const databasePlan = database ? `; database (${database.target === 'stack' ? `inside the ${database.service} container, no URL held` : 'external, by --db-url-env'}): migrate preview, a verified backup only when a migration is pending, the expand migrations before the deploy, the contract migrations after the accept, and an automatic first start when no release was ever accepted${hasSeedEntries(config.repoRoot, config.env) ? `; ${seedPlanLine(config.repoRoot, config.env)}` : ''}` : '';
|
|
404
407
|
const plan = options.skipPublic
|
|
405
408
|
? `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} and verified inside the stack (public hosts skipped); it would stay pending until ingress publish and stack accept${databasePlan}`
|
|
406
409
|
: `dry run: release ${releaseId} would be provisioned, stored, pre-pulled, deployed, converged,${post} verified, accepted${databasePlan}`;
|
package/stack/local-up.mjs
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
// local-up.mjs - `starci app stack up --env <dev env> [--execute]`: the one command that brings the whole dev environment up (PROJECT-STACKS-6).
|
|
2
2
|
// Order: host prerequisites, dev custody, setup (install, codegen), compose services and their health, the environment's own database role,
|
|
3
|
-
// migrations with a receipt, seed, native app processes, readiness, origins. Idempotent: what runs is kept, what changed is converged.
|
|
3
|
+
// migrations with a receipt, the seed levels with their receipts, the seed commands, native app processes, readiness, origins. Idempotent: what runs is kept, what changed is converged.
|
|
4
4
|
// A dry run prints the plan. It never generates a replacement secret, never removes a volume and touches only its own compose project.
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { firstLine } from './context.mjs';
|
|
9
|
+
import { dbTarget } from '../db/postgres.mjs';
|
|
9
10
|
import { failure, refusal } from './errors.mjs';
|
|
10
11
|
import { missingKeys, missingStep, openCustody } from './local-custody.mjs';
|
|
11
12
|
import { hostRefusal, preflight, runSetup } from './local-prepare.mjs';
|
|
12
13
|
import { DEFAULT_TIMEOUT_SECONDS, makeDataDirectories, commandDigest, compose, composeValues, databaseUrl, isAlive, probe, runCommand, runEnvironment, serviceReady, startProcess, stateOf, waitFor } from './local-run.mjs';
|
|
14
|
+
import { launch } from './local-host.mjs';
|
|
13
15
|
import { readRecord, writeRecord } from './local-state.mjs';
|
|
16
|
+
import { SEED_RECEIPTS, seedStep } from './seeds.mjs';
|
|
14
17
|
import { ensureRuntimeLayers, layerPlan } from './runtime-layers.mjs';
|
|
15
18
|
const SAFE_PASSWORD = /^[A-Za-z0-9_-]{8,}$/u;
|
|
16
19
|
/** The digest of the migration folder (names and contents): what a migrate receipt records. */
|
|
@@ -81,6 +84,25 @@ async function migrate(ctx, local, custody, deps) {
|
|
|
81
84
|
writeRecord(dir, record);
|
|
82
85
|
return `migrate: applied, receipt ${record.receipts.migrate.digest}${await appRolePassword(ctx, local, custody)}`;
|
|
83
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Applies the seed levels (seeds.mjs, PROJECT-STACKS-12) on the environment's database under its owner role, the role the migrations ran as: the
|
|
89
|
+
* receipts in the database keep a rerun from applying anything that did not change. A document entry's app command runs from the app root with
|
|
90
|
+
* the owner's environment, the documents on its stdin.
|
|
91
|
+
*/
|
|
92
|
+
async function seedLevels(ctx, local, custody) {
|
|
93
|
+
const database = local.database;
|
|
94
|
+
const url = databaseUrl(local, custody);
|
|
95
|
+
if (!database || url === null)
|
|
96
|
+
return [];
|
|
97
|
+
const container = firstLine((await compose(ctx, local, ['ps', '-q', database.service])).stdout);
|
|
98
|
+
const target = dbTarget({ ...ctx, env: { ...ctx.env, DATABASE_URL: url, STARCI_DB_CONTAINER: container } }, 'DATABASE_URL');
|
|
99
|
+
const env = runEnvironment(ctx, local, { name: 'seed', argv: [], cwd: '.', heavy: 'none', skipIfExists: null, env: {}, files: [...custody.values.keys()], after: 'migrate' }, custody, 'owner');
|
|
100
|
+
const command = (argv, input) => {
|
|
101
|
+
const [program, args] = launch(ctx.platform, argv[0] ?? '', argv.slice(1));
|
|
102
|
+
return ctx.spawn(program, args, { cwd: local.repoRoot, env, input });
|
|
103
|
+
};
|
|
104
|
+
return seedStep({ ctx, repoRoot: local.repoRoot, env: local.env, target, execute: true, command }).lines;
|
|
105
|
+
}
|
|
84
106
|
/** Runs the seed commands once each (a receipt keyed by the command's digest); the seed owns its idempotence beyond that. */
|
|
85
107
|
async function seed(ctx, local, custody, after) {
|
|
86
108
|
const dir = stateOf(ctx, local);
|
|
@@ -125,7 +147,7 @@ function plan(local, missing) {
|
|
|
125
147
|
...local.setup.map((run) => `plan setup ${run.name}: ${run.argv.join(' ')}`),
|
|
126
148
|
...layerPlan(local),
|
|
127
149
|
`plan services: ${local.services.map((service) => `${service.name} (wait ${service.wait})`).join(', ')}`,
|
|
128
|
-
...(local.database ? [`plan database: own role ${local.database.role}, database ${local.database.name}, then starci app db migrate`] : []),
|
|
150
|
+
...(local.database ? [`plan database: own role ${local.database.role}, database ${local.database.name}, then starci app db migrate`, `plan seed levels: .starcistacks/seeds, then .starcistacks/${local.env}/seeds, each entry once per content hash (receipts in ${SEED_RECEIPTS})`] : []),
|
|
129
151
|
...local.seed.map((run) => `plan seed ${run.name}: ${run.argv.join(' ')}`),
|
|
130
152
|
...local.processes.map((run) => `plan process ${run.name}: ${run.argv.join(' ')}`),
|
|
131
153
|
...local.ready.map((row) => `plan ready ${row.name}: ${row.url}`),
|
|
@@ -156,7 +178,7 @@ export async function upLocal(ctx, local, { execute = false, timeoutSeconds = DE
|
|
|
156
178
|
if (!ready.ok)
|
|
157
179
|
throw failure('STACK_LOCAL_SERVICE', `${service.name} did not become ready in ${timeoutSeconds}s (${ready.detail}); read it with: starci app stack logs --env ${local.env} --service ${service.name}`);
|
|
158
180
|
}
|
|
159
|
-
lines.push(await provisionDatabase(ctx, local, custody), await migrate(ctx, local, custody, deps), ...await seed(ctx, local, custody, 'migrate'), ...startProcesses(ctx, local, custody, deps));
|
|
181
|
+
lines.push(await provisionDatabase(ctx, local, custody), await migrate(ctx, local, custody, deps), ...await seedLevels(ctx, local, custody), ...await seed(ctx, local, custody, 'migrate'), ...startProcesses(ctx, local, custody, deps));
|
|
160
182
|
for (const row of local.ready) {
|
|
161
183
|
const result = await waitFor(ctx, deadline, () => probe(ctx, local, row));
|
|
162
184
|
lines.push(`${result.ok ? 'ok ' : 'FAIL '}ready ${result.detail}`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { DbTarget } from '../db/postgres.mjs';
|
|
2
|
+
import type { DeployConfig } from './config.mjs';
|
|
3
|
+
import type { RunResult, StackContext } from './context.mjs';
|
|
4
|
+
import type { DeployDatabase, FloorDeps } from './db-phases.mjs';
|
|
5
|
+
import type { ActionResult } from './remote.mjs';
|
|
6
|
+
/** The receipts table of the seed step, in the app's database. */
|
|
7
|
+
export declare const SEED_RECEIPTS = "public.starci_seed_receipts";
|
|
8
|
+
/** What the seed step reaches: the app root, the environment, its database (the migrations' target) and how an app command runs. */
|
|
9
|
+
export interface SeedStepInput {
|
|
10
|
+
ctx: StackContext;
|
|
11
|
+
repoRoot: string;
|
|
12
|
+
env: string;
|
|
13
|
+
target: DbTarget;
|
|
14
|
+
execute: boolean;
|
|
15
|
+
/** Runs a document entry's app command with the resolved documents on stdin. */
|
|
16
|
+
command: (argv: readonly string[], input: string) => RunResult;
|
|
17
|
+
}
|
|
18
|
+
/** The seed step of `env`: plans it, or with `execute` applies it. Returns { ok, lines, data: { applied, kept } }. */
|
|
19
|
+
export declare function seedStep(input: SeedStepInput): ActionResult;
|
|
20
|
+
/** True when either seed level of the environment declares an entry. */
|
|
21
|
+
export declare function hasSeedEntries(repoRoot: string, env: string): boolean;
|
|
22
|
+
/** What a deploy's dry run says of the seed step: every entry of the two levels, each applied unless its receipt holds. */
|
|
23
|
+
export declare function seedPlanLine(repoRoot: string, env: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* The seed step of a deploy (PROJECT-STACKS-6), after convergence so a document entry's app command is the new release's: in the database
|
|
26
|
+
* container under the credentials it holds (the role the migrations ran as), each app command inside a running task of the jobs service
|
|
27
|
+
* (`deploy.jobs.service`), the documents on its stdin.
|
|
28
|
+
*/
|
|
29
|
+
export declare function seedDeployed(ctx: StackContext, config: DeployConfig, database: Pick<DeployDatabase, 'service'>, f: Pick<FloorDeps, 'dbContainer' | 'containerTarget'>): Promise<ActionResult>;
|
package/stack/seeds.mjs
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// seeds.mjs - the seed step (contexts/project/stacks.md PROJECT-STACKS-12): after the migrations, the entries of the shared level and then of
|
|
2
|
+
// the environment's level, each in apply order, on the database the migrations ran on and under the role they ran as. The harness owns
|
|
3
|
+
// the receipts: one row per entry in `public.starci_seed_receipts` of that database, created by this step (never an app migration), holding
|
|
4
|
+
// the entry's content hash and, for a document entry with secret references, the member names and a digest of their values. A `once` entry
|
|
5
|
+
// with a receipt never runs again; an `upsert` entry runs again when its hash (or a referenced secret) changed; a rerun with nothing changed
|
|
6
|
+
// writes nothing. A `sql` entry runs in one transaction with its receipt. A `data` or `test-account` entry validates its documents against the
|
|
7
|
+
// app's schema, opens their secret references in memory with the environment's age identity (the sops seam) and hands the resolved documents
|
|
8
|
+
// to the app command it names on stdin, never in argv, a file or a log; its receipt is written once the command succeeded. A `files` entry
|
|
9
|
+
// is validated only: the harness has no object-storage client, so its upload stays the app's. Without `execute` the step reads the
|
|
10
|
+
// receipts and prints what would apply.
|
|
11
|
+
import crypto from 'node:crypto';
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { literal, psqlTransaction, queryRows } from '../db/postgres.mjs';
|
|
15
|
+
import { validateAgainstSchema } from '../runtime/scripts/lib/json-schema.mjs';
|
|
16
|
+
import { parseYaml } from '../runtime/engine/yaml.mjs';
|
|
17
|
+
import { envelopeOf } from '../runtime/scripts/hfs/secret-document.mjs';
|
|
18
|
+
import { entryDigest, entryFiles, isDocumentEntry, readSeedLevel, resolveSecrets, secretReferences, seedDocuments, seedMemberFile } from '../runtime/scripts/hfs/stack-seeds-config.mjs';
|
|
19
|
+
import { lines as splitLines, tool } from './context.mjs';
|
|
20
|
+
import { DB_WAIT_SECONDS } from './db-phases.mjs';
|
|
21
|
+
import { failure, refusal } from './errors.mjs';
|
|
22
|
+
/** The receipts table of the seed step, in the app's database. */
|
|
23
|
+
export const SEED_RECEIPTS = 'public.starci_seed_receipts';
|
|
24
|
+
const RECEIPTS_DDL = `create table if not exists ${SEED_RECEIPTS} (scope text not null, id text not null, hash text not null, secrets text not null default '', env text not null, applied_at timestamptz not null default now(), primary key (scope, id))`;
|
|
25
|
+
/** The scope of the shared level's receipts; an environment's level uses its name. */
|
|
26
|
+
const SHARED_SCOPE = 'shared';
|
|
27
|
+
/** What a secret's value reads as in a dry run, which opens no member. */
|
|
28
|
+
const SEALED = '[sealed]';
|
|
29
|
+
const sha = (text) => crypto.createHash('sha256').update(text).digest('hex');
|
|
30
|
+
/** The two levels of `env` in apply order; a manifest problem refuses the step before anything runs. */
|
|
31
|
+
function levelsOf(repoRoot, env) {
|
|
32
|
+
const shared = readSeedLevel(repoRoot, null);
|
|
33
|
+
const own = readSeedLevel(repoRoot, env, new Set(shared.entries.map((entry) => entry.id)));
|
|
34
|
+
for (const level of [shared, own]) {
|
|
35
|
+
const [problem] = level.problems;
|
|
36
|
+
if (problem)
|
|
37
|
+
throw refusal('STACK_SEED_MANIFEST', `${level.dir}/seeds.yaml ${problem.at}: ${problem.message}; run starci app check`);
|
|
38
|
+
}
|
|
39
|
+
return [shared, own];
|
|
40
|
+
}
|
|
41
|
+
/** The receipts the database holds; none before the first armed run created the table. */
|
|
42
|
+
function receiptsOf(input) {
|
|
43
|
+
const [present] = queryRows(input.ctx, input.target, `select to_regclass(${literal(SEED_RECEIPTS)}) is not null as present`);
|
|
44
|
+
if (present?.present !== true)
|
|
45
|
+
return [];
|
|
46
|
+
return queryRows(input.ctx, input.target, `select scope, id, hash, secrets from ${SEED_RECEIPTS}`).map((row) => ({ scope: String(row.scope), id: String(row.id), hash: String(row.hash), secrets: String(row.secrets) }));
|
|
47
|
+
}
|
|
48
|
+
/** The statement that records an entry's receipt. */
|
|
49
|
+
const receiptSql = (item, env, secrets) => `insert into ${SEED_RECEIPTS} (scope, id, hash, secrets, env) values (${literal(item.scope)}, ${literal(item.entry.id)}, ${literal(item.hash)}, ${literal(secrets)}, ${literal(env)}) ` +
|
|
50
|
+
'on conflict (scope, id) do update set hash = excluded.hash, secrets = excluded.secrets, env = excluded.env, applied_at = now()';
|
|
51
|
+
/** The documents of a document entry with its secret references resolved by `open`, refused when one fails the app's schema. */
|
|
52
|
+
function resolvedDocuments(input, item, open) {
|
|
53
|
+
const schemaText = fs.readFileSync(path.join(input.repoRoot, String(item.entry.schema)), 'utf8');
|
|
54
|
+
const schema = item.entry.schema?.endsWith('.json') ? JSON.parse(schemaText) : parseYaml(schemaText);
|
|
55
|
+
return seedDocuments(path.join(input.repoRoot, item.level.dir), item.entry).map((document) => {
|
|
56
|
+
if (document.error !== undefined)
|
|
57
|
+
throw refusal('STACK_SEED_DATA_INVALID', `${item.scope}/${item.entry.id}: ${document.path} does not parse`);
|
|
58
|
+
const resolved = resolveSecrets(document.doc, open);
|
|
59
|
+
const [error] = validateAgainstSchema(resolved, schema);
|
|
60
|
+
if (error !== undefined)
|
|
61
|
+
throw refusal('STACK_SEED_DATA_INVALID', `${item.scope}/${item.entry.id}: ${document.path} does not validate against ${String(item.entry.schema)}: ${error.replace(/:.*$/u, '')}`);
|
|
62
|
+
return { path: document.path, document: resolved };
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** The members a document entry references, in first-use order. */
|
|
66
|
+
const membersOf = (input, item) => [...new Set(seedDocuments(path.join(input.repoRoot, item.level.dir), item.entry).flatMap((document) => secretReferences(document.doc)))];
|
|
67
|
+
/** Opens one sealed member of the environment in memory (the sops seam and its age identity) and answers its map. */
|
|
68
|
+
function openMember(input, member) {
|
|
69
|
+
const file = memberPath(input, member);
|
|
70
|
+
return input.ctx.sops.decrypt(file, envelopeOf(fs.readFileSync(file, 'utf8')).format);
|
|
71
|
+
}
|
|
72
|
+
/** The sealed file of a member of the environment; refused when the environment does not seal it. */
|
|
73
|
+
function memberPath(input, member) {
|
|
74
|
+
const file = path.join(input.repoRoot, seedMemberFile(input.env, member));
|
|
75
|
+
if (!fs.existsSync(file))
|
|
76
|
+
throw refusal('STACK_SEED_SECRET_MISSING', `${seedMemberFile(input.env, member)} is not sealed; seal it with starci app secret set --env ${input.env}`);
|
|
77
|
+
return file;
|
|
78
|
+
}
|
|
79
|
+
/** What a member reads as in a dry run: its key names from the envelope, each a sealed placeholder; no value is opened. */
|
|
80
|
+
function sealedMember(input, member) {
|
|
81
|
+
return Object.fromEntries(envelopeOf(fs.readFileSync(memberPath(input, member), 'utf8')).keys.map((key) => [key, SEALED]));
|
|
82
|
+
}
|
|
83
|
+
/** The receipt text of the opened members: each name with a digest of its values, never a value. */
|
|
84
|
+
const secretsReceipt = (opened) => [...opened].map(([member, values]) => `${member}:${sha(JSON.stringify(Object.entries(values).sort(([a], [b]) => (a < b ? -1 : Number(a > b))))).slice(0, 16)}`).join(',');
|
|
85
|
+
/** Applies one document entry: the app command with the resolved documents on stdin, then its receipt; answers the receipt's secrets. */
|
|
86
|
+
function applyDocuments(input, item, receipt) {
|
|
87
|
+
const opened = new Map();
|
|
88
|
+
const open = (member) => {
|
|
89
|
+
const known = opened.get(member);
|
|
90
|
+
if (known)
|
|
91
|
+
return known;
|
|
92
|
+
const values = openMember(input, member);
|
|
93
|
+
opened.set(member, values);
|
|
94
|
+
return values;
|
|
95
|
+
};
|
|
96
|
+
const documents = resolvedDocuments(input, item, open);
|
|
97
|
+
const secrets = secretsReceipt(opened);
|
|
98
|
+
if (receipt?.hash === item.hash && receipt.secrets === secrets)
|
|
99
|
+
return null;
|
|
100
|
+
const run = input.command(item.entry.target, `${JSON.stringify({ env: input.env, scope: item.scope, entry: item.entry.id, kind: item.entry.kind, documents })}\n`);
|
|
101
|
+
if (run.error || run.status !== 0)
|
|
102
|
+
throw failure('STACK_SEED_COMMAND', `${item.scope}/${item.entry.id}: ${item.entry.target.join(' ')} ${run.error ? 'could not run' : `exited ${String(run.status)}`}; its output is withheld (it held the opened secrets)`);
|
|
103
|
+
psqlTransaction(input.ctx, input.target, [{ sql: receiptSql(item, input.env, secrets) }], { withhold: true, code: 'STACK_SEED_RECEIPT' });
|
|
104
|
+
return secrets;
|
|
105
|
+
}
|
|
106
|
+
/** The line of an entry the step leaves as it is, or null when it applies. */
|
|
107
|
+
function keptLine(item, receipt) {
|
|
108
|
+
const name = `seed ${item.scope}/${item.entry.id}`;
|
|
109
|
+
if (item.entry.kind === 'files')
|
|
110
|
+
return `${name}: validated, not uploaded (files: the harness has no object-storage client; the app uploads them to ${String(item.entry.target)})`;
|
|
111
|
+
if (receipt && item.entry.mode === 'once')
|
|
112
|
+
return `${name}: kept (once, applied before)`;
|
|
113
|
+
if (receipt?.hash === item.hash && item.entry.kind === 'sql')
|
|
114
|
+
return `${name}: unchanged (upsert)`;
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/** The seed step of `env`: plans it, or with `execute` applies it. Returns { ok, lines, data: { applied, kept } }. */
|
|
118
|
+
export function seedStep(input) {
|
|
119
|
+
const planned = levelsOf(input.repoRoot, input.env).flatMap((level) => level.entries.map((entry) => ({
|
|
120
|
+
level, scope: level.env ?? SHARED_SCOPE, entry, hash: entryDigest(path.join(input.repoRoot, level.dir), entry),
|
|
121
|
+
})));
|
|
122
|
+
if (planned.length === 0)
|
|
123
|
+
return { ok: true, lines: ['seed: no entry in .starcistacks/seeds or the level of the environment'], data: { applied: [], entries: 0 } };
|
|
124
|
+
const receipts = receiptsOf(input);
|
|
125
|
+
const lines = [];
|
|
126
|
+
const applied = [];
|
|
127
|
+
if (input.execute)
|
|
128
|
+
psqlTransaction(input.ctx, input.target, [{ sql: RECEIPTS_DDL }], { code: 'STACK_SEED_RECEIPT' });
|
|
129
|
+
for (const item of planned) {
|
|
130
|
+
const receipt = receipts.find((row) => row.scope === item.scope && row.id === item.entry.id);
|
|
131
|
+
const kept = keptLine(item, receipt);
|
|
132
|
+
const name = `${item.scope}/${item.entry.id}`;
|
|
133
|
+
if (kept !== null) {
|
|
134
|
+
lines.push(kept);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!input.execute) {
|
|
138
|
+
const members = isDocumentEntry(item.entry) ? membersOf(input, item) : [];
|
|
139
|
+
if (isDocumentEntry(item.entry))
|
|
140
|
+
resolvedDocuments(input, item, (member) => sealedMember(input, member));
|
|
141
|
+
const unchanged = receipt?.hash === item.hash;
|
|
142
|
+
lines.push(`plan seed ${name}: ${unchanged ? 'apply when a referenced secret changed' : 'apply'} (${item.entry.kind}, ${item.entry.mode}${members.length ? `, secrets ${members.join(', ')}` : ''})`);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (item.entry.kind === 'sql') {
|
|
146
|
+
const files = entryFiles(path.join(input.repoRoot, item.level.dir), item.entry).map((file) => ({ file: path.join(input.repoRoot, item.level.dir, file) }));
|
|
147
|
+
psqlTransaction(input.ctx, input.target, [...files, { sql: receiptSql(item, input.env, '') }], { code: 'STACK_SEED_SQL' });
|
|
148
|
+
lines.push(`seed ${name}: applied (sql, ${item.entry.mode}, ${files.length} file${files.length === 1 ? '' : 's'})`);
|
|
149
|
+
applied.push(name);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const secrets = applyDocuments(input, item, receipt);
|
|
153
|
+
if (secrets === null) {
|
|
154
|
+
lines.push(`seed ${name}: unchanged (${item.entry.mode})`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
lines.push(`seed ${name}: applied (${item.entry.kind}, ${item.entry.mode}${secrets ? `, secrets ${secrets.split(',').map((part) => part.split(':')[0]).join(', ')}` : ''})`);
|
|
158
|
+
applied.push(name);
|
|
159
|
+
}
|
|
160
|
+
if (planned.length === 0)
|
|
161
|
+
lines.push('seed: no entry in .starcistacks/seeds or the environment\'s level');
|
|
162
|
+
return { ok: true, lines, data: { applied, entries: planned.length } };
|
|
163
|
+
}
|
|
164
|
+
/** The running container of a service of the stack on this daemon, or undefined. */
|
|
165
|
+
function serviceContainer(ctx, config, service) {
|
|
166
|
+
const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
|
|
167
|
+
return splitLines(found.stdout)[0];
|
|
168
|
+
}
|
|
169
|
+
/** True when either seed level of the environment declares an entry. */
|
|
170
|
+
export function hasSeedEntries(repoRoot, env) {
|
|
171
|
+
return readSeedLevel(repoRoot, null).entries.length > 0 || readSeedLevel(repoRoot, env).entries.length > 0;
|
|
172
|
+
}
|
|
173
|
+
/** What a deploy's dry run says of the seed step: every entry of the two levels, each applied unless its receipt holds. */
|
|
174
|
+
export function seedPlanLine(repoRoot, env) {
|
|
175
|
+
const entries = levelsOf(repoRoot, env).flatMap((level) => level.entries.map((entry) => `${level.env ?? SHARED_SCOPE}/${entry.id} (${entry.kind}, ${entry.mode})`));
|
|
176
|
+
return `seed after convergence, each entry unless its receipt in ${SEED_RECEIPTS} holds: ${entries.join(', ')}`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The seed step of a deploy (PROJECT-STACKS-6), after convergence so a document entry's app command is the new release's: in the database
|
|
180
|
+
* container under the credentials it holds (the role the migrations ran as), each app command inside a running task of the jobs service
|
|
181
|
+
* (`deploy.jobs.service`), the documents on its stdin.
|
|
182
|
+
*/
|
|
183
|
+
export async function seedDeployed(ctx, config, database, f) {
|
|
184
|
+
const container = await f.dbContainer(ctx, config, { service: database.service, seconds: DB_WAIT_SECONDS });
|
|
185
|
+
if (!container)
|
|
186
|
+
return { ok: false, lines: [`FAIL seed no running container of ${config.stack}_${database.service} within ${DB_WAIT_SECONDS}s`] };
|
|
187
|
+
const command = (argv, input) => {
|
|
188
|
+
const service = config.jobs?.service;
|
|
189
|
+
if (service === undefined)
|
|
190
|
+
throw refusal('STACK_SEED_NO_SERVICE', 'a data entry runs its app command in the jobs service; declare deploy.jobs of the environment');
|
|
191
|
+
const task = serviceContainer(ctx, config, service);
|
|
192
|
+
if (!task)
|
|
193
|
+
return { status: null, stdout: '', stderr: '', error: new Error(`no running container of ${config.stack}_${service}`) };
|
|
194
|
+
return tool(ctx, 'docker', ['exec', '-i', task, ...argv], { input });
|
|
195
|
+
};
|
|
196
|
+
return seedStep({ ctx, repoRoot: config.repoRoot, env: config.env, target: f.containerTarget(ctx, container), execute: true, command });
|
|
197
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The print width of the canon config. */
|
|
2
|
+
export declare const CANON_PRINT_WIDTH = 120;
|
|
3
|
+
/** One indent level of code (`tabWidth` of the canon config). */
|
|
4
|
+
export declare const CANON_INDENT = " ";
|
|
5
|
+
/** One indent level of JSON (the canon config's override for `*.json`). */
|
|
6
|
+
export declare const CANON_JSON_INDENT = " ";
|
|
7
|
+
/**
|
|
8
|
+
* `head` + an array literal of the items + `tail`, the way Prettier prints it: `head[a, b]tail` on one line while it fits the print width,
|
|
9
|
+
* else every item on its own line with a trailing comma.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} list - The text before the array, its items and the text after it.
|
|
12
|
+
* @returns {string} The lines, without a final newline.
|
|
13
|
+
*/
|
|
14
|
+
export declare const canonList: ({ head, items, tail }: {
|
|
15
|
+
head: string;
|
|
16
|
+
items: readonly string[];
|
|
17
|
+
tail?: string;
|
|
18
|
+
}) => string;
|
|
19
|
+
/**
|
|
20
|
+
* A named import, the way Prettier prints it: `import { a, b } from "x"` on one line while it fits, else one name a line; an import of one
|
|
21
|
+
* name is never broken.
|
|
22
|
+
*
|
|
23
|
+
* @param {object} declaration - The imported names, the module and whether it imports types only.
|
|
24
|
+
* @returns {string} The lines, without a final newline.
|
|
25
|
+
*/
|
|
26
|
+
export declare const canonImport: ({ names, from, type }: {
|
|
27
|
+
names: readonly string[];
|
|
28
|
+
from: string;
|
|
29
|
+
type?: boolean;
|
|
30
|
+
}) => string;
|
|
31
|
+
/**
|
|
32
|
+
* A JSON document as the canon prints a `.json` file (not a `package.json`, which Prettier prints the way `JSON.stringify` does): every
|
|
33
|
+
* object open one key a line, an array of scalars on one line while it fits, and a final newline.
|
|
34
|
+
*
|
|
35
|
+
* @param {unknown} value - The document.
|
|
36
|
+
* @returns {string} Its text.
|
|
37
|
+
*/
|
|
38
|
+
export declare const canonJson: (value: unknown) => string;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// canon-layout.mts - the layout of the canon Prettier config (@starci/prettier-config) that the code and JSON `starci app sync` renders by
|
|
2
|
+
// itself are written in, so a managed file passes the app's `prettier --check` (HFS_FORMAT, the pre-commit hook) as rendered and formatting it
|
|
3
|
+
// never makes HFS_SYNC_DRIFT. A rendered file is laid out the way Prettier lays it out: a list on one line while the line fits the print width,
|
|
4
|
+
// else one item a line with a trailing comma. src/sync-prettier.spec.mts holds these numbers to the canon config and every managed file to
|
|
5
|
+
// `prettier --check`.
|
|
6
|
+
/** The print width of the canon config. */
|
|
7
|
+
export const CANON_PRINT_WIDTH = 120;
|
|
8
|
+
/** One indent level of code (`tabWidth` of the canon config). */
|
|
9
|
+
export const CANON_INDENT = ' ';
|
|
10
|
+
/** One indent level of JSON (the canon config's override for `*.json`). */
|
|
11
|
+
export const CANON_JSON_INDENT = ' ';
|
|
12
|
+
/** `open` + the items + `close` on one line when it fits or `breaks` is false, else one item a line, one level deeper, with a trailing comma. */
|
|
13
|
+
function fitted(open, items, close, flat, breaks) {
|
|
14
|
+
if (!breaks || flat.length <= CANON_PRINT_WIDTH)
|
|
15
|
+
return flat;
|
|
16
|
+
return [open, ...items.map((item) => `${CANON_INDENT}${item},`), close].join('\n');
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* `head` + an array literal of the items + `tail`, the way Prettier prints it: `head[a, b]tail` on one line while it fits the print width,
|
|
20
|
+
* else every item on its own line with a trailing comma.
|
|
21
|
+
*
|
|
22
|
+
* @param {object} list - The text before the array, its items and the text after it.
|
|
23
|
+
* @returns {string} The lines, without a final newline.
|
|
24
|
+
*/
|
|
25
|
+
export const canonList = ({ head, items, tail = '' }) => fitted(`${head}[`, items, `]${tail}`, `${head}[${items.join(', ')}]${tail}`, items.length > 0);
|
|
26
|
+
/**
|
|
27
|
+
* A named import, the way Prettier prints it: `import { a, b } from "x"` on one line while it fits, else one name a line; an import of one
|
|
28
|
+
* name is never broken.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} declaration - The imported names, the module and whether it imports types only.
|
|
31
|
+
* @returns {string} The lines, without a final newline.
|
|
32
|
+
*/
|
|
33
|
+
export const canonImport = ({ names, from, type = false }) => {
|
|
34
|
+
const head = `import ${type ? 'type ' : ''}{`;
|
|
35
|
+
const tail = `} from ${JSON.stringify(from)}`;
|
|
36
|
+
return fitted(head, names, tail, `${head} ${names.join(', ')} ${tail}`, names.length > 1);
|
|
37
|
+
};
|
|
38
|
+
const isContainer = (value) => typeof value === 'object' && value !== null;
|
|
39
|
+
const isEmpty = (value) => (Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
|
|
40
|
+
/** One JSON value at `indent`, after `prefix` (`"key": `) and before `suffix` (`,` or nothing), as the canon prints a `.json` file. */
|
|
41
|
+
function jsonValue(value, indent, prefix, suffix) {
|
|
42
|
+
if (!isContainer(value) || isEmpty(value))
|
|
43
|
+
return `${indent}${prefix}${JSON.stringify(value)}${suffix}`;
|
|
44
|
+
const inner = `${indent}${CANON_JSON_INDENT}`;
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
// An array of scalars stays on one line while it fits; an array holding an object or a list breaks, since the object is kept open.
|
|
47
|
+
const flat = `${indent}${prefix}[${value.map((item) => JSON.stringify(item)).join(', ')}]${suffix}`;
|
|
48
|
+
if (value.every((item) => !isContainer(item) || isEmpty(item)) && flat.length <= CANON_PRINT_WIDTH)
|
|
49
|
+
return flat;
|
|
50
|
+
return [`${indent}${prefix}[`, ...value.map((item, index) => jsonValue(item, inner, '', index < value.length - 1 ? ',' : '')), `${indent}]${suffix}`].join('\n');
|
|
51
|
+
}
|
|
52
|
+
const entries = Object.entries(value);
|
|
53
|
+
return [`${indent}${prefix}{`, ...entries.map(([key, item], index) => jsonValue(item, inner, `${JSON.stringify(key)}: `, index < entries.length - 1 ? ',' : '')), `${indent}}${suffix}`].join('\n');
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A JSON document as the canon prints a `.json` file (not a `package.json`, which Prettier prints the way `JSON.stringify` does): every
|
|
57
|
+
* object open one key a line, an array of scalars on one line while it fits, and a final newline.
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} value - The document.
|
|
60
|
+
* @returns {string} Its text.
|
|
61
|
+
*/
|
|
62
|
+
export const canonJson = (value) => `${jsonValue(value, '', '', '')}\n`;
|
package/sync/ci-commands.d.mts
CHANGED
|
@@ -11,9 +11,10 @@ interface CiSource {
|
|
|
11
11
|
/** jest --shard=i/N gives each shard ceil(specs / N) spec files in order, so shard N is empty unless (N - 1) * ceil(specs / N) < specs. */
|
|
12
12
|
export declare function filledShards(specs: number, wanted: number): number;
|
|
13
13
|
/** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
|
|
14
|
-
export declare function ciWorkflowCommands(source: CiSource | undefined, lcovReport?: string, quality?: Declaration, { nodeMajor, app, packages }?: {
|
|
14
|
+
export declare function ciWorkflowCommands(source: CiSource | undefined, lcovReport?: string, quality?: Declaration, { nodeMajor, app, packages, rootPackages }?: {
|
|
15
15
|
nodeMajor?: string;
|
|
16
16
|
packages?: boolean;
|
|
17
|
+
rootPackages?: boolean;
|
|
17
18
|
app?: {
|
|
18
19
|
sides: {
|
|
19
20
|
fe: {
|