@starci/hfs 4.12.2 → 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.
Files changed (152) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +74 -7
  73. package/runtime/knowledge/hfs/slots.yaml +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. 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] [--fast] [--base <ref>] [--wiring] [--coverage-from <lcov>] | check --staged
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; not under
18
- // --fast, and a repository without prettier is a refusal); soft-size backlog (report only); then the whole
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
- // --fast: only what changed since the merge-base with origin/main (else main; --base
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 { changedSince, checkDatabase, checkRepository, explainPath, trackedFiles, withCheckProofs } from '../runtime/scripts/hfs/check.mjs';
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 { contractEmitFindings } from '../runtime/scripts/hfs/rules/contract.mjs';
103
- import { eslintRepository, lintRepository, parseLintArgs, printLintText } from '../lint/run.mjs';
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: ['--base', '--coverage-from'], boolean: ['--json', '--fast', '--wiring', '--staged'], edition: true, forms: [{ only: { boolean: ['--json', '--fast', '--wiring'], value: ['--base', '--coverage-from'] } }, { only: { boolean: ['--staged'] }, edition: false }] },
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${result.fast ? ' --fast' : ''} ${result.repoRoot} (profile ${result.profile ?? 'unknown'}, manifest ${result.manifest}, ${result.tracked} tracked paths)\n`);
248
- if (result.fast)
249
- out(` ${result.fast.changed} path${result.fast.changed === 1 ? '' : 's'} changed since ${result.fast.base.slice(0, 12)}\n`);
250
- out(` architecture machine: ${machineDescription(result.machine)}\n`);
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)}\n`);
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}\n`);
275
+ out(` ESLint: ${sides}
276
+ `);
256
277
  for (const error of result.eslint.errors)
257
- out(` ESLint tool error: ${error}\n`);
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 === 'eslint')
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: slots, managed files, formatter, contract snapshots and the architecture machine's check surface, root and sides.
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 runCheck({ repoRoot, harness, fast = false, base, only, presets, prettier }) {
347
+ async function appPass({ repoRoot, harness, only, presets, prettier, io, selected, full, use = 'normal', session: given }) {
315
348
  const tracked = trackedFiles(repoRoot);
316
- // Managed files of the root and both sides, the .gitignore block and sonar against their render (R04, R05, R11, ...), and prettier
317
- // through the app's own install (R19, never under --fast).
318
- const extraFindings = [...await managedFindings({ repoRoot, tracked, presets }), ...await checkDatabase({ repoRoot, files: tracked, base }), ...(fast ? [] : await formatFindings({ repoRoot, files: only ?? tracked, prettier }))];
319
- // R23 against the be side itself (full pass only): committed snapshots equal what `starci app emit` writes now.
320
- let contracts = null;
321
- let be = null;
322
- try {
323
- be = readRepoDeclaration(loadSlotManifest(), repoRoot).sides?.be ?? null;
324
- }
325
- catch { /* checkRepository reports the unreadable declaration */ }
326
- if (be) {
327
- if (fast)
328
- contracts = { status: 'skipped', reason: '--fast does not emit the apps' };
329
- else {
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, { cwd, harness, stdout, presets, prettier, runLinter, checkApp = runCheck }) {
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
- return exit;
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', '--fast', '--base', '--wiring', '--coverage-from'];
452
- async function checkMain(opts, { cwd: repoRoot, harness, edition, stdout: out, presets, prettier, checkApp = runCheck, runLinter }) {
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
- if (opts.base !== undefined && opts.fast !== true)
462
- throw new Error('--base names the ref --fast compares with; it needs --fast');
463
- const checked = await checkEdition({ repoRoot, edition, normal: () => checkApp({ repoRoot, harness, fast: opts.fast === true, base: opts.base, presets, prettier }), presets, prettier });
464
- const proof = withCheckProofs(checked, { repoRoot, edition, wiring: opts.wiring === true, coverageFrom: valueFlag(opts, 'coverage-from') });
465
- const changed = opts.fast === true ? changedSince(repoRoot, proof.fast?.base ?? valueFlag(opts, 'base')).files : null;
466
- const existing = changed ? changed.filter((file) => fs.existsSync(path.join(repoRoot, file))) : [];
467
- const eslint = await eslintRepository({ repoRoot, changed, existing, run: runLinter });
468
- const findings = [...proof.findings, ...eslint.findings.map(finding => ({ ...finding, code: finding.code ?? finding.rule, level: 'error' }))];
469
- // Green under the threshold (gate-threshold.mjs): every finding is still listed, only the verdict changes.
470
- const verdict = gateVerdict(findings.filter(finding => finding.level === 'error'));
471
- const result = { ...proof, findings, counts: { ...proof.counts, error: proof.counts.error + eslint.findings.length }, eslint: { ...eslint.engine, errors: eslint.errors }, ok: verdict.pass && eslint.errors.length === 0, gate: verdict };
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 (verdict.line)
478
- out(`${verdict.line}\n`);
589
+ if (result.gate.line)
590
+ out(`${result.gate.line}\n`);
479
591
  }
480
- return eslint.errors.length ? 2 : result.ok ? 0 : 1;
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
- const out = global.quiet ? () => { } : stdout;
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
- // (up, down and status are the test-world verbs and are dispatched by packages/hfs/src/main.mjs.)
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 (!flags.urgent && (flags['db-url-env'] || flags['backup-out']))
95
- throw refusal('STACK_USAGE', '--db-url-env and --backup-out choose the urgent floor\'s database and backup folder: add --urgent');
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];
@@ -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[];
@@ -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.
@@ -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')