@starci/hfs 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. package/upgrade/index.mjs +1 -1
@@ -5,7 +5,6 @@
5
5
  // the remote shell. The record holds names and digests, never a secret value. Files travel as text on stdin / stdout.
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
- import { eachInOrder, mapInOrder } from '../runtime/scripts/lib/in-order.mjs';
9
8
  import { firstLine, lines as splitLines } from './context.mjs';
10
9
  import { isReleaseId } from './digest.mjs';
11
10
  import { failure, refusal } from './errors.mjs';
@@ -56,9 +55,8 @@ export function createReleaseStore(config, ssh) {
56
55
  async store(dir, id) {
57
56
  const target = dirOf(id);
58
57
  const contents = FILES.map((name) => [name, readLocal(dir, name)]);
59
- await run(`set -e; d="${target}"; if [ -d "$d" ]; then chmod -R u+w "$d"; else mkdir -p "$d"; fi`);
60
- await eachInOrder(contents, ([name, text]) => run(`set -e; d="${target}"; cat > "$d/${name}"`, text));
61
- await run(`set -e; d="${target}"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`);
58
+ const input = contents.map(([, text]) => Buffer.from(text, 'utf8').toString('base64')).join('\n') + '\n';
59
+ await run(`set -e; d="${target}"; if [ -d "$d" ]; then chmod -R u+w "$d"; else mkdir -p "$d"; fi; IFS= read -r stack; IFS= read -r release; printf '%s' "$stack" | base64 -d > "$d/stack.yaml"; printf '%s' "$release" | base64 -d > "$d/release.json"; chmod 440 "$d/stack.yaml" "$d/release.json"; chmod 550 "$d"`, input);
62
60
  },
63
61
  /** The release was verified: previous := current, current := id. */
64
62
  async accept(id) {
@@ -72,15 +70,22 @@ export function createReleaseStore(config, ssh) {
72
70
  /** Downloads a stored release (both files) into `targetDir` for a rollback or an audit. */
73
71
  async fetch(id, targetDir) {
74
72
  const source = dirOf(id);
75
- const files = await mapInOrder(FILES, async (name) => [name, await run(`cat "${source}/${name}"`)]);
73
+ const out = await run(`set -e; for f in stack.yaml release.json; do test -r "${source}/$f"; base64 < "${source}/$f" | tr -d '\\n'; printf '\\n'; done`);
74
+ const frames = out.split('\n');
75
+ if (frames.length !== 3 || frames[2] !== '' || frames.slice(0, 2).some((frame) => !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(frame))) throw failure('STACK_RELEASE_SSH', 'invalid release response framing');
76
+ const files = FILES.map((name, index) => [name, Buffer.from(frames[index], 'base64').toString('utf8')]);
76
77
  fs.mkdirSync(targetDir, { recursive: true });
77
78
  for (const [name, text] of files) fs.writeFileSync(path.join(targetDir, name), text);
78
79
  return targetDir;
79
80
  },
80
81
  /** The stored releases, newest first: [{ id, current, previous }]. */
81
82
  async list() {
82
- const [now, before] = [await pointer('current'), await pointer('previous')];
83
- const out = await run(`cd "${base}/releases" 2>/dev/null && ls -1t || true`);
83
+ const pointerScript = (name) => `if [ -L "${base}/${name}" ]; then basename "$(readlink "${base}/${name}")"; else printf '\\n'; fi`;
84
+ const response = await run(`${pointerScript('current')}; ${pointerScript('previous')}; cd "${base}/releases" 2>/dev/null && ls -1t || true`);
85
+ const [current, previous, ...entries] = response.split('\n');
86
+ const now = isReleaseId(current) ? current : '';
87
+ const before = isReleaseId(previous) ? previous : '';
88
+ const out = entries.join('\n');
84
89
  return splitLines(out).filter(isReleaseId).map((id) => ({ id, current: id === now, previous: id === before }));
85
90
  },
86
91
  };
package/stack/remote.mjs CHANGED
@@ -13,6 +13,7 @@ import path from 'node:path';
13
13
  import { tool } from './context.mjs';
14
14
  import { restrictToUser, writePrivate } from './private-file.mjs';
15
15
  import { refusal } from './errors.mjs';
16
+ import { createSshSession, sessionDirectory } from './ssh-session.mjs';
16
17
 
17
18
  const ACCOUNT = /^[A-Za-z0-9._@-]+$/u;
18
19
  const CI_VARIABLES = ['VPS_SSH_KEY', 'VPS_KNOWN_HOSTS', 'VPS_HOST', 'VPS_USER'];
@@ -33,11 +34,11 @@ export const remoteDocker = (run, args) => run(['docker', ...args.map(shellQuote
33
34
 
34
35
  const noop = () => {};
35
36
 
36
- function makeRunner(ctx, argv, { cleanup = noop, notes = [] } = {}) {
37
+ function makeRunner(ctx, argv, { cleanup = noop, notes = [], connection } = {}) {
37
38
  const [command, ...head] = argv;
38
39
  const run = (remote, { input } = {}) => {
39
40
  const options = input === undefined ? {} : { input: lfOnly(input) };
40
- const result = tool(ctx, command, [...head, lfOnly(remote)], options);
41
+ const result = connection ? connection.remote(lfOnly(remote), options) : tool(ctx, command, [...head, lfOnly(remote)], options);
41
42
  if (result.error) return { status: 127, stdout: '', stderr: `cannot run ${command}: ${result.error.message}` };
42
43
  return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
43
44
  };
@@ -119,21 +120,28 @@ export function openSealedSession(ctx, config) {
119
120
  function sealedRunner(ctx, config) {
120
121
  const session = openSealedSession(ctx, config);
121
122
  const argv = ['ssh', '-T', '-i', session.keyFile, '-o', 'IdentitiesOnly=yes', '-o', `UserKnownHostsFile=${session.knownFile}`, '-o', `StrictHostKeyChecking=${session.strict}`, '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', session.account];
122
- return makeRunner(ctx, argv, { cleanup: session.cleanup, notes: session.notes });
123
+ return sessionRunner(ctx, argv, session.dir, session.cleanup, session.notes);
124
+ }
125
+
126
+ function sessionRunner(ctx, argv, dir, cleanup, notes = []) {
127
+ const connection = createSshSession(ctx, { argv, controlPath: path.join(dir, 'cm').replaceAll('\\', '/'), cleanup });
128
+ return makeRunner(ctx, argv, { connection, cleanup: connection.close, notes });
123
129
  }
124
130
 
125
131
  /** The ssh runner of one invocation (see the top of the file). */
126
- export function sshRunner(ctx, config, { sshCmd = null } = {}) {
132
+ function sshRunner(ctx, config, { sshCmd = null } = {}) {
133
+ if (ctx.stackSsh && !sshCmd) return makeRunner(ctx, ['ssh'], { connection: ctx.stackSsh });
127
134
  if (sshCmd) return makeRunner(ctx, sshCommandArgv(sshCmd));
128
135
  if (config.remote.sealed && ctx.env.STARCI_STACK_SEALED_SSH === '1') return sealedRunner(ctx, config);
129
- return makeRunner(ctx, ['ssh', ...SSH_OPTIONS, config.remote.host]);
136
+ const dir = sessionDirectory();
137
+ return sessionRunner(ctx, ['ssh', ...SSH_OPTIONS, config.remote.host], dir, () => fs.rmSync(dir, { recursive: true, force: true }));
130
138
  }
131
139
 
132
140
  /**
133
141
  * The context to open the runner with. On an operator machine (not CI, no --ssh-cmd, the sealed members exist, no explicit
134
142
  * STARCI_STACK_SEALED_SSH) the sealed session is selected; otherwise the context is returned unchanged.
135
143
  */
136
- export function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
144
+ function withOwnerTransport(ctx, config, { sshCmd = null } = {}) {
137
145
  const { sealed } = config.remote;
138
146
  if (sshCmd || !sealed || ctx.env.GITHUB_ACTIONS === 'true' || ctx.env.STARCI_STACK_SEALED_SSH !== undefined) return ctx;
139
147
  const present = [sealed.ops, sealed.sshKey, sealed.knownHosts].every((slug) => fs.existsSync(path.join(config.secretsDir, `${slug}.enc`)));
@@ -150,7 +158,7 @@ export async function withRunner(ctx, config, options, deps, body) {
150
158
  try {
151
159
  return await body(run, run.notes ?? []);
152
160
  } finally {
153
- run.cleanup?.();
161
+ await run.cleanup?.();
154
162
  }
155
163
  }
156
164
 
@@ -174,7 +182,7 @@ function ciInputs(ctx) {
174
182
  const sshBlock = (alias, inputs) => [
175
183
  '', `Host ${alias}`, ` HostName ${inputs.host}`, ` User ${inputs.user}`, ' IdentityFile ~/.ssh/deploy_key',
176
184
  ' StrictHostKeyChecking yes', ' BatchMode yes', ' ConnectTimeout 20', ' ServerAliveInterval 30',
177
- ' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist 15m', '',
185
+ ' ControlMaster auto', ' ControlPath ~/.ssh/cm-%C', ' ControlPersist 60', '',
178
186
  ].join('\n');
179
187
 
180
188
  /** CI transport: pinned key, known_hosts, ssh config block and DOCKER_HOST=ssh://<alias> in $GITHUB_ENV. */
package/stack/sealed.mjs CHANGED
@@ -19,16 +19,16 @@ const CR = String.fromCodePoint(13);
19
19
  const LF = String.fromCodePoint(10);
20
20
 
21
21
  /** True for content that is not text: a NUL byte in the first 8 KiB. Binary files are never rewritten. */
22
- export const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
22
+ const isBinary = (bytes) => bytes.subarray(0, 8192).includes(0);
23
23
 
24
24
  /** Every CRLF of a string becomes LF. */
25
- export const lfText = (text) => text.replaceAll(CR + LF, LF);
25
+ const lfText = (text) => text.replaceAll(CR + LF, LF);
26
26
 
27
27
  /**
28
28
  * Line endings of a text file as the services need them: every CRLF becomes LF (a script rendered from a Windows checkout failed with
29
29
  * `set: Illegal option -`). Binary content is returned untouched. Takes and returns a Buffer.
30
30
  */
31
- export function lfBytes(bytes) {
31
+ function lfBytes(bytes) {
32
32
  if (isBinary(bytes) || !bytes.includes(13)) return bytes;
33
33
  return Buffer.from(lfText(bytes.toString('latin1')), 'latin1');
34
34
  }
@@ -37,14 +37,14 @@ export function lfBytes(bytes) {
37
37
  export const configBytes = (file) => lfBytes(fs.readFileSync(file));
38
38
 
39
39
  /** The key that makes the version suffix of a secret name unguessable from the name alone: a local fixture key or the age identity. */
40
- export function versionKeyOf(ctx) {
40
+ function versionKeyOf(ctx) {
41
41
  const key = ctx.env.STARCI_SECRET_VERSION_KEY || ctx.env.SOPS_AGE_KEY;
42
42
  if (!key) throw refusal('STACK_VERSION_KEY_MISSING', 'STARCI_SECRET_VERSION_KEY (a local fixture) or SOPS_AGE_KEY is needed to version the secret names');
43
43
  return key;
44
44
  }
45
45
 
46
46
  /** `<stack>-<key>-v<10 hex of HMAC(versionKey, key NUL value)>`: a changed value is a new name, an unchanged one keeps its name. */
47
- export function secretRuntimeName(stack, key, value, versionKey) {
47
+ function secretRuntimeName(stack, key, value, versionKey) {
48
48
  const version = createHmac('sha256', versionKey).update(`${key}\0${value}`).digest('hex').slice(0, 10);
49
49
  const name = `${stack}-${key.toLowerCase().replaceAll('_', '-')}-v${version}`;
50
50
  if (name.length > MAX_NAME) throw refusal('STACK_SECRET_NAME_TOO_LONG', `the Swarm secret name for ${key} would exceed ${MAX_NAME} characters`);
@@ -0,0 +1,178 @@
1
+ // ssh-session.mjs - one command-owned connection; all processes use ctx.spawn. Windows prefers a Unix socket in private 0700 HOME
2
+ // when runtime ssh -V reports OpenSSH >= 6.7 and a local AF_UNIX Docker CLI probe succeeds. Otherwise a fresh ephemeral TCP
3
+ // port is asserted to bind only 127.0.0.1 and never reaches files or logs. Finally and SIGINT/SIGTERM close the tunnel.
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { failure } from './errors.mjs';
8
+ import { restrictToUser } from './private-file.mjs';
9
+
10
+ const FREE_PORT = "const net=require('node:net');const s=net.createServer();s.on('error',()=>process.exit(1));s.listen(0,'127.0.0.1',()=>{console.log(s.address().port);s.close();});";
11
+ const WAIT_PORT = "const net=require('node:net');const deadline=Date.now()+20000;function probe(){const s=net.connect({host:'127.0.0.1',port:Number(process.argv[1])});s.setTimeout(200);s.on('connect',()=>{s.destroy();process.exit(0);});function retry(){s.destroy();if(Date.now()>=deadline)process.exit(1);else setTimeout(probe,50);}s.on('error',retry);s.on('timeout',retry);}probe();";
12
+
13
+ const WAIT_SOCKET = WAIT_PORT.replace("{host:'127.0.0.1',port:Number(process.argv[1])}", '{path:process.argv[1]}');
14
+ const UNIX_PROBE = String.raw`
15
+ const http = require('node:http');
16
+ const fs = require('node:fs');
17
+ const { spawn } = require('node:child_process');
18
+ const socket = process.argv[1];
19
+ let child;
20
+ function finish(code) {
21
+ child?.kill();
22
+ server.close();
23
+ try { fs.unlinkSync(socket); } catch {}
24
+ process.exit(code);
25
+ }
26
+ const server = http.createServer((req, res) => {
27
+ res.setHeader('Api-Version', '1.47');
28
+ res.setHeader('Content-Type', 'application/json');
29
+ res.end(req.url.endsWith('/_ping') ? 'OK' : JSON.stringify({ Version: '27.0.0', ApiVersion: '1.47', MinAPIVersion: '1.24', Os: 'linux', Arch: 'amd64' }));
30
+ });
31
+ server.on('error', () => finish(1));
32
+ server.listen(socket, () => {
33
+ child = spawn('docker', ['--host', 'unix://' + socket.replaceAll('\\', '/'), 'version', '--format', '{{.Server.Version}}'], { stdio: 'ignore', windowsHide: true });
34
+ child.on('error', () => finish(1));
35
+ child.on('exit', code => finish(code === 0 ? 0 : 1));
36
+ });
37
+ setTimeout(() => finish(1), 5000);
38
+ `;
39
+
40
+ /** TCP Docker forwarding must never expose the manager on a non-loopback interface. */
41
+ function loopbackForward(bind, port, socket) {
42
+ if (bind !== '127.0.0.1') throw failure('STACK_SSH_SESSION', 'the Docker tunnel must bind 127.0.0.1');
43
+ return `${bind}:${port}:${socket}`;
44
+ }
45
+
46
+ function unixEndpoint(ctx, command, env) {
47
+ const version = ctx.spawn(command, ['-V'], { env });
48
+ const match = /OpenSSH(?:_for_Windows)?_(\d+)\.(\d+)/u.exec(`${version.stderr ?? ''} ${version.stdout ?? ''}`);
49
+ if (!successful(version) || !match || Number(match[1]) < 6 || Number(match[1]) === 6 && Number(match[2]) < 7) return null;
50
+ if (!env.HOME) return null;
51
+ fs.mkdirSync(env.HOME, { recursive: true, mode: 0o700 });
52
+ fs.chmodSync(env.HOME, 0o700);
53
+ restrictToUser(ctx, env.HOME, '(OI)(CI)F');
54
+ const probe = path.join(env.HOME, 'probe.sock');
55
+ try {
56
+ if (!successful(ctx.spawn(process.execPath, ['-e', UNIX_PROBE, probe], { env, timeout: 7000 }))) return null;
57
+ } finally {
58
+ fs.rmSync(probe, { force: true });
59
+ }
60
+ return path.join(env.HOME, 'docker.sock').replaceAll('\\', '/');
61
+ }
62
+
63
+ /** The same multiplexing settings belong to the master, remote commands and Docker's SSH alias. */
64
+ export function controlSettings(controlPath) {
65
+ return ['ControlMaster=auto', `ControlPath=${controlPath}`, 'ControlPersist=60'];
66
+ }
67
+
68
+ function successful(result) {
69
+ return !result.error && result.status === 0;
70
+ }
71
+
72
+ function freePort(ctx) {
73
+ const result = ctx.spawn(process.execPath, ['-e', FREE_PORT]);
74
+ const port = Number(result.stdout?.trim());
75
+ if (!successful(result) || !Number.isInteger(port) || port < 1 || port > 65535) throw failure('STACK_SSH_SESSION', 'cannot allocate a loopback port');
76
+ return port;
77
+ }
78
+
79
+ /** Waits for the tunnel child to exit before any private credentials are removed. */
80
+ function stopTunnel(child) {
81
+ if (!child || child.spawnError || child.exitCode !== null && child.exitCode !== undefined || child.signalCode) return;
82
+ if (!child.once) { child.kill(); return; }
83
+ return new Promise((resolve) => {
84
+ const timer = setTimeout(() => child.kill('SIGKILL'), 2000);
85
+ child.once('close', () => { clearTimeout(timer); resolve(); });
86
+ child.kill();
87
+ });
88
+ }
89
+
90
+ async function closeTunnel(child, cleanup) {
91
+ try { await stopTunnel(child); } finally { cleanup(); }
92
+ }
93
+
94
+ /** Creates a lazy connection; no SSH is spawned until a Docker query or remote command needs it. */
95
+ export function createSshSession(ctx, { argv, env = ctx.env, controlPath, socket = '/var/run/docker.sock', cleanup = () => {} }) {
96
+ const windows = (ctx.platform ?? process.platform) === 'win32';
97
+ const [command, ...head] = argv;
98
+ const controls = windows ? [] : controlSettings(controlPath).flatMap((setting) => ['-o', setting]);
99
+ let started = false;
100
+ let ready = false;
101
+ let child;
102
+ let host;
103
+ let closing;
104
+ const signalHandlers = new Map();
105
+ let closed = false;
106
+
107
+ function start() {
108
+ if (closed) throw failure('STACK_SSH_SESSION', 'the command session is already closed');
109
+ if (ready) return;
110
+ if (started) throw failure('STACK_SSH_SESSION', 'the SSH connection failed to open');
111
+ started = true;
112
+ if (windows) {
113
+ const localSocket = unixEndpoint(ctx, command, env);
114
+ const port = localSocket ? null : freePort(ctx);
115
+ const forward = localSocket ? `./docker.sock:${socket}` : loopbackForward('127.0.0.1', port, socket);
116
+ host = localSocket ? `unix://${localSocket}` : `tcp://127.0.0.1:${port}`;
117
+ child = ctx.spawn(command, ['-N', '-o', 'ExitOnForwardFailure=yes', '-o', 'GatewayPorts=no', ...(localSocket ? ['-o', 'StreamLocalBindMask=0177'] : []), '-L', forward, ...head], { env, cwd: localSocket ? env.HOME : undefined, background: true });
118
+ if (typeof child?.kill !== 'function') {
119
+ child = undefined;
120
+ throw failure('STACK_SSH_SESSION', 'cannot start the Docker SSH tunnel');
121
+ }
122
+ installSignals();
123
+ const probeArgs = localSocket ? ['-e', WAIT_SOCKET, localSocket] : ['-e', WAIT_PORT, String(port)];
124
+ const probe = ctx.spawn(process.execPath, probeArgs, { env, timeout: 25000 });
125
+ if (!successful(probe)) throw failure('STACK_SSH_SESSION', 'the Docker SSH tunnel did not become ready');
126
+ } else {
127
+ const result = ctx.spawn(command, ['-MNf', ...controls, ...head], { env });
128
+ if (!successful(result)) throw failure('STACK_SSH_SESSION', 'cannot open the SSH control master');
129
+ }
130
+ ready = true;
131
+ }
132
+
133
+ function remote(remoteCommand, options = {}) {
134
+ // Windows cannot multiplex exec channels; Docker queries share the tunnel and remote scripts remain one call per step.
135
+ if (!windows) start();
136
+ return ctx.spawn(command, [...controls, ...head, remoteCommand], { ...options, env });
137
+ }
138
+
139
+ function dockerHost(alias) {
140
+ start();
141
+ return windows ? host : `ssh://${alias}`;
142
+ }
143
+
144
+ function installSignals() {
145
+ for (const [signal, code] of [['SIGINT', 130], ['SIGTERM', 143]]) {
146
+ const handler = () => { Promise.resolve(close()).finally(() => process.exit(code)); };
147
+ signalHandlers.set(signal, handler);
148
+ process.once(signal, handler);
149
+ }
150
+ }
151
+
152
+ function finish() {
153
+ for (const [signal, handler] of signalHandlers) process.removeListener(signal, handler);
154
+ signalHandlers.clear();
155
+ cleanup();
156
+ }
157
+
158
+ function close() {
159
+ if (closed) return closing;
160
+ closed = true;
161
+ if (windows && child) {
162
+ closing = closeTunnel(child, finish);
163
+ return closing;
164
+ }
165
+ try {
166
+ if (!windows && started) ctx.spawn(command, ['-O', 'exit', ...controls, ...head], { env });
167
+ } finally {
168
+ finish();
169
+ }
170
+ }
171
+
172
+ return { remote, dockerHost, close };
173
+ }
174
+
175
+ /** A short private directory for an invocation whose SSH credentials already live in the operator or CI HOME. */
176
+ export function sessionDirectory() {
177
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'sc-'));
178
+ }
@@ -1,13 +1,115 @@
1
+ import fs from 'node:fs';
2
+
1
3
  // One command vocabulary for managed CI rendering and the runtime coverage gate.
2
4
  const exampleCommand = (source, command) => source?.runtimeExample
3
5
  ? 'node node_modules/@starci/hfs/runtime/scripts/hfs/example-secrets.mjs --run . -- ' + command
4
6
  : command;
5
7
 
6
- export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info') {
8
+ const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
9
+ const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
10
+ const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && env.SONAR_TOKEN != '' }}";
11
+
12
+ /** Codecov reports missing coverage without failing twice; uploads retain the release-tag condition and required authentication. */
13
+ function codecovSteps(lcovReport, codecov) {
14
+ if (codecov.enabled === false || codecov.wiring === 'not-used') return [
15
+ ' - name: coverage upload',
16
+ ' run: echo "codecov disabled in .starcistacks/application-stacks.yaml"',
17
+ ].join('\n');
18
+ return [
19
+ ' - name: coverage upload skipped',
20
+ ` if: \${{ !cancelled() && hashFiles('${lcovReport}') == '' }}`,
21
+ ' run: |',
22
+ ` echo "::notice::codecov upload skipped: no coverage file at ${lcovReport} (the test step failed or produced none)"`,
23
+ ' exit 0',
24
+ ' - name: coverage upload',
25
+ ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
26
+ ` if: \${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') && hashFiles('${lcovReport}') != '' }}`,
27
+ ' with:',
28
+ codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
29
+ ` files: ${lcovReport}`,
30
+ ' disable_search: true',
31
+ ' fail_ci_if_error: true',
32
+ ].join('\n');
33
+ }
34
+
35
+ /** Sonar consumes the test coverage and fast lint artifacts only on release tags with a configured token. */
36
+ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor) {
37
+ if (!sonar) return '';
38
+ const header = [
39
+ ' sonar:',
40
+ ' needs: [fast, test]',
41
+ ' runs-on: ubuntu-latest',
42
+ ' env:',
43
+ ` SONAR_TOKEN: \${{ secrets.${sonar.token} }}`,
44
+ ` SONAR_HOST_URL: \${{ vars.${sonar.hostVar} }}`,
45
+ ' steps:',
46
+ ].join('\n');
47
+ const steps = [
48
+ ` - uses: ${DOWNLOAD_ARTIFACT}`,
49
+ ` if: ${SONAR_CONDITION}`,
50
+ ' with:',
51
+ ` name: ${lcovArtifact}`,
52
+ ` path: ${lcovReport.split('/').slice(0, -1).join('/') || '.'}`,
53
+ ` - uses: ${DOWNLOAD_ARTIFACT}`,
54
+ ` if: ${SONAR_CONDITION}`,
55
+ ' with:',
56
+ ' name: sonar-lint',
57
+ ' path: reports',
58
+ ' - uses: SonarSource/sonarqube-scan-action@c7ee0f9df90b7aa20e8dcf9695dcfe2e7da5b4f2 # v7.2.1',
59
+ ` if: ${SONAR_CONDITION}`,
60
+ ' - uses: SonarSource/sonarqube-quality-gate-action@7a5fffe8e523c40e0c740b6bc2712ab503e52efa # v1.2.1',
61
+ ` if: ${SONAR_CONDITION}`,
62
+ ' timeout-minutes: 10',
63
+ ].join('\n');
64
+ // Variable strings are inserted after render() expands partials, so resolve Sonar's shared install partial before insertion.
65
+ const read = name => fs.readFileSync(new URL('../templates/app/ci-workflows/' + name, import.meta.url), 'utf8').replaceAll('\r\n', '\n');
66
+ return read('sonar-steps.yml')
67
+ .replace('{{> app/ci-workflows/install-steps.yml}}', () => read('install-steps.yml').trimEnd())
68
+ .replaceAll('{{nodeMajor}}', nodeMajor)
69
+ .replace('{{sonarJobHeader}}', () => header)
70
+ .replace('{{sonarJobSteps}}', () => steps)
71
+ .trimEnd();
72
+ }
73
+
74
+ /** An omitted quality declaration preserves hosted Sonar, required OIDC Codecov, and the high production dependency audit. */
75
+ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info', quality = undefined, { nodeMajor = '22' } = {}) {
76
+ const configuredSonar = quality === undefined ? { token: 'SONAR_TOKEN', hostVar: 'SONAR_HOST_URL' } : quality.sonar;
77
+ const sonar = configuredSonar?.wiring === 'not-used' || configuredSonar?.mode === 'disabled' ? null : configuredSonar;
78
+ const codecov = quality?.codecov ?? { enabled: true, auth: 'oidc' };
79
+ const audit = quality?.audit ?? { level: 'high', omitDev: true };
80
+ const lcovArtifact = 'coverage-lcov';
7
81
  return {
8
82
  ciLint: exampleCommand(source, 'npm run lint'),
9
83
  ciLintSonar: exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json'),
10
84
  ciCoverageProof: exampleCommand(source, 'npx starci app check --coverage-from ' + lcovReport),
11
85
  ciDatabaseLint: exampleCommand(source, 'npm run db:lint'),
86
+ auditCommand: `npm audit${audit.omitDev ? ' --omit=dev' : ''} --audit-level=${audit.level}`,
87
+ sonarTokenSecret: sonar?.token ?? 'SONAR_TOKEN',
88
+ sonarHostVar: sonar?.hostVar ?? 'SONAR_HOST_URL',
89
+ sonarEnv: sonar ? ` env:\n SONAR_TOKEN: \${{ secrets.${sonar.token} }}` : '',
90
+ ciLintSteps: [
91
+ ...['be', 'fe'].flatMap((side) => [
92
+ ' - name: lint ' + side,
93
+ ...(sonar ? [" if: env.SONAR_TOKEN == ''"] : []),
94
+ ` run: ${exampleCommand(source, 'npm run lint -- --side ' + side)}`,
95
+ ' env:',
96
+ ' NODE_OPTIONS: --max-old-space-size=6144',
97
+ ]),
98
+ ...(sonar ? [
99
+ ' - name: lint with Sonar import',
100
+ " if: env.SONAR_TOKEN != ''",
101
+ ` run: ${exampleCommand(source, 'npm run lint -- --sonar reports/lint.sonar.json')}`,
102
+ ' env:',
103
+ ' NODE_OPTIONS: --max-old-space-size=6144',
104
+ ` - uses: ${UPLOAD_ARTIFACT}`,
105
+ " if: always() && env.SONAR_TOKEN != '' && hashFiles('reports/lint.sonar.json') != ''",
106
+ ' with:',
107
+ ' name: sonar-lint',
108
+ ' path: reports/lint.sonar.json',
109
+ ] : []),
110
+ ].join('\n'),
111
+ codecovSteps: codecovSteps(lcovReport, codecov),
112
+ sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor),
113
+ lcovArtifact,
12
114
  };
13
115
  }
package/sync/format.mjs CHANGED
@@ -13,7 +13,7 @@ const IGNORE_FILE = '.prettierignore';
13
13
  const LOCKFILES = new Set(['package-lock.json']);
14
14
 
15
15
  /** The repository's own prettier, or a SyncError when it is not installed. */
16
- export function loadPrettier(repoRoot) {
16
+ function loadPrettier(repoRoot) {
17
17
  const require = createRequire(path.join(repoRoot, 'package.json'));
18
18
  try {
19
19
  return require('prettier');
package/sync/hygiene.mjs CHANGED
@@ -43,7 +43,7 @@ function ignoredAmong(cwd, files) {
43
43
  }
44
44
 
45
45
  /** Violations among `files` (repository-relative, '/'-separated): [{ file, code, message }]. `ignored` is a Set of ignored paths. */
46
- export function hygieneFindings(files, ignored) {
46
+ function hygieneFindings(files, ignored) {
47
47
  const findings = [];
48
48
  for (const file of files) {
49
49
  if (file.startsWith(WORK) && ignored.has(file)) {