@starci/hfs 4.12.0 → 4.13.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 +43 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
package/src/main.mjs
CHANGED
|
@@ -8,27 +8,31 @@ 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] [--
|
|
11
|
+
// starci app check [--cwd <dir>] [--json] [--full] [--hfs] [--format] [--db] [--secrets] [--wiring] [--types] [--lint] [--test] [--sonar] [--coverage-from <lcov>] | 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
|
|
15
15
|
// directory, no untracked entry outside an ignored slot; plaintext secrets, the .starcistacks shape, CI
|
|
16
16
|
// and pre-push canon steps, dependency version skew, the contract snapshot and the test, wire and i18n
|
|
17
|
-
// trees; prettier over every tracked file through the repository's own install (sync/format.mjs;
|
|
18
|
-
//
|
|
17
|
+
// trees; prettier over every tracked file through the repository's own install (sync/format.mjs; a repository
|
|
18
|
+
// without prettier is a refusal); soft-size backlog (report only); then the whole
|
|
19
19
|
// architecture machine (tiers, owners, clones, dead exports, module registration, the
|
|
20
20
|
// front-end and back-end source rules), each violation a finding with its why.
|
|
21
21
|
// ESLint executes once per side through the installed managed config; the FE config composes
|
|
22
22
|
// the universal canon and the explicitly selected Grammar-family canon.
|
|
23
|
+
// The results are committed in .starcistacks/cache/ (cache/, contexts/project/check-cache.md): with no selector every
|
|
24
|
+
// family runs diff-only against it and writes what it judged back; --full runs the selected families in full; a
|
|
25
|
+
// selector (--hfs, --format, --db, --secrets, --wiring, --types, --lint, --test, --sonar) runs only that family, and
|
|
26
|
+
// selectors combine. Each family prints one diagnosis line (full or diff and why, checked, reused, findings new, known,
|
|
27
|
+
// fixed); progress goes to stderr (--quiet silences it). STARCI_APP_CHECK_CACHE=verify runs diff then full and fails (exit 2)
|
|
28
|
+
// on any difference; =full never reads the cache (the release gate).
|
|
29
|
+
// --coverage-from <lcov> judges a coverage report produced elsewhere (the merge of a sharded unit run) and records nothing.
|
|
23
30
|
// --staged: the pre-commit guard alone (sync/hygiene.mjs: staged .starciwork and .starcistacks paths, the secrets
|
|
24
31
|
// guard and the bypass guard over every staged file read from the index, a new back-end logic file staged
|
|
25
32
|
// without its unit spec); it takes no other flag.
|
|
26
|
-
//
|
|
27
|
-
// overrides it); the machine
|
|
28
|
-
// runs on those owners without clones and dead exports. No merge-base is a refusal
|
|
29
|
-
// (exit 2), never a silent full pass. Exit 1 at 20 error findings or a hard blocker (gate-threshold.mjs). The root checks run once and
|
|
33
|
+
// Exit 1 at 20 error findings or a hard blocker (gate-threshold.mjs). The root checks run once and
|
|
30
34
|
// the side checks and the machine once per side, the side folder as their root; every path is app-relative.
|
|
31
|
-
// starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--json] [--out <file>]
|
|
35
|
+
// starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--json] [--out <file>]
|
|
32
36
|
// the ONE lint entry (npm run lint): ESLint per side with that side's canon config (per-file rules and
|
|
33
37
|
// the project-graph rules), this check's findings, and stylelint over the fe side, as one starci/lint@1
|
|
34
38
|
// report; --out writes the one Sonar import file. Exit 0 under 20 error findings and no hard blocker, 1 otherwise, 2 a tool could not run (lint/run.mjs).
|
|
@@ -81,26 +85,33 @@ import { createRequire } from 'node:module';
|
|
|
81
85
|
import path from 'node:path';
|
|
82
86
|
import { pathToFileURL } from 'node:url';
|
|
83
87
|
import { isMain } from '../runtime/scripts/lib/is-main.mjs';
|
|
84
|
-
import {
|
|
88
|
+
import { explainPath, readWhy, trackedFiles, withCheckProofs } from '../runtime/scripts/hfs/check.mjs';
|
|
89
|
+
import { withWhy } from '../runtime/scripts/hfs/check-results.mjs';
|
|
90
|
+
import { skillRoot } from '../runtime/engine/runtime-root.mjs';
|
|
85
91
|
import { secretMain } from '../runtime/scripts/hfs/secret.mjs';
|
|
86
92
|
import { dbBaselineMain } from '../runtime/scripts/hfs/db-baseline-verb.mjs';
|
|
87
93
|
import { dbRestampMain } from '../runtime/scripts/hfs/db-restamp-verb.mjs';
|
|
88
94
|
import { formatCoverageReport } from '../runtime/scripts/hfs/coverage-from.mjs';
|
|
89
95
|
import { gateVerdict } from '../runtime/scripts/hfs/gate-threshold.mjs';
|
|
90
96
|
import { HFS_DECLARATION_FILE, HfsSlotsError, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
|
|
91
|
-
import { formatFindings } from '../sync/format.mjs';
|
|
92
97
|
import { main as syncMain } from '../sync/cli.mjs';
|
|
93
98
|
import { runWorkHygiene } from '../sync/hygiene.mjs';
|
|
94
99
|
import { SyncError, bundledPresets } from '../sync/index.mjs';
|
|
95
|
-
import { managedFindings } from '../sync/managed.mjs';
|
|
96
100
|
import { emitContracts } from '../emit/contracts.mjs';
|
|
97
101
|
import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
|
|
98
102
|
import { EditionRefusal } from '../scaffold/edition-gate.mjs';
|
|
99
103
|
import { addKind } from '../scaffold/add.mjs';
|
|
100
104
|
import { scaffoldApp } from '../scaffold/app.mjs';
|
|
101
105
|
import { newImages } from '../scaffold/image.mjs';
|
|
102
|
-
import {
|
|
103
|
-
import {
|
|
106
|
+
import { cachedLinters, eslintRepository, lintRepository, parseLintArgs, printLintText } from '../lint/run.mjs';
|
|
107
|
+
import { runSonarForApp, runTestForApp, runTestShard, runTypesForApp } from '../cache/app-families.mjs';
|
|
108
|
+
import { CACHE_ENV, createDiskSession } from '../cache/session.mjs';
|
|
109
|
+
import { DEFAULT_FAMILIES, runAppFamilies } from '../cache/run.mjs';
|
|
110
|
+
import { createProgress, silentProgress } from '../cache/progress.mjs';
|
|
111
|
+
import { familyLine } from '../cache/report.mjs';
|
|
112
|
+
import { writeRunLog } from '../cache/run-log.mjs';
|
|
113
|
+
import { FAMILIES } from '../cache/types.mjs';
|
|
114
|
+
import { SECRET_CODES as SECRET_CODE_LIST } from '../cache/hfs-family.mjs';
|
|
104
115
|
import { writeReport } from '../report/sonar.mjs';
|
|
105
116
|
import { checkEdition } from '../upgrade/check.mjs';
|
|
106
117
|
import { UpgradeError, upgradeMain } from '../upgrade/index.mjs';
|
|
@@ -134,6 +145,8 @@ const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRest
|
|
|
134
145
|
*/
|
|
135
146
|
/** The flags of the add nouns that generate a file tree from a pattern (service, spec and image take none of them, and --inject is only theirs). */
|
|
136
147
|
const ADD_FLAGS = { value: ['--event', '--from', '--service', '--connection', '--owner', '--failed', '--done', '--shapes', '--read', '--app', '--kind', '--blocks', '--tabs'], boolean: ['--fe', '--form'] };
|
|
148
|
+
/** The family selectors of `app check`, one flag per family of the cache (contexts/project/check-cache.md). */
|
|
149
|
+
const FAMILY_FLAGS = FAMILIES.map((family) => `--${family}`);
|
|
137
150
|
const PARSED = {
|
|
138
151
|
scaffold: { run: scaffoldMain, value: ['--registry-visibility'], edition: true, forms: [{ operands: '<name>' }] },
|
|
139
152
|
add: {
|
|
@@ -145,7 +158,7 @@ const PARSED = {
|
|
|
145
158
|
{ operands: 'image', flags: [] },
|
|
146
159
|
],
|
|
147
160
|
},
|
|
148
|
-
check: { run: checkMain, value: ['--
|
|
161
|
+
check: { run: checkMain, value: ['--coverage-from', '--shard'], boolean: ['--json', '--staged', '--full', ...FAMILY_FLAGS], edition: true, forms: [{ only: { boolean: ['--json', '--full', ...FAMILY_FLAGS], value: ['--coverage-from', '--shard'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
|
|
149
162
|
slot: { run: slotMain, boolean: ['--json'], forms: [{ operands: '<path>' }] },
|
|
150
163
|
emit: { run: emitMain, forms: [{}] },
|
|
151
164
|
};
|
|
@@ -155,7 +168,7 @@ const ADD_FLAGS_ALL = flagsOf(ADD_FLAGS);
|
|
|
155
168
|
const PARSED_FLAGS = new Set([...Object.values(PARSED).flatMap(flagsOf), '--edition']);
|
|
156
169
|
/** The placeholder of a value flag in the usage; any other prints as <name>. */
|
|
157
170
|
const PLACEHOLDERS = {
|
|
158
|
-
'--registry-visibility': 'public|private', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--inject': '<binding>',
|
|
171
|
+
'--registry-visibility': 'public|private', '--edition': 'full|lite', '--base': '<ref>', '--coverage-from': '<lcov>', '--shard': '<i>/<n>', '--inject': '<binding>',
|
|
159
172
|
'--event': '<event>', '--from': '<service>', '--service': '<Class>=<module>', '--owner': '<service>', '--failed': '<event>', '--done': '<event>',
|
|
160
173
|
'--shapes': '<a,b,c>', '--blocks': '<a,b>', '--tabs': '<a,b>', '--kind': 'modal|drawer', '--image': '<service>=<ref>', '--sha': '<sha>',
|
|
161
174
|
'--previous': '<release>', '--release': '<release>', '--out': '<path>', '--since': '<ISO>', '--timeout': '<seconds>', '--retry': '<seconds>',
|
|
@@ -178,7 +191,7 @@ const DATABASE_USAGE = [['new', '<kebab-name>', DB_NEW_FLAGS], ['baseline', '',
|
|
|
178
191
|
/** The usage of each verb, from the tables its parser refuses every other flag with (lint, codemod and upgrade keep hand-parsed arguments, held by main.spec.mjs). */
|
|
179
192
|
const VERB_USAGE = {
|
|
180
193
|
...Object.fromEntries(Object.entries(PARSED).map(([verb, spec]) => [verb, spec.forms.map((form) => line(`starci app ${verb}`, form.operands, specUsage(form.only ?? (form.flags === undefined ? spec : { value: form.flags })), spec.edition && form.edition !== false ? flagUsage('--edition', { value: true }) : ''))])),
|
|
181
|
-
lint: ['starci app lint [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--json] [--out <file>]'],
|
|
194
|
+
lint: ['starci app lint [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--full] [--json] [--out <file>]'],
|
|
182
195
|
codemod: ['starci app codemod button-intent [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]'],
|
|
183
196
|
upgrade: ['starci app upgrade [--execute]'],
|
|
184
197
|
sync: [SYNC_USAGE.sync],
|
|
@@ -244,22 +257,31 @@ function contractDescription(contracts) {
|
|
|
244
257
|
return `emitted and compared, ${apps}`;
|
|
245
258
|
}
|
|
246
259
|
function printCheckOverview(result, out) {
|
|
247
|
-
out(`starci app check
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
260
|
+
out(`starci app check ${result.repoRoot} (profile ${result.profile ?? 'unknown'}, manifest ${result.manifest}, ${result.tracked} tracked paths)
|
|
261
|
+
`);
|
|
262
|
+
for (const report of (result.families ?? []))
|
|
263
|
+
out(` ${familyLine(report)}
|
|
264
|
+
`);
|
|
265
|
+
for (const warning of (result.cacheWarnings ?? []))
|
|
266
|
+
out(` cache: ${warning}
|
|
267
|
+
`);
|
|
268
|
+
out(` architecture machine: ${machineDescription(result.machine)}
|
|
269
|
+
`);
|
|
251
270
|
if (result.contracts)
|
|
252
|
-
out(` contract snapshots: ${contractDescription(result.contracts)}
|
|
271
|
+
out(` contract snapshots: ${contractDescription(result.contracts)}
|
|
272
|
+
`);
|
|
253
273
|
if (result.eslint) {
|
|
254
274
|
const sides = Object.entries(result.eslint.sides).map(([side, run]) => `${side}: ${run.files} files${run.skipped ? ` (${run.skipped})` : ''}`).join(', ');
|
|
255
|
-
out(` ESLint: ${sides}
|
|
275
|
+
out(` ESLint: ${sides}
|
|
276
|
+
`);
|
|
256
277
|
for (const error of result.eslint.errors)
|
|
257
|
-
out(` ESLint tool error: ${error}
|
|
278
|
+
out(` ESLint tool error: ${error}
|
|
279
|
+
`);
|
|
258
280
|
}
|
|
259
281
|
}
|
|
260
282
|
function printFindingGroup(code, list, out) {
|
|
261
283
|
const first = list[0];
|
|
262
|
-
if (first.engine
|
|
284
|
+
if (first.engine)
|
|
263
285
|
out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}\n`);
|
|
264
286
|
else
|
|
265
287
|
out(`\n[${first.level.toUpperCase()}] ${code} x${list.length}: ${first.titleVi}\n ${first.whyVi}\n -> ${first.nextStepVi}\n`);
|
|
@@ -307,36 +329,37 @@ function printExplain(e, out) {
|
|
|
307
329
|
if (e.code)
|
|
308
330
|
out(` ${e.code}: ${e.titleVi}\n ${e.whyVi}\n`);
|
|
309
331
|
}
|
|
332
|
+
/** The family selectors given, as families; none given selects `fallback`. */
|
|
333
|
+
function selectedFamilies(opts, fallback) {
|
|
334
|
+
const given = FAMILIES.filter((family) => opts[family] === true);
|
|
335
|
+
return new Set(given.length > 0 ? given : fallback);
|
|
336
|
+
}
|
|
337
|
+
/** Opens the session of a verb over the tracked files of the app: progress on stderr, `--quiet` silent, a terminal rewriting running lines in place. */
|
|
338
|
+
function openSession({ repoRoot, tracked, io, full, use, planned }) {
|
|
339
|
+
const progress = io.quiet === true ? silentProgress : createProgress({ write: (text) => io.stderr(text), tty: io.stderrIsTty === true, planned });
|
|
340
|
+
const env = use === 'bypass' ? { ...io.env, [CACHE_ENV]: 'full' } : use === 'read' ? { ...io.env, [CACHE_ENV]: io.env[CACHE_ENV] === 'full' ? 'full' : 'verify' } : io.env;
|
|
341
|
+
return createDiskSession({ root: repoRoot, tracked, env, progress, cliVersion: io.harness?.version, full });
|
|
342
|
+
}
|
|
310
343
|
/**
|
|
311
|
-
* The app pass
|
|
312
|
-
* `only` limits the formatter to those (app-relative) files.
|
|
344
|
+
* The app pass of `starci app check` and `starci app lint`: the families the verb selects, against one cache session, merged into one result.
|
|
345
|
+
* `only` limits the formatter to those (app-relative) files and bypasses the cache (`app lint --changed`).
|
|
313
346
|
*/
|
|
314
|
-
async function
|
|
347
|
+
async function appPass({ repoRoot, harness, only, presets, prettier, io, selected, full, use = 'normal', session: given }) {
|
|
315
348
|
const tracked = trackedFiles(repoRoot);
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const files = tracked.filter((file) => file.startsWith('be/')).map((file) => file.slice('be/'.length));
|
|
331
|
-
const emitted = contractEmitFindings({ repoRoot: path.join(repoRoot, 'be'), files, repo: be, emit: emitContracts });
|
|
332
|
-
extraFindings.push(...emitted.findings.map((finding) => ({ ...finding, side: 'be', path: `be/${finding.path}` })));
|
|
333
|
-
contracts = { status: 'checked', apps: emitted.apps };
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
const result = checkRepository({ repoRoot, harness, fast, base, extraFindings });
|
|
337
|
-
if (contracts)
|
|
338
|
-
result.contracts = contracts;
|
|
339
|
-
return result;
|
|
349
|
+
const session = given ?? openSession({ repoRoot, tracked, io, full, use: only === undefined ? use : 'bypass', planned: tracked.length * Math.max(1, selected.size) });
|
|
350
|
+
const result = await runAppFamilies({ repoRoot, tracked, session, selected, harness, presets, prettier, only, edition: undefined });
|
|
351
|
+
if (given !== undefined)
|
|
352
|
+
return result;
|
|
353
|
+
const flushed = session.flush();
|
|
354
|
+
return { ...result, findings: [...result.findings, ...verdictFindings(session)], cacheWarnings: flushed.warnings };
|
|
355
|
+
}
|
|
356
|
+
/** The verdicts of the CI comparison (forged, stale, missing) as findings with their why. */
|
|
357
|
+
function verdictFindings(session) {
|
|
358
|
+
const verdicts = session.verdicts();
|
|
359
|
+
if (verdicts.length === 0)
|
|
360
|
+
return [];
|
|
361
|
+
const why = readWhy(skillRoot, [...new Set(verdicts.map((verdict) => verdict.code))]);
|
|
362
|
+
return withWhy(verdicts.map((verdict) => ({ code: verdict.code, level: verdict.level, path: verdict.path, message: verdict.message })), why);
|
|
340
363
|
}
|
|
341
364
|
/** The nearest folder at or above `dir` that holds hfs.json (the app root of a workspace), or `dir` itself when none does (the lint then refuses it). */
|
|
342
365
|
export function appRootAbove(dir) {
|
|
@@ -348,7 +371,8 @@ export function appRootAbove(dir) {
|
|
|
348
371
|
}
|
|
349
372
|
}
|
|
350
373
|
/** `starci app lint`: ESLint per side, the app checks and stylelint over the fe side as one report; see lint/run.mjs. */
|
|
351
|
-
async function lintMain(argv,
|
|
374
|
+
async function lintMain(argv, io) {
|
|
375
|
+
const { cwd, harness, stdout, presets, prettier, runLinter, checkApp = appPass } = io;
|
|
352
376
|
const opts = parseLintArgs(argv);
|
|
353
377
|
// --workspace names a folder from the working directory (`starci app lint --workspace .` is an fe workspace script); the app
|
|
354
378
|
// root is then the nearest folder above it that holds hfs.json, and the workspace is passed on app-relative.
|
|
@@ -356,10 +380,17 @@ async function lintMain(argv, { cwd, harness, stdout, presets, prettier, runLint
|
|
|
356
380
|
const repoRoot = path.resolve(workspaceDir ? appRootAbove(workspaceDir) : cwd);
|
|
357
381
|
if (workspaceDir)
|
|
358
382
|
opts.workspace = path.relative(repoRoot, workspaceDir).split(path.sep).join('/');
|
|
383
|
+
// The lint shares the cache of `app check`: --workspace reads it and writes none, --changed and --fix neither read nor write, --full judges again.
|
|
384
|
+
const use = opts.changed !== null || opts.fix ? 'bypass' : opts.workspace !== undefined ? 'read' : 'normal';
|
|
385
|
+
const selected = new Set(['hfs', 'secrets', 'db', 'format']);
|
|
386
|
+
// One session for the app findings and the linters, so the two never hold two answers; checkApp is a spec seam that brings its own.
|
|
387
|
+
const session = io.checkApp === undefined ? openSession({ repoRoot, tracked: trackedFiles(repoRoot), io, full: opts.full === true ? new Set([...selected, 'lint']) : undefined, use, planned: 1 }) : undefined;
|
|
359
388
|
const { report, sonar, exit } = await lintRepository({
|
|
360
|
-
repoRoot, opts, trackedFiles, runLinter,
|
|
361
|
-
hfsCheck: (root) => checkApp({ repoRoot: root, harness, only: opts.changed ?? undefined, presets, prettier }),
|
|
389
|
+
repoRoot, opts, trackedFiles, runLinter, ...(session === undefined ? {} : { cache: { session } }),
|
|
390
|
+
hfsCheck: (root) => checkApp({ repoRoot: root, harness, only: opts.changed ?? undefined, presets, prettier, io, selected, use, session }),
|
|
362
391
|
});
|
|
392
|
+
const flushed = session?.flush();
|
|
393
|
+
const verdicts = session === undefined ? [] : verdictFindings(session);
|
|
363
394
|
if (opts.out !== undefined)
|
|
364
395
|
writeReport(path.resolve(cwd, opts.out), sonar);
|
|
365
396
|
if (opts.format === 'json')
|
|
@@ -367,7 +398,11 @@ async function lintMain(argv, { cwd, harness, stdout, presets, prettier, runLint
|
|
|
367
398
|
`);
|
|
368
399
|
else
|
|
369
400
|
printLintText(report, stdout);
|
|
370
|
-
|
|
401
|
+
for (const verdict of verdicts)
|
|
402
|
+
stdout(`${verdict.level.toUpperCase()} ${verdict.code}: ${verdict.message}\n`);
|
|
403
|
+
for (const warning of flushed?.warnings ?? [])
|
|
404
|
+
stdout(`cache: ${warning}\n`);
|
|
405
|
+
return Math.max(exit, verdicts.some((verdict) => verdict.level === 'error') ? 1 : 0);
|
|
371
406
|
}
|
|
372
407
|
/**
|
|
373
408
|
* The jest preset a new app renders its Sonar exclusions from: the copy this @starci/hfs ships (bundledPresets), the same version the
|
|
@@ -448,8 +483,80 @@ function printCheckProofs(result, out) {
|
|
|
448
483
|
out(formatCoverageReport(result.coverage));
|
|
449
484
|
}
|
|
450
485
|
/** The flags `check --staged` refuses: it is the pre-commit guard alone, so none of the whole-app pass's options means anything with it. */
|
|
451
|
-
const STAGED_REFUSES = ['--json', '--
|
|
452
|
-
|
|
486
|
+
const STAGED_REFUSES = ['--json', '--full', '--coverage-from', '--shard', ...FAMILY_FLAGS];
|
|
487
|
+
/** The family a finding belongs to, for naming it when a verified run disagrees with the cache. */
|
|
488
|
+
function familyOf(finding) {
|
|
489
|
+
const code = finding.code ?? '';
|
|
490
|
+
if (finding.engine === 'eslint' || finding.engine === 'stylelint')
|
|
491
|
+
return 'lint';
|
|
492
|
+
if (code === 'HFS_FORMAT')
|
|
493
|
+
return 'format';
|
|
494
|
+
if (/^(DB_|LITE_DB_)/.test(code))
|
|
495
|
+
return 'db';
|
|
496
|
+
if (code === 'BE_APP_BOOT_PROOF_MISSING' || code === 'BOOT_PROOF_TIMEOUT')
|
|
497
|
+
return 'wiring';
|
|
498
|
+
return SECRET_CODE_LIST.has(code) ? 'secrets' : 'hfs';
|
|
499
|
+
}
|
|
500
|
+
/** One run of the selected families against the cache, then the coverage proof on top; returns the result and what it took. */
|
|
501
|
+
async function checkRun({ io, selected, full, use, coverageFrom }) {
|
|
502
|
+
const { cwd: repoRoot, harness, edition, presets, prettier, checkApp = appPass, runLinter } = io;
|
|
503
|
+
const started = Date.now();
|
|
504
|
+
const tracked = io.checkApp === undefined ? trackedFiles(repoRoot) : [];
|
|
505
|
+
const session = io.checkApp === undefined ? openSession({ repoRoot, tracked, io, full, use, planned: tracked.length * Math.max(1, selected.size) }) : undefined;
|
|
506
|
+
const checked = await checkEdition({ repoRoot, edition, normal: () => checkApp({ repoRoot, harness, presets, prettier, io, selected, full, use, session }), presets, prettier });
|
|
507
|
+
const proof = withCheckProofs(checked, { repoRoot, edition, wiring: false, coverageFrom });
|
|
508
|
+
const extra = [];
|
|
509
|
+
const lines = [];
|
|
510
|
+
let eslint = null;
|
|
511
|
+
if (selected.has('lint')) {
|
|
512
|
+
if (session !== undefined) {
|
|
513
|
+
const app = readRepoDeclaration(loadSlotManifest(), repoRoot);
|
|
514
|
+
eslint = (await cachedLinters({ repoRoot, app, opts: { changed: null, fix: false, format: 'text', out: undefined, workspace: undefined }, workspace: null, onFe: null, run: runLinter, cache: { session } })).eslint;
|
|
515
|
+
}
|
|
516
|
+
else
|
|
517
|
+
eslint = await eslintRepository({ repoRoot, changed: null, existing: [], run: runLinter });
|
|
518
|
+
}
|
|
519
|
+
if (session !== undefined && selected.has('types'))
|
|
520
|
+
extra.push(...runTypesForApp(session, { repoRoot, tracked, env: io.env }).findings);
|
|
521
|
+
if (session !== undefined && selected.has('test')) {
|
|
522
|
+
const tests = await runTestForApp(session, { repoRoot, tracked });
|
|
523
|
+
extra.push(...tests.findings);
|
|
524
|
+
}
|
|
525
|
+
if (session !== undefined && selected.has('sonar'))
|
|
526
|
+
lines.push(...(runSonarForApp(session, { repoRoot })?.lines ?? []));
|
|
527
|
+
const flushed = session?.flush();
|
|
528
|
+
const verdicts = session === undefined ? [] : verdictFindings(session);
|
|
529
|
+
const reports = session === undefined ? [...(checked.families ?? [])] : [...session.reports];
|
|
530
|
+
const findings = [...proof.findings, ...extra, ...verdicts, ...(eslint?.findings ?? []).map(finding => ({ ...finding, code: finding.code ?? finding.rule, level: 'error' }))];
|
|
531
|
+
// Green under the threshold (gate-threshold.mjs): every finding is still listed, only the verdict changes.
|
|
532
|
+
const verdict = gateVerdict(findings.filter(finding => finding.level === 'error'));
|
|
533
|
+
const toolErrors = eslint?.errors ?? [];
|
|
534
|
+
const counts = { ...proof.counts, error: findings.filter(finding => finding.level === 'error').length, warning: findings.filter(finding => finding.level === 'warning').length };
|
|
535
|
+
const result = { ...proof, findings, families: reports, predictions: lines, cacheWarnings: [...(checked.cacheWarnings ?? []), ...(flushed?.warnings ?? [])], counts, ...(eslint ? { eslint: { ...eslint.engine, errors: eslint.errors } } : {}), ok: verdict.pass && toolErrors.length === 0, gate: verdict };
|
|
536
|
+
return { result, seconds: (Date.now() - started) / 1000 };
|
|
537
|
+
}
|
|
538
|
+
/** STARCI_APP_CHECK_CACHE=verify: the diff-only run, then the full run, compared finding by finding; any difference names family and file (exit 2). */
|
|
539
|
+
async function verifyCache(io, selected, coverageFrom) {
|
|
540
|
+
const diff = await checkRun({ io, selected, full: undefined, use: 'read', coverageFrom });
|
|
541
|
+
const full = await checkRun({ io, selected, full: new Set(selected), use: 'bypass', coverageFrom });
|
|
542
|
+
const keyed = (result) => new Map(result.findings.map((finding) => [`${finding.code}|${finding.path ?? ''}|${finding.message}`, `${familyOf(finding)}: ${finding.path ?? finding.code}`]));
|
|
543
|
+
const left = keyed(diff.result);
|
|
544
|
+
const right = keyed(full.result);
|
|
545
|
+
const differences = [...left].filter(([key]) => !right.has(key)).map(([, name]) => `diff-only only: ${name}`)
|
|
546
|
+
.concat([...right].filter(([key]) => !left.has(key)).map(([, name]) => `full only: ${name}`)).sort();
|
|
547
|
+
io.stdout(`${CACHE_ENV}=verify: diff-only ${diff.seconds.toFixed(1)}s, full ${full.seconds.toFixed(1)}s, ${right.size} finding(s) compared\n`);
|
|
548
|
+
for (const report of diff.result.families)
|
|
549
|
+
io.stdout(` diff-only ${familyLine(report)}\n`);
|
|
550
|
+
if (differences.length === 0) {
|
|
551
|
+
io.stdout(' identical\n');
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
for (const difference of differences)
|
|
555
|
+
io.stdout(` DIFFERENCE ${difference}\n`);
|
|
556
|
+
return 2;
|
|
557
|
+
}
|
|
558
|
+
async function checkMain(opts, io) {
|
|
559
|
+
const { cwd: repoRoot, edition, stdout: out } = io;
|
|
453
560
|
if (opts.positional.length)
|
|
454
561
|
throw new Error('starci app check takes no path');
|
|
455
562
|
if (opts.staged === true) {
|
|
@@ -458,26 +565,33 @@ async function checkMain(opts, { cwd: repoRoot, harness, edition, stdout: out, p
|
|
|
458
565
|
throw new Error(`starci app check --staged is the pre-commit guard alone and takes no ${given.join(', ')}`);
|
|
459
566
|
return runWorkHygiene({ cwd: repoRoot, out: (text) => out(`${text}\n`) });
|
|
460
567
|
}
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const
|
|
471
|
-
|
|
568
|
+
const coverageFrom = valueFlag(opts, 'coverage-from');
|
|
569
|
+
const shard = valueFlag(opts, 'shard');
|
|
570
|
+
if (shard !== undefined) {
|
|
571
|
+
// The sharded full unit run of CI (--test --full --shard <i>/<n>): the shard's Jest run in the shape the shard merge reads; nothing is recorded.
|
|
572
|
+
const match = /^(\d+)\/(\d+)$/.exec(shard);
|
|
573
|
+
if (match === null || opts.test !== true || opts.full !== true)
|
|
574
|
+
throw new Error('--shard <i>/<n> belongs to `starci app check --test --full`');
|
|
575
|
+
return runTestShard(repoRoot, Number(match[1]), Number(match[2]));
|
|
576
|
+
}
|
|
577
|
+
const explicit = selectedFamilies(opts, []);
|
|
578
|
+
// A coverage report judged on its own (the merge job of a sharded run) runs no family; with selectors it comes on top of them.
|
|
579
|
+
const selected = explicit.size > 0 ? explicit : coverageFrom === undefined ? new Set(DEFAULT_FAMILIES) : new Set();
|
|
580
|
+
const full = opts.full === true ? new Set(selected) : undefined;
|
|
581
|
+
if (io.env[CACHE_ENV] === 'verify')
|
|
582
|
+
return verifyCache(io, selected, coverageFrom);
|
|
583
|
+
const { result, seconds } = await checkRun({ io, selected, full, use: coverageFrom !== undefined && explicit.size === 0 ? 'bypass' : 'normal', coverageFrom });
|
|
472
584
|
if (opts.json)
|
|
473
585
|
out(`${JSON.stringify(result, null, 2)}\n`);
|
|
474
586
|
else {
|
|
475
587
|
printCheck(result, out);
|
|
476
588
|
printCheckProofs(result, out);
|
|
477
|
-
if (
|
|
478
|
-
out(`${
|
|
589
|
+
if (result.gate.line)
|
|
590
|
+
out(`${result.gate.line}\n`);
|
|
479
591
|
}
|
|
480
|
-
|
|
592
|
+
const exit = result.eslint?.errors.length ? 2 : result.ok ? 0 : 1;
|
|
593
|
+
writeRunLog(repoRoot, { command: `check${[...selected].map((family) => ` --${family}`).join('')}${opts.full === true ? ' --full' : ''}`, seconds, exit, families: result.families.map((report) => ({ ...report, seconds: 0 })) }, io.env);
|
|
594
|
+
return exit;
|
|
481
595
|
}
|
|
482
596
|
function emitMain(opts, { cwd: repoRoot, stdout: out }) {
|
|
483
597
|
if (opts.positional.length)
|
|
@@ -607,11 +721,12 @@ function errorMessage(error) {
|
|
|
607
721
|
* the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
|
|
608
722
|
* harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
|
|
609
723
|
*/
|
|
610
|
-
export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain, env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, } = {}) {
|
|
724
|
+
export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain, env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, stderrIsTty = process.stderr.isTTY === true, } = {}) {
|
|
611
725
|
try {
|
|
612
726
|
const global = globalOptions(argv, path.resolve(baseCwd));
|
|
613
727
|
const [verb, ...given] = global.argv;
|
|
614
|
-
|
|
728
|
+
// --quiet silences stdout for every verb but check and lint, whose diagnosis it keeps (their progress on stderr is what it mutes).
|
|
729
|
+
const out = global.quiet && !['check', 'lint'].includes(verb) ? () => { } : stdout;
|
|
615
730
|
if (global.help) {
|
|
616
731
|
out(USAGE);
|
|
617
732
|
return 0;
|
|
@@ -621,7 +736,7 @@ export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout
|
|
|
621
736
|
return 2;
|
|
622
737
|
}
|
|
623
738
|
const { edition, argv: rest } = editionOption(verb, given);
|
|
624
|
-
return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp });
|
|
739
|
+
return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition, quiet: global.quiet, stderrIsTty, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp });
|
|
625
740
|
}
|
|
626
741
|
catch (error) {
|
|
627
742
|
stderr(errorMessage(error));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReleaseStore, StoredRelease } from '../stack/release-store.mjs';
|
|
2
|
+
/** The records half of a ReleaseStore, plus what it kept. */
|
|
3
|
+
export interface RecordsDouble extends Pick<ReleaseStore, 'read' | 'tryLock' | 'refreshLock' | 'unlock' | 'journal'> {
|
|
4
|
+
/** The latest document of each attempt, as the journal wrote it (a copy at each write). */
|
|
5
|
+
documents: Map<string, Record<string, unknown>>;
|
|
6
|
+
/** Every write of every document, in order: the phase boundaries a cancelled job would have left. */
|
|
7
|
+
writes: Record<string, unknown>[];
|
|
8
|
+
/** The append-only index. */
|
|
9
|
+
index: Record<string, unknown>[];
|
|
10
|
+
/** The holder token and trigger of the lock, null when it is free. */
|
|
11
|
+
lock: {
|
|
12
|
+
token: string;
|
|
13
|
+
trigger: string;
|
|
14
|
+
} | null;
|
|
15
|
+
/** Lock calls in order: `try:<trigger>`, `refresh`, `unlock`. */
|
|
16
|
+
calls: string[];
|
|
17
|
+
}
|
|
18
|
+
/** What a double can be told: the release `read` answers, a lock held by someone else, and journal writes that fail. */
|
|
19
|
+
export interface RecordsOptions {
|
|
20
|
+
read?: (id: string) => StoredRelease | Promise<StoredRelease>;
|
|
21
|
+
/** The lock is held by another deploy for this many seconds: `tryLock` answers busy until `freeAfterTries` tries have passed. */
|
|
22
|
+
busy?: {
|
|
23
|
+
holder: string;
|
|
24
|
+
ageSeconds: number;
|
|
25
|
+
freeAfterTries?: number;
|
|
26
|
+
} | undefined;
|
|
27
|
+
failJournal?: boolean | undefined;
|
|
28
|
+
/** The journal accepts this many document writes and fails every later one (a manager that drops mid-deploy). */
|
|
29
|
+
failJournalAfter?: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
export declare function recordsDouble(options?: RecordsOptions): RecordsDouble;
|
package/stack/cli.d.mts
CHANGED
|
@@ -14,6 +14,7 @@ export interface Flags {
|
|
|
14
14
|
push?: boolean;
|
|
15
15
|
urgent?: boolean;
|
|
16
16
|
'first-start'?: boolean;
|
|
17
|
+
redeploy?: boolean;
|
|
17
18
|
image?: string[];
|
|
18
19
|
keep?: string[];
|
|
19
20
|
'extra-compose'?: string[];
|
|
@@ -33,8 +34,9 @@ export interface Flags {
|
|
|
33
34
|
'db-service'?: string;
|
|
34
35
|
reason?: string;
|
|
35
36
|
remote?: string;
|
|
37
|
+
'ci-run'?: string;
|
|
36
38
|
}
|
|
37
|
-
export declare const ACTIONS: readonly ["prepare", "connect", "disconnect", "build-args", "render", "deploy", "converge", "verify", "rollback", "prune", "backup", "jobs", "task", "ingress", "retire", "build", "accept"];
|
|
39
|
+
export declare const ACTIONS: readonly ["prepare", "connect", "disconnect", "build-args", "render", "deploy", "converge", "verify", "rollback", "prune", "backup", "jobs", "task", "ingress", "retire", "build", "accept", "status"];
|
|
38
40
|
/** One action of `starci app stack`. */
|
|
39
41
|
export type Action = (typeof ACTIONS)[number];
|
|
40
42
|
/** True when `value` names one action of `starci app stack`. */
|
package/stack/cli.mjs
CHANGED
|
@@ -9,7 +9,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
9
9
|
// cli.mjs - `starci app stack <action>` for the Swarm deploy tooling: flags, dry run versus --execute, dispatch, text and --json output.
|
|
10
10
|
// starci app stack <action> --env <env> [the action's FLAGS] (only the READ_ONLY actions default --env to production)
|
|
11
11
|
// Each action takes only its own flags: a flag of another action is a refusal, never ignored.
|
|
12
|
-
// (
|
|
12
|
+
// (`starci app world <up|down|status>` is the test world's, dispatched by packages/hfs/src/main.mjs; `stack status` below is the deploy's drift report.)
|
|
13
13
|
// Every action module exports one async function (ctx, config, options) -> { ok, lines, data }; a refusal throws StackError.
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import { stackContext } from './context.mjs';
|
|
@@ -19,12 +19,12 @@ import { StackError, EXIT, refusal } from './errors.mjs';
|
|
|
19
19
|
import { parseTargetBinding, bindStackTarget, nativeToolEnvironment } from './target-binding.mjs';
|
|
20
20
|
import { oneCommand, prepareUrgentRelease } from './urgent-release.mjs';
|
|
21
21
|
/** The actions that drive the docker CLI of the operator machine (pointed at the manager by docker-transport.mjs when the key is sealed). */
|
|
22
|
-
const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune']);
|
|
23
|
-
export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept']);
|
|
22
|
+
const DOCKER_ACTIONS = new Set(['deploy', 'accept', 'converge', 'verify', 'rollback', 'prune', 'status']);
|
|
23
|
+
export const ACTIONS = Object.freeze(['prepare', 'connect', 'disconnect', 'build-args', 'render', 'deploy', 'converge', 'verify', 'rollback', 'prune', 'backup', 'jobs', 'task', 'ingress', 'retire', 'build', 'accept', 'status']);
|
|
24
24
|
/** True when `value` names one action of `starci app stack`. */
|
|
25
25
|
export const isAction = (value) => ACTIONS.includes(value);
|
|
26
|
-
export const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start']);
|
|
27
|
-
export const VALUE = new Set(['--env', '--image', '--sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service', '--reason', '--remote']);
|
|
26
|
+
export const BOOLEAN = new Set(['--execute', '--json', '--swarm-state', '--init-swarm', '--owner-approved', '--fix-live-restore', '--skip-public', '--skip-internal', '--allow-unpinned', '--push', '--urgent', '--first-start', '--redeploy']);
|
|
27
|
+
export const VALUE = new Set(['--env', '--image', '--sha', '--previous', '--out', '--release', '--since', '--timeout', '--retry', '--keep', '--app', '--ssh-cmd', '--extra-compose', '--project', '--target-binding', '--db-url-env', '--backup-out', '--db-service', '--reason', '--remote', '--ci-run']);
|
|
28
28
|
export const REPEAT = new Set(['--image', '--keep', '--extra-compose', '--project']);
|
|
29
29
|
/** `--ssh-cmd` replaces the configured alias for an action whose module (or the Docker transport) reaches the manager over SSH. */
|
|
30
30
|
const SSH = ['--ssh-cmd'];
|
|
@@ -38,7 +38,7 @@ export const FLAGS = Object.freeze({
|
|
|
38
38
|
disconnect: ['--execute'],
|
|
39
39
|
'build-args': ['--app'],
|
|
40
40
|
render: ['--image', '--sha', '--previous', '--out', '--allow-unpinned', '--extra-compose'],
|
|
41
|
-
deploy: ['--image', '--sha', '--execute', '--out', '--timeout', '--retry', '--skip-public', '--target-binding', '--urgent', '--reason', '--remote', '--db-url-env', '--backup-out', '--first-start', '--db-service', ...SSH],
|
|
41
|
+
deploy: ['--image', '--sha', '--execute', '--out', '--timeout', '--retry', '--skip-public', '--target-binding', '--urgent', '--reason', '--remote', '--db-url-env', '--backup-out', '--first-start', '--db-service', '--ci-run', '--redeploy', ...SSH],
|
|
42
42
|
converge: ['--since', '--timeout', ...SSH],
|
|
43
43
|
verify: ['--retry', '--skip-public', '--skip-internal', '--target-binding', ...SSH],
|
|
44
44
|
rollback: ['--release', '--execute', '--timeout', ...SSH],
|
|
@@ -49,12 +49,13 @@ export const FLAGS = Object.freeze({
|
|
|
49
49
|
ingress: ['--execute', ...SSH],
|
|
50
50
|
retire: ['--project', '--execute', ...SSH],
|
|
51
51
|
build: ['--push', '--execute'],
|
|
52
|
-
accept: ['--release', '--execute', '--retry', ...SSH],
|
|
52
|
+
accept: ['--release', '--execute', '--retry', '--db-url-env', ...SSH],
|
|
53
|
+
status: [...SSH],
|
|
53
54
|
});
|
|
54
55
|
/** The operands of the actions that take any, as the usage prints them. */
|
|
55
56
|
export const OPERANDS = Object.freeze({ jobs: '<action> [<arg>...]', task: '<name> [<arg>...]', ingress: '<action> [<arg>...]' });
|
|
56
57
|
/** The read-only actions, whose `--env` defaults to production. Every action with an effect names its environment, so none reaches production by omission. */
|
|
57
|
-
export const READ_ONLY = Object.freeze(['build-args', 'render', 'verify']);
|
|
58
|
+
export const READ_ONLY = Object.freeze(['build-args', 'render', 'verify', 'status']);
|
|
58
59
|
/** Splits argv into { flags, positional }; an unknown flag, a flag of another action or a flag without its value is a refusal. */
|
|
59
60
|
function parseFlags(action, argv) {
|
|
60
61
|
const own = new Set(['--env', '--json', ...FLAGS[action]]);
|
|
@@ -91,8 +92,10 @@ function checkDeployForm(flags) {
|
|
|
91
92
|
}
|
|
92
93
|
if (!flags.urgent && (flags['first-start'] || flags['db-service']))
|
|
93
94
|
throw refusal('STACK_USAGE', '--first-start and --db-service are the first start of an empty database under the urgent floor: add --urgent');
|
|
94
|
-
if (
|
|
95
|
-
throw refusal('STACK_USAGE', '--
|
|
95
|
+
if (flags.urgent && flags['ci-run'])
|
|
96
|
+
throw refusal('STACK_USAGE', '--ci-run names the CI run that deploys; an urgent deploy runs no CI (the CI run of its tag is the back-fill)');
|
|
97
|
+
if (flags['ci-run'] !== undefined && !/^[0-9]{1,20}$/u.test(flags['ci-run']))
|
|
98
|
+
throw refusal('STACK_USAGE', '--ci-run takes the numeric id of the CI run');
|
|
96
99
|
}
|
|
97
100
|
/** `--image api=ghcr.io/o/x@sha256:...` (repeatable or comma separated) -> { api: 'ghcr.io/o/x@sha256:...' }. */
|
|
98
101
|
function parseImages(values = []) {
|
|
@@ -130,6 +133,7 @@ const OPTIONS = {
|
|
|
130
133
|
out: flags.out ? path.resolve(cwd, flags.out) : null, timeoutSeconds: number(flags, 'timeout'), retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true,
|
|
131
134
|
urgent: flags.urgent === true, dbUrlEnv: flags['db-url-env'], backupOut: flags['backup-out'] ? path.resolve(cwd, flags['backup-out']) : undefined,
|
|
132
135
|
firstStart: flags['first-start'] === true, dbService: flags['db-service'], reason: flags.reason, remote: flags.remote,
|
|
136
|
+
ciRun: flags['ci-run'], redeploy: flags.redeploy === true,
|
|
133
137
|
}),
|
|
134
138
|
converge: ({ flags }) => ({ since: flags.since, timeoutSeconds: number(flags, 'timeout') }),
|
|
135
139
|
verify: ({ flags }) => ({ retrySeconds: number(flags, 'retry'), skipPublic: flags['skip-public'] === true, skipInternal: flags['skip-internal'] === true }),
|
|
@@ -138,7 +142,8 @@ const OPTIONS = {
|
|
|
138
142
|
backup: ({ flags }) => ({ swarmState: flags['swarm-state'] === true, execute: flags.execute === true }),
|
|
139
143
|
ingress: ({ flags, positional }) => ({ action: positional[0], execute: flags.execute === true, args: positional.slice(1) }),
|
|
140
144
|
build: ({ flags }) => ({ push: flags.push === true, execute: flags.execute === true }),
|
|
141
|
-
accept: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, retrySeconds: number(flags, 'retry') }),
|
|
145
|
+
accept: ({ flags }) => ({ release: flags.release ?? null, execute: flags.execute === true, retrySeconds: number(flags, 'retry'), dbUrlEnv: flags['db-url-env'] }),
|
|
146
|
+
status: () => ({}),
|
|
142
147
|
retire: ({ flags }) => ({ projects: flags.project ?? [], execute: flags.execute === true }),
|
|
143
148
|
jobs: ({ flags, positional }) => ({ action: positional[0], args: positional.slice(1), execute: flags.execute === true }),
|
|
144
149
|
task: ({ flags, positional }) => ({ name: positional[0], args: positional.slice(1), execute: flags.execute === true }),
|
|
@@ -154,6 +159,7 @@ const MODULES = {
|
|
|
154
159
|
retire: ['./retire.mjs', 'retireCompose'],
|
|
155
160
|
build: ['./build.mjs', 'buildImages'],
|
|
156
161
|
accept: ['./deploy.mjs', 'acceptRelease'],
|
|
162
|
+
status: ['./status.mjs', 'statusStack'],
|
|
157
163
|
};
|
|
158
164
|
async function handlerOf(action, importer) {
|
|
159
165
|
const [file, name] = MODULES[action];
|
package/stack/config.d.mts
CHANGED
|
@@ -158,6 +158,12 @@ export interface DeployBlock {
|
|
|
158
158
|
environment: Record<string, ServiceEnvironment>;
|
|
159
159
|
configs: ConfigEntry[];
|
|
160
160
|
passEnv: string[];
|
|
161
|
+
/** deploy.auto: a green release tag deploys here by itself (the CI workflow reads it; the verbs do not). */
|
|
162
|
+
auto: boolean;
|
|
163
|
+
/** deploy.approval: none, or reviewers set on the GitHub Environment. */
|
|
164
|
+
approval: 'none' | 'reviewers';
|
|
165
|
+
/** deploy.platforms: the platforms the images are built for (default linux/amd64). */
|
|
166
|
+
platforms: string[];
|
|
161
167
|
verify: {
|
|
162
168
|
http: HttpCheck[];
|
|
163
169
|
exec: ExecCheck[];
|
package/stack/converge.d.mts
CHANGED
|
@@ -2,6 +2,8 @@ import type { DeployConfig } from './config.mjs';
|
|
|
2
2
|
import type { StackContext } from './context.mjs';
|
|
3
3
|
import type { ActionResult } from './remote.mjs';
|
|
4
4
|
type Ctx = Pick<StackContext, 'spawn' | 'now' | 'sleep'>;
|
|
5
|
+
/** The JSON text with object keys sorted at every depth, so two equal specs compare equal whatever the key order. */
|
|
6
|
+
export declare function canonical(value: unknown): string;
|
|
5
7
|
/**
|
|
6
8
|
* Waits until the stack converged. `since` is the ISO instant the deploy began (required). Returns { ok, lines, data: { services, failed, pending } };
|
|
7
9
|
* on failure the lines name the service and the Swarm task error text.
|
package/stack/converge.mjs
CHANGED
|
@@ -13,7 +13,7 @@ const TAB = String.fromCodePoint(9);
|
|
|
13
13
|
const TASK_FORMAT = `{{.DesiredState}}|{{.Status.State}}|{{.Status.Timestamp}}|{{.Status.Err}}${TAB}{{json .Spec}}`;
|
|
14
14
|
const docker = (ctx, args) => toolOutput(ctx, 'docker', args);
|
|
15
15
|
/** The JSON text with object keys sorted at every depth, so two equal specs compare equal whatever the key order. */
|
|
16
|
-
function canonical(value) {
|
|
16
|
+
export function canonical(value) {
|
|
17
17
|
if (Array.isArray(value))
|
|
18
18
|
return `[${value.map(canonical).join(',')}]`;
|
|
19
19
|
if (value !== null && typeof value === 'object')
|