@starci/hfs 4.4.1 → 4.5.1
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 +8 -0
- package/ci/cli.mjs +64 -0
- package/ci/github.mjs +41 -0
- package/ci/secret.mjs +28 -0
- package/ci/vars.mjs +33 -0
- package/ci/verify.mjs +36 -0
- package/db/backup-receipt.mjs +129 -0
- package/db/dump.mjs +66 -0
- package/db/migrate.mjs +4 -1
- package/db/reset.mjs +125 -0
- package/emit/db-types.mjs +98 -5
- package/lint/run.mjs +28 -6
- package/package.json +2 -1
- package/release/ci-red.mjs +70 -0
- package/release/gate.mjs +98 -27
- package/release/index.mjs +6 -4
- package/release/push.mjs +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +5 -5
- package/runtime/knowledge/hfs/rules.yaml +95 -4
- package/runtime/modules/kernel/failure-codes.yaml +60 -0
- package/runtime/modules/schemas/application-stacks.schema.yaml +134 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +243 -20
- package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +9 -0
- package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
- package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
- package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
- package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
- package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
- package/runtime/scripts/lib/secret-patterns.mjs +2 -2
- package/src/main.mjs +17 -6
- package/stack/context.mjs +13 -1
- package/stack/docker-transport.mjs +93 -15
- package/stack/release-store.mjs +12 -7
- package/stack/remote.mjs +14 -6
- package/stack/ssh-session.mjs +178 -0
- package/sync/ci-commands.mjs +205 -1
- package/sync/index.mjs +42 -16
- package/sync/quality-services.mjs +61 -0
- package/sync/readme-badges.mjs +44 -0
- package/sync/stack-images.mjs +29 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +33 -25
- package/templates/app/ci-workflows/github/workflows/e2e.yml +1 -0
- package/templates/app/ci-workflows/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/ci-workflows/install-steps.yml +23 -0
- package/templates/app/ci-workflows/jest-cache.yml +8 -0
- package/templates/app/ci-workflows/lint-base.yml +16 -0
- package/templates/app/ci-workflows/sonar-steps.yml +3 -9
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +36 -36
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -1
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +25 -2
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
- package/templates/app/gitignore +2 -0
- package/templates/app/gitignore-lite +2 -0
- package/templates/app/quality-config/codecov.yml +6 -6
- package/templates/app/quality-config/sonar-project.properties +1 -0
- package/templates/app/readme-badges/README.md +1 -0
- package/templates/app/skeleton/.github/dependabot.yml +1 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +62 -3
- package/templates/app/skeleton/README.md +6 -0
- package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +58 -3
- package/templates/app/skeleton-lite/README.md +6 -0
- package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +53 -1
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// ci-custody.mjs - R296-R298: CI declarations, sealed app keys and workflow reads agree by consumer, without decrypting or contacting GitHub.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { list } from '../../lib/list.mjs';
|
|
4
|
+
import { findStackDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
|
|
5
|
+
import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices } from '../ci-declaration.mjs';
|
|
6
|
+
import { found } from './read.mjs';
|
|
7
|
+
|
|
8
|
+
const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
|
|
9
|
+
const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
|
|
10
|
+
const identity = (kind, name) => `${kind}:${name}`;
|
|
11
|
+
const atCi = (section) => `${DECLARATION}#ci.${section}`;
|
|
12
|
+
|
|
13
|
+
/** A CI or Dependabot secret must never also live in a sealed app member. */
|
|
14
|
+
function sealedFindings(ci, sealed) {
|
|
15
|
+
const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
|
|
16
|
+
return sealed.filter(({ key }) => names.has(key)).map(({ key, file }) => found(
|
|
17
|
+
'CI_SECRET_SEALED', file,
|
|
18
|
+
`${key} is a CI secret (GitHub reads it) but it is sealed in ${file} for app containers; one key, one class: remove it from the member (starci app secret unset ...) and set it with starci app ci secret set ${key}`,
|
|
19
|
+
));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A source selects sealed keys only from its own member and environment. */
|
|
23
|
+
function sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen }) {
|
|
24
|
+
for (const entry of sealed) {
|
|
25
|
+
if (entry.env !== environment || entry.member !== source?.member || !names.has(entry.key)) continue;
|
|
26
|
+
if (source.keys !== undefined && !list(source.keys).includes(entry.key)) continue;
|
|
27
|
+
const id = `${entry.file}:${grant.service}:${entry.key}`;
|
|
28
|
+
if (seen.has(id)) continue;
|
|
29
|
+
seen.add(id);
|
|
30
|
+
findings.push(found('APP_SECRET_IN_CI', entry.file,
|
|
31
|
+
`${entry.key} is read by the ${grant.service} container at runtime through the ${environment} deploy grant; keep it in the .enc and drop it from ci`));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Only keys actually selected by a grant, in that grant's environment and member, are runtime consumers. */
|
|
36
|
+
function runtimeFindings(doc, ci, sealed) {
|
|
37
|
+
const names = new Set([...ci.secrets, ...ci.vars].filter(named).map((entry) => entry.name));
|
|
38
|
+
const findings = [], seen = new Set();
|
|
39
|
+
for (const [environment, spec] of Object.entries(doc.environments ?? {})) {
|
|
40
|
+
for (const grant of list(spec?.deploy?.secrets?.grants)) {
|
|
41
|
+
if (typeof grant?.service !== 'string') continue;
|
|
42
|
+
for (const source of list(grant.from)) sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return findings;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
|
|
49
|
+
function workflowFindings(doc, declared, references) {
|
|
50
|
+
const hasCi = doc.ci !== undefined;
|
|
51
|
+
const allowed = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
52
|
+
if (hasCi) {
|
|
53
|
+
for (const entry of ciNamesOfServices(doc).filter(named)) {
|
|
54
|
+
const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
|
|
55
|
+
if (legacy.some((item) => (typeof item === 'string' ? item : item?.name) === entry.name)) allowed.add(identity(`${entry.kind}s`, entry.name));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const findings = [], seen = new Set();
|
|
59
|
+
for (const { file, kind, name } of references) {
|
|
60
|
+
if (allowed.has(identity(kind, name))) continue;
|
|
61
|
+
const id = hasCi ? `${file}:${kind}:${name}` : name;
|
|
62
|
+
if (seen.has(id)) continue;
|
|
63
|
+
seen.add(id);
|
|
64
|
+
findings.push(found('CI_DECLARATION_DRIFT', file,
|
|
65
|
+
`${kind}.${name} is read by this workflow but is not declared in ci.${kind}; declare its name, scope and consumer in ${DECLARATION}`));
|
|
66
|
+
}
|
|
67
|
+
return findings;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Dependabot has its own store and consumer, so only Actions secrets and vars require a workflow read. */
|
|
71
|
+
function driftFindings(doc, ci, references) {
|
|
72
|
+
const declared = [
|
|
73
|
+
...ci.secrets.filter(named).map((entry) => ({ ...entry, kind: 'secrets' })),
|
|
74
|
+
...ci.vars.filter(named).map((entry) => ({ ...entry, kind: 'vars' })),
|
|
75
|
+
];
|
|
76
|
+
const findings = workflowFindings(doc, declared, references);
|
|
77
|
+
if (doc.ci === undefined) return findings;
|
|
78
|
+
const reads = new Set(references.map(({ kind, name }) => identity(kind, name)));
|
|
79
|
+
for (const { kind, name, optional } of declared) {
|
|
80
|
+
if (!optional && !reads.has(identity(kind, name))) findings.push(found('CI_DECLARATION_DRIFT', atCi(kind),
|
|
81
|
+
`${name} is declared in ci.${kind} as non-optional but no workflow reads it; remove the declaration, mark it optional or wire its workflow consumer`));
|
|
82
|
+
}
|
|
83
|
+
const names = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
84
|
+
for (const [service, entry] of Object.entries(doc.services ?? {})) {
|
|
85
|
+
for (const [field, kind] of [['token', 'secrets'], ['hostVar', 'vars']]) {
|
|
86
|
+
const name = entry?.ci?.[field];
|
|
87
|
+
if (typeof name !== 'string' || names.has(identity(kind, name))) continue;
|
|
88
|
+
findings.push(found('CI_DECLARATION_DRIFT', atCi(kind),
|
|
89
|
+
`services.${service}.ci.${field} names ${name}, which is not declared in ci.${kind}; declare the shared CI name`));
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return findings;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Offline CI custody findings of an app root, in both lite and full editions. */
|
|
96
|
+
export function ciCustodyFindings({ repoRoot, repo, isRoot }) {
|
|
97
|
+
if (repo?.kind !== 'app' || !['lite', 'full'].includes(repo.edition) || isRoot === false) return [];
|
|
98
|
+
const { doc } = findStackDeclaration(repoRoot);
|
|
99
|
+
if (!doc) return [];
|
|
100
|
+
const ci = readCiDeclaration(doc);
|
|
101
|
+
const sealed = sealedKeyNames(repoRoot, { fs });
|
|
102
|
+
const references = workflowReferences(repoRoot, { fs });
|
|
103
|
+
return [...sealedFindings(ci, sealed), ...runtimeFindings(doc, ci, sealed), ...driftFindings(doc, ci, references)];
|
|
104
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// R251: product workflow custody, immutable dependencies and release-only triggers, in both editions.
|
|
2
|
-
import { parseWorkflow
|
|
3
|
-
import { found
|
|
2
|
+
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
3
|
+
import { found } from './read.mjs';
|
|
4
|
+
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
4
5
|
import { isPlainObject as record } from '../../../engine/plain-object.mjs';
|
|
5
6
|
import { workflowEventOptions } from '../../lib/workflow-events.mjs';
|
|
6
7
|
|
|
7
8
|
const DEPLOY = 'HFS_DEPLOY_WORKFLOW';
|
|
8
|
-
const PIN = 'HFS_WORKFLOW_PIN';
|
|
9
9
|
const TOOL = 'HFS_CI_TOOL_INSTALL';
|
|
10
10
|
const TRIGGER = 'HFS_APP_WORKFLOW_TRIGGER';
|
|
11
11
|
// Only the age identity and SSH transport credentials belong in the CI secret store.
|
|
@@ -85,18 +85,6 @@ function triggerFindings(doc, refuse) {
|
|
|
85
85
|
return findings;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
function actionFindings(doc, refuse) {
|
|
89
|
-
const findings = [];
|
|
90
|
-
for (const { key, value } of entries(doc)) {
|
|
91
|
-
if (key !== 'uses') continue;
|
|
92
|
-
const local = typeof value === 'string' && /^\.\/[\w./-]+$/.test(value);
|
|
93
|
-
const remote = typeof value === 'string' && /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[a-f\d]{40}$/i.test(value);
|
|
94
|
-
const container = typeof value === 'string' && /^docker:\/\/[^\s@]+@sha256:[a-f\d]{64}$/i.test(value);
|
|
95
|
-
if (!local && !remote && !container) findings.push(refuse(PIN, 'every remote uses entry needs a full 40-hex commit SHA (a container action needs a sha256 digest)'));
|
|
96
|
-
}
|
|
97
|
-
return findings;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
88
|
function secretProblems(doc) {
|
|
101
89
|
const problems = [];
|
|
102
90
|
if (entries(doc).some(({ key, value }) => key === 'secrets' && value === 'inherit')) problems.push('inherited secrets are forbidden');
|
|
@@ -209,11 +197,10 @@ function perWorkflow({ path: file, text }) {
|
|
|
209
197
|
const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
|
|
210
198
|
const doc = parseWorkflow(text);
|
|
211
199
|
if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
|
|
212
|
-
return [...triggerFindings(doc, refuse), ...
|
|
200
|
+
return [...triggerFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
|
|
213
201
|
}
|
|
214
202
|
|
|
215
203
|
/** Synchronous R251 findings over the tracked workflows of a lite/full app. */
|
|
216
|
-
export function deployWorkflowFindings(
|
|
217
|
-
|
|
218
|
-
return workflowFindings({ files: all.filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, perWorkflow);
|
|
204
|
+
export function deployWorkflowFindings(ctx) {
|
|
205
|
+
return appWorkflowFindings(ctx, perWorkflow);
|
|
219
206
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// R300 FE_PUBLIC_BUILD_ARG_MISSING: every required browser build value has a Dockerfile ARG default or a buildArgs name in every environment deploying that app; literal fallback reads are optional.
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { loadTypescript } from '../../lib/package-at.mjs';
|
|
5
|
+
import { parseDockerfile, words } from '../../lib/dockerfile.mjs';
|
|
6
|
+
import { parseYaml } from '../../../engine/yaml.mjs';
|
|
7
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
8
|
+
import { found, readText } from './read.mjs';
|
|
9
|
+
|
|
10
|
+
export const FE_PUBLIC_BUILD_ARG_MISSING = 'FE_PUBLIC_BUILD_ARG_MISSING';
|
|
11
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const PUBLIC = /^NEXT_PUBLIC_[A-Z0-9_]+$/u;
|
|
13
|
+
const SOURCE = /\.[cm]?[jt]sx?$/u;
|
|
14
|
+
const SCHEMA = /(?:^|[/.-])(?:env|schema)(?:[/.-]|$)/u;
|
|
15
|
+
const DECLARATION = '.starcistacks/application-stacks.yaml';
|
|
16
|
+
|
|
17
|
+
function declarationOf(root) {
|
|
18
|
+
try { return parseYaml(readText(root, DECLARATION) ?? '') ?? {}; } catch { return {}; }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Parentheses and TypeScript assertions do not alter whether a read has a literal fallback. */
|
|
22
|
+
function unwrappedParent(ts, node) {
|
|
23
|
+
let current = node;
|
|
24
|
+
while (current.parent && (ts.isParenthesizedExpression(current.parent) || ts.isAsExpression(current.parent) || ts.isNonNullExpression(current.parent))) current = current.parent;
|
|
25
|
+
return current;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function literalFallback(ts, node) {
|
|
29
|
+
const current = unwrappedParent(ts, node);
|
|
30
|
+
const parent = current.parent;
|
|
31
|
+
if (!parent || !ts.isBinaryExpression(parent) || parent.left !== current) return false;
|
|
32
|
+
const fallback = parent.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || parent.operatorToken.kind === ts.SyntaxKind.BarBarToken;
|
|
33
|
+
return fallback && ts.isStringLiteralLike(parent.right);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function publicRead(ts, node) {
|
|
37
|
+
if (!ts.isPropertyAccessExpression(node) || !PUBLIC.test(node.name.text)) return null;
|
|
38
|
+
const owner = node.expression;
|
|
39
|
+
const env = ts.isIdentifier(owner) && owner.text === 'env';
|
|
40
|
+
const processEnv = ts.isPropertyAccessExpression(owner) && owner.name.text === 'env' && ts.isIdentifier(owner.expression) && owner.expression.text === 'process';
|
|
41
|
+
return env || processEnv ? node.name.text : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function optionalSchema(ts, node) {
|
|
45
|
+
if (!node) return false;
|
|
46
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
47
|
+
if (['optional', 'default', 'catch', 'nullish'].includes(node.expression.name.text)) return true;
|
|
48
|
+
return optionalSchema(ts, node.expression.expression);
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function schemaKey(ts, node) {
|
|
54
|
+
if (!ts.isPropertyAssignment(node) && !ts.isPropertySignature(node)) return null;
|
|
55
|
+
const name = node.name?.text;
|
|
56
|
+
if (!PUBLIC.test(name ?? '') || node.questionToken || optionalSchema(ts, node.initializer)) return null;
|
|
57
|
+
return name;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sourceRequirements(ts, file, text) {
|
|
61
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
62
|
+
const required = new Set();
|
|
63
|
+
const schema = SCHEMA.test(file);
|
|
64
|
+
const visit = node => {
|
|
65
|
+
const name = publicRead(ts, node);
|
|
66
|
+
if (name && !literalFallback(ts, node)) required.add(name);
|
|
67
|
+
const key = schema ? schemaKey(ts, node) : null;
|
|
68
|
+
if (key) required.add(key);
|
|
69
|
+
ts.forEachChild(node, visit);
|
|
70
|
+
};
|
|
71
|
+
visit(source);
|
|
72
|
+
return required;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** JSON/YAML environment schemas declare required names explicitly; optional properties impose no build requirement. */
|
|
76
|
+
function schemaRequirements(text) {
|
|
77
|
+
let schema;
|
|
78
|
+
try { schema = parseYaml(text); } catch { return []; }
|
|
79
|
+
const names = [];
|
|
80
|
+
const visit = value => {
|
|
81
|
+
if (!value || typeof value !== 'object') return;
|
|
82
|
+
if (Array.isArray(value.required)) names.push(...value.required.filter(name => typeof name === 'string' && PUBLIC.test(name)));
|
|
83
|
+
Object.values(value).forEach(child => { if (child && typeof child === 'object') visit(child); });
|
|
84
|
+
};
|
|
85
|
+
visit(schema);
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requiredNames(root, files, app, ts) {
|
|
90
|
+
const prefix = `fe/apps/${app}/`;
|
|
91
|
+
const names = new Set();
|
|
92
|
+
for (const file of files.filter(file => file.startsWith(prefix))) {
|
|
93
|
+
const local = file.slice(prefix.length);
|
|
94
|
+
const source = SOURCE.test(local) && (local.startsWith('src/') || /^next\.config\./u.test(local) || SCHEMA.test(local));
|
|
95
|
+
const schema = SCHEMA.test(local) && /\.(?:json|ya?ml)$/u.test(local);
|
|
96
|
+
if (!source && !schema) continue;
|
|
97
|
+
const text = readText(root, file);
|
|
98
|
+
if (text === null) continue;
|
|
99
|
+
const required = source ? sourceRequirements(ts, local, text) : schemaRequirements(text);
|
|
100
|
+
for (const name of required) names.add(name);
|
|
101
|
+
}
|
|
102
|
+
return [...names].sort(byCodeUnit);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function dockerDefaults(text) {
|
|
106
|
+
return new Set(parseDockerfile(text).instructions.filter(item => item.keyword === 'ARG')
|
|
107
|
+
.flatMap(item => words(item.text)).filter(word => word.includes('='))
|
|
108
|
+
.map(word => word.slice(0, word.indexOf('='))));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function missingForEnvironment(environment, app, required) {
|
|
112
|
+
const images = Object.values(environment?.deploy?.images ?? {}).filter(image => image?.app === app);
|
|
113
|
+
if (!images.length) return [];
|
|
114
|
+
return required.filter(name => images.some(image => {
|
|
115
|
+
const args = image.buildArgs ?? {};
|
|
116
|
+
return !Object.hasOwn(args.values ?? {}, name) && !(args.sealed?.keys ?? []).includes(name);
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function appFindings(context, app, environments, ts) {
|
|
121
|
+
const { repoRoot, files } = context;
|
|
122
|
+
const file = `fe/apps/${app}/Dockerfile`;
|
|
123
|
+
if (!files.includes(file)) return [];
|
|
124
|
+
const defaults = dockerDefaults(readText(repoRoot, file) ?? '');
|
|
125
|
+
const required = requiredNames(repoRoot, files, app, ts).filter(name => !defaults.has(name));
|
|
126
|
+
const missing = Object.entries(environments).flatMap(([environment, entry]) => {
|
|
127
|
+
const names = missingForEnvironment(entry, app, required);
|
|
128
|
+
return names.length ? [{ environment, names }] : [];
|
|
129
|
+
});
|
|
130
|
+
if (!missing.length) return [];
|
|
131
|
+
const detail = missing.map(entry => `${entry.environment}: ${entry.names.join(', ')}`).join('; ');
|
|
132
|
+
return [found(FE_PUBLIC_BUILD_ARG_MISSING, file, `${FE_PUBLIC_BUILD_ARG_MISSING}: ${file} requires public build values without ARG defaults or deploy image buildArgs (${detail}).`, { missing })];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Judge front-end Dockerfiles at the app root in both editions; only names are read, and sealed members are never opened. */
|
|
136
|
+
export function feBuildArgsFindings({ repoRoot, files = [], all = files, repo, isRoot = true }) {
|
|
137
|
+
if (!repoRoot || !isRoot || !repo?.sides?.fe) return [];
|
|
138
|
+
const ts = loadTypescript(repoRoot, HERE);
|
|
139
|
+
if (!ts) return [];
|
|
140
|
+
const environments = declarationOf(repoRoot).environments ?? {};
|
|
141
|
+
return repo.sides.fe.apps.flatMap(app => appFindings({ repoRoot, files: all }, app.name, environments, ts));
|
|
142
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// R269: app repositories in both editions declare dependency updates, a production audit gate, and image SBOM/provenance.
|
|
2
|
-
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
2
|
+
import { parseWorkflow, workflowFindings } from '../runtime-rules/workflow-source.mjs';
|
|
3
3
|
import { found, readText } from './read.mjs';
|
|
4
4
|
|
|
5
5
|
const DEPENDABOT = '.github/dependabot.yml';
|
|
@@ -85,12 +85,48 @@ function stepsOf(workflow) {
|
|
|
85
85
|
return Object.values(workflow?.jobs ?? {}).filter(enabled).flatMap((job) => Array.isArray(job.steps) ? job.steps.filter(enabled) : []);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
/** npm groups admit only minor and patch updates; canon packages are ignored for every update type. */
|
|
89
|
+
function hasNpmPolicy(update) {
|
|
90
|
+
const groups = update.groups && typeof update.groups === 'object' && !Array.isArray(update.groups) ? Object.values(update.groups) : [];
|
|
91
|
+
const minorPatch = (group) => Array.isArray(group?.['update-types']) && group['update-types'].includes('minor') && group['update-types'].includes('patch');
|
|
92
|
+
return groups.length > 0 && groups.every((group) => Array.isArray(group?.['update-types']) && group['update-types'].length > 0 && group['update-types'].every((type) => ['minor', 'patch'].includes(type))) &&
|
|
93
|
+
groups.some((group) => minorPatch(group) && [undefined, 'version-updates'].includes(group['applies-to'])) &&
|
|
94
|
+
groups.some((group) => group['applies-to'] === 'security-updates') &&
|
|
95
|
+
Array.isArray(update.ignore) && update.ignore.some((entry) => entry?.['dependency-name'] === '@starci/*' && entry.versions === undefined && entry['update-types'] === undefined);
|
|
96
|
+
}
|
|
97
|
+
|
|
88
98
|
function hasDependabot(document) {
|
|
89
99
|
const directory = (value) => typeof value === 'string' && value.startsWith('/');
|
|
90
100
|
return document?.version === 2 && Array.isArray(document.updates) && ECOSYSTEMS.every((ecosystem) => document.updates.some((update) =>
|
|
91
101
|
update?.['package-ecosystem'] === ecosystem &&
|
|
92
102
|
(directory(update.directory) || Array.isArray(update.directories) && update.directories.length > 0 && update.directories.every(directory)) &&
|
|
93
|
-
['daily', 'weekly', 'monthly'].includes(update.schedule?.interval)))
|
|
103
|
+
['daily', 'weekly', 'monthly'].includes(update.schedule?.interval))) &&
|
|
104
|
+
document.updates.filter((update) => update?.['package-ecosystem'] === 'npm').every(hasNpmPolicy);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pinnedAction(value) {
|
|
108
|
+
return typeof value === 'string' && (/^\.\/[\w./-]+$/.test(value) ||
|
|
109
|
+
/^[\w.-]+\/[\w.-]+(?:\/[\w.-]+)*@[a-f\d]{40}$/i.test(value) ||
|
|
110
|
+
/^docker:\/\/[^\s@]+@sha256:[a-f\d]{64}$/i.test(value));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Mark YAML keys before parsing so comments and script scalars are ignored while flow maps and quoted keys retain their source lines. */
|
|
114
|
+
function workflowPinFindings({ path: file, text }) {
|
|
115
|
+
const marked = text.replace(/(^|[\s{,])(?:"uses"|'uses'|uses)([ \t]*:)/gm, (match, prefix, colon, offset) => {
|
|
116
|
+
const line = text.slice(0, offset + prefix.length).split('\n').length;
|
|
117
|
+
return `${prefix}__hfs_action_uses_line_${line}__${colon}`;
|
|
118
|
+
});
|
|
119
|
+
const findings = [];
|
|
120
|
+
function visit(value) {
|
|
121
|
+
if (!value || typeof value !== 'object') return;
|
|
122
|
+
for (const [key, member] of Object.entries(value)) {
|
|
123
|
+
const line = /^__hfs_action_uses_line_(\d+)__$/.exec(key)?.[1];
|
|
124
|
+
if (line && !pinnedAction(member)) findings.push(found('HFS_WORKFLOW_PIN', file, `${file}:${line}: every remote uses entry needs a full 40-hex commit SHA; container actions need a sha256 digest.`, { line: Number(line) }));
|
|
125
|
+
visit(member);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
visit(parseWorkflow(marked));
|
|
129
|
+
return findings;
|
|
94
130
|
}
|
|
95
131
|
|
|
96
132
|
function hasAudit(steps) {
|
|
@@ -160,8 +196,9 @@ export function supplyChainFindings({ repoRoot, files, all = files, repo, isRoot
|
|
|
160
196
|
const tracked = new Set(all);
|
|
161
197
|
const parsed = (file) => tracked.has(file) ? parseWorkflow(readText(repoRoot, file) ?? '') : null;
|
|
162
198
|
const findings = [];
|
|
163
|
-
if (inScope(DEPENDABOT) && !hasDependabot(parsed(DEPENDABOT))) findings.push(found('HFS_DEPENDABOT_MISSING', DEPENDABOT, 'Declare Dependabot version 2 updates for npm, github-actions and docker, each with a directory and schedule.'));
|
|
199
|
+
if (inScope(DEPENDABOT) && !hasDependabot(parsed(DEPENDABOT))) findings.push(found('HFS_DEPENDABOT_MISSING', DEPENDABOT, 'Declare Dependabot version 2 updates for npm, github-actions and docker, each with a directory and schedule; every npm update must group minor+patch version updates, restrict every group to minor/patch (never major), include a security-updates group, and ignore all @starci/* updates (canon pins move only by starci upgrade).'));
|
|
164
200
|
if (inScope(CI) && !hasAudit(stepsOf(parsed(CI)))) findings.push(found('HFS_AUDIT_STEP_MISSING', CI, 'CI must run npm audit --omit=dev in an enabled step whose failure stops the job.'));
|
|
165
201
|
if (inScope(IMAGES) && !imageEvidence(stepsOf(parsed(IMAGES)))) findings.push(found('HFS_SBOM_MISSING', IMAGES, 'The images workflow must generate an SBOM and provenance for its release images in enabled steps.'));
|
|
202
|
+
findings.push(...workflowFindings({ files: [...tracked].filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, workflowPinFindings));
|
|
166
203
|
return findings;
|
|
167
204
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// R299: GitHub Actions expressions use only contexts available at their workflow key.
|
|
2
|
+
import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
|
|
3
|
+
import { found } from './read.mjs';
|
|
4
|
+
import { appWorkflowFindings } from './app-workflow-findings.mjs';
|
|
5
|
+
|
|
6
|
+
const WORKFLOW = ['github', 'inputs', 'vars'];
|
|
7
|
+
const JOB_IF = ['github', 'needs', 'vars', 'inputs'];
|
|
8
|
+
const JOB = [...JOB_IF, 'strategy', 'matrix'];
|
|
9
|
+
const JOB_ENV = [...JOB, 'secrets'];
|
|
10
|
+
const EXECUTION = [...JOB_ENV, 'job', 'runner', 'env'];
|
|
11
|
+
const CONTEXTS = [...EXECUTION, 'steps'];
|
|
12
|
+
const JOB_KEYS = new Set(['name', 'runs-on', 'concurrency', 'strategy', 'timeout-minutes', 'continue-on-error', 'with']);
|
|
13
|
+
|
|
14
|
+
/** Container and service images are chosen before a runner exists; their other properties use execution contexts. */
|
|
15
|
+
function containerContexts(keys) {
|
|
16
|
+
if (keys[2] === 'services') return keys[4] === 'image' ? JOB : EXECUTION;
|
|
17
|
+
return keys.length === 3 || keys[3] === 'image' ? JOB : EXECUTION;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The most specific row of GitHub's Context availability table governs each descendant value. */
|
|
21
|
+
function jobContexts(keys) {
|
|
22
|
+
const key = keys[2];
|
|
23
|
+
if (key === 'steps') return CONTEXTS;
|
|
24
|
+
if (key === 'if') return JOB_IF;
|
|
25
|
+
if (key === 'strategy') return JOB_IF;
|
|
26
|
+
if (key === 'env' || key === 'secrets') return JOB_ENV;
|
|
27
|
+
if (key === 'outputs') return CONTEXTS;
|
|
28
|
+
if (key === 'defaults' && keys[3] === 'run') return [...JOB, 'env'];
|
|
29
|
+
if (key === 'environment') return keys[3] === 'url' ? CONTEXTS.filter((context) => context !== 'secrets') : JOB;
|
|
30
|
+
if (key === 'container' || key === 'services') return containerContexts(keys);
|
|
31
|
+
if (JOB_KEYS.has(key)) return JOB;
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Unlisted workflow keys admit no contexts; reusable workflow outputs alone may read jobs. */
|
|
36
|
+
function allowedContexts(keys) {
|
|
37
|
+
if (keys[0] === 'jobs') return jobContexts(keys);
|
|
38
|
+
if (keys[0] === 'env') return [...WORKFLOW, 'secrets'];
|
|
39
|
+
if (keys[0] === 'run-name' || keys[0] === 'concurrency') return WORKFLOW;
|
|
40
|
+
if (keys.slice(0, 3).join('.') === 'on.workflow_call.inputs' && keys[4] === 'default') return WORKFLOW;
|
|
41
|
+
if (keys.slice(0, 3).join('.') === 'on.workflow_call.outputs' && keys[4] === 'value') return [...WORKFLOW, 'jobs'];
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Strings and property names are not context roots; bracket access and case-insensitive roots still are. */
|
|
46
|
+
function expressionContexts(expression) {
|
|
47
|
+
const unquoted = expression.replace(/'(?:[^']|'')*'/g, ' ');
|
|
48
|
+
const tokens = unquoted.match(/[A-Za-z_][\w-]*|[^\s]/g) ?? [];
|
|
49
|
+
const roots = tokens.filter((token, index) => tokens[index - 1] !== '.' &&
|
|
50
|
+
([...CONTEXTS, 'jobs'].includes(token.toLowerCase()) || ['.', '['].includes(tokens[index + 1]))).map((token) => token.toLowerCase());
|
|
51
|
+
return [...new Set(roots)];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A closing delimiter inside an Actions single-quoted string cannot end an expression. */
|
|
55
|
+
function expressionEnd(value, start) {
|
|
56
|
+
let quoted = false;
|
|
57
|
+
for (let index = start; index < value.length; index++) {
|
|
58
|
+
if (value[index] === "'") quoted = !quoted;
|
|
59
|
+
if (!quoted && value.slice(index, index + 2) === '}}') return index;
|
|
60
|
+
}
|
|
61
|
+
return value.length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Read marked expressions without ambiguous regex repetition or losing delimiters inside string literals. */
|
|
65
|
+
function expressions(value) {
|
|
66
|
+
const result = [];
|
|
67
|
+
const marker = /\$\{\{ __hfs_context_line_(\d+)__ /g;
|
|
68
|
+
let match;
|
|
69
|
+
while ((match = marker.exec(value)) !== null) {
|
|
70
|
+
const end = expressionEnd(value, marker.lastIndex);
|
|
71
|
+
result.push({ line: Number(match[1]), body: value.slice(marker.lastIndex, end) });
|
|
72
|
+
marker.lastIndex = end + 2;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
|
|
78
|
+
function workflowContextFindings({ path: file, text }) {
|
|
79
|
+
const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
|
|
80
|
+
const findings = [];
|
|
81
|
+
function inspect(value, keys) {
|
|
82
|
+
const allowed = allowedContexts(keys);
|
|
83
|
+
for (const { line, body } of expressions(value)) {
|
|
84
|
+
for (const context of expressionContexts(body)) {
|
|
85
|
+
if (allowed.includes(context)) continue;
|
|
86
|
+
const keyPath = keys.join('.');
|
|
87
|
+
findings.push(found('HFS_WORKFLOW_CONTEXT', file, `HFS_WORKFLOW_CONTEXT: ${file}:${line}: ${keyPath} forbids context ${context}.`, { line, keyPath, context }));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function visit(value, keys = []) {
|
|
92
|
+
if (typeof value === 'string') inspect(value, keys);
|
|
93
|
+
if (!value || typeof value !== 'object') return;
|
|
94
|
+
for (const [key, member] of Object.entries(value)) visit(member, [...keys, key]);
|
|
95
|
+
}
|
|
96
|
+
visit(parseWorkflow(marked));
|
|
97
|
+
return findings;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Judge every tracked workflow in scope at the full or lite app root, using the shared workflow selector. */
|
|
101
|
+
export function workflowContextsFindings(ctx) {
|
|
102
|
+
return appWorkflowFindings(ctx, workflowContextFindings, { dedupe: true });
|
|
103
|
+
}
|
|
@@ -43,11 +43,11 @@ const SECRET_NAME = [
|
|
|
43
43
|
String.raw`[A-Za-z_$][A-Za-z0-9_$.-]*\s*[:=]\s*`,
|
|
44
44
|
].join('');
|
|
45
45
|
|
|
46
|
-
// `password = "
|
|
46
|
+
// `password = "example-password-1"` — the quoted literal. `env(NAME)` is a reference, never a secret.
|
|
47
47
|
const ASSIGNED_QUOTED = new RegExp(
|
|
48
48
|
SECRET_NAME + String.raw`['"\`](?!env\([A-Z_][A-Z0-9_]*\)['"\`])([^'"\`\n\s$<{]{12,})['"\`]`,
|
|
49
49
|
);
|
|
50
|
-
// `DB_PASSWORD=
|
|
50
|
+
// `DB_PASSWORD=example-password-1` — the compose list-item and dotenv form; the dotenv value has no `:` so
|
|
51
51
|
// `password: secret:db/password` and `token: ref:vault/api` stay references, and the end boundary keeps
|
|
52
52
|
// calls and member/index references such as `secret = credentialsSource.current` out of the literals.
|
|
53
53
|
const ASSIGNED_UNQUOTED = new RegExp(
|
package/src/main.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
// runs on those owners without clones and dead exports. No merge-base is a refusal
|
|
16
16
|
// (exit 2), never a silent full pass. Exit 1 on any error-level finding. The root checks run once and
|
|
17
17
|
// the side checks and the machine once per side, the side folder as their root; every path is app-relative.
|
|
18
|
-
// starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
|
|
18
|
+
// starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
|
|
19
19
|
// the ONE lint entry (npm run lint): ESLint per side with that side's canon config (per-file rules and
|
|
20
20
|
// the project-graph rules), this check's findings, and stylelint over the fe side, as one starci/lint@1
|
|
21
21
|
// report; --sonar writes the one Sonar import file. Exit 0 clean, 1 findings, 2 a tool could not run (lint/run.mjs).
|
|
@@ -43,6 +43,9 @@
|
|
|
43
43
|
// starci app secret materialize <env> | list <env> | verify <env>
|
|
44
44
|
// dotenv custody in .starcistacks, public age rule initialization, private KEY files outside Git;
|
|
45
45
|
// values reach stdout only when get explicitly receives --reveal.
|
|
46
|
+
// starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] reads the value from stdin only.
|
|
47
|
+
// starci app ci var set <NAME> [--env E] [--value v] | var sync [--execute] | verify [--json]
|
|
48
|
+
// declared GitHub CI custody; verify compares names, scopes and public values.
|
|
46
49
|
// starci app db baseline | restamp --map <restamp.yaml> | plan
|
|
47
50
|
// preview the database cutover or compare its state with the repo; baseline and restamp mutate only with --apply.
|
|
48
51
|
// starci app add <api|webhook|realtime|saga|job|reactor|queue|projection> <name> [--event <event> --from <service> --service <Class>=<module>] [--owner <service> --failed <event> --done <event>] [--connection <name>] [--cwd <dir>]
|
|
@@ -83,18 +86,22 @@ import { ACTIONS as STACK_ACTIONS, stackMain as deployStackMain } from '../stack
|
|
|
83
86
|
import { releaseMain } from '../release/index.mjs';
|
|
84
87
|
import { dbMigrateMain } from '../db/migrate.mjs';
|
|
85
88
|
import { dbPostCutoverMain } from '../db/post-cutover.mjs';
|
|
89
|
+
import { dbDumpMain } from '../db/dump.mjs';
|
|
90
|
+
import { dbResetMain } from '../db/reset.mjs';
|
|
86
91
|
|
|
87
|
-
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'db', 'hygiene']);
|
|
88
|
-
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain]]);
|
|
92
|
+
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene']);
|
|
93
|
+
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
89
94
|
|
|
90
95
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
91
96
|
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [options] [--cwd <dir>]
|
|
92
|
-
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
|
|
97
|
+
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
|
|
93
98
|
starci app sync (--check | --write) [--cwd <dir>]
|
|
94
99
|
starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--edition full] [--db-types] [--wiring] [--coverage-from <lcov>]
|
|
95
100
|
starci app db baseline [--stamp <version>] [--local] [--apply] [--cwd <dir>]
|
|
96
101
|
starci app db restamp --map <restamp.yaml> [--local] [--apply] [--cwd <dir>]
|
|
97
102
|
starci app db plan [--local] [--json] [--cwd <dir>]
|
|
103
|
+
starci app db dump (--linked | --local | --url-env <NAME>) --out <dir> [--schema-only] [--json] [--cwd <dir>]
|
|
104
|
+
starci app db reset --linked --auth keep|drop --storage keep|drop --backup <receipt.json> [--execute] [--json] [--cwd <dir>]
|
|
98
105
|
starci app db migrate [--execute] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
99
106
|
starci app db post-cutover [--execute] [--green-at <ISO> | --owner-confirms-old-app-gone] [--linked | --url-env <NAME>] [--json] [--cwd <dir>]
|
|
100
107
|
starci app release gate [--execute] [--source <ref>] [--tag-prefix <prefix>] [--json] [--cwd <dir>]
|
|
@@ -116,6 +123,10 @@ starci app secret remove <env> <slug> [--execute] [--force-nonempty] [--cwd <dir
|
|
|
116
123
|
starci app secret materialize <env> [--cwd <dir>]
|
|
117
124
|
starci app secret list <env> [--cwd <dir>]
|
|
118
125
|
starci app secret verify <env> [--cwd <dir>]
|
|
126
|
+
starci app ci secret set <NAME> [--env E | --dependabot] [--repo owner/name] [--cwd <dir>]
|
|
127
|
+
starci app ci var set <NAME> [--env E] [--value v] [--repo owner/name] [--cwd <dir>]
|
|
128
|
+
starci app ci var sync [--execute] [--repo owner/name] [--cwd <dir>]
|
|
129
|
+
starci app ci verify [--json] [--repo owner/name] [--cwd <dir>]
|
|
119
130
|
starci app hygiene [--cwd <dir>]
|
|
120
131
|
`;
|
|
121
132
|
const PER_CODE_LIMIT = 25;
|
|
@@ -318,7 +329,7 @@ async function installedStackMain(cwd) {
|
|
|
318
329
|
function databaseMain(argv, io) {
|
|
319
330
|
const [verb, ...rest] = argv;
|
|
320
331
|
const dispatch = DATABASE_VERBS.get(verb);
|
|
321
|
-
if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate
|
|
332
|
+
if (!dispatch) throw new Error('starci app db takes baseline, restamp, plan, migrate, post-cutover, dump or reset');
|
|
322
333
|
return dispatch(rest, io);
|
|
323
334
|
}
|
|
324
335
|
|
|
@@ -408,7 +419,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
|
|
|
408
419
|
return run(argv, { cwd, out, err });
|
|
409
420
|
}
|
|
410
421
|
|
|
411
|
-
const DIRECT_VERBS = new Map([['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
|
|
422
|
+
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain]]);
|
|
412
423
|
const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
|
|
413
424
|
|
|
414
425
|
function dispatchMain(verb, argv, io) {
|
package/stack/context.mjs
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
// context.mjs - the seams every stack module runs through: spawning tools, sleeping, the clock and the output.
|
|
2
2
|
// Production uses the real implementations; a spec injects fakes.
|
|
3
|
-
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
4
4
|
import { defaultSops } from '../runtime/scripts/hfs/secret-sops.mjs';
|
|
5
5
|
|
|
6
6
|
const MAX_BUFFER = 1 << 24;
|
|
7
7
|
|
|
8
8
|
const realSpawn = (command, args, options = {}) => {
|
|
9
|
+
if (options.background) {
|
|
10
|
+
const childOptions = { ...options };
|
|
11
|
+
delete childOptions.background;
|
|
12
|
+
const child = spawn(command, args, { stdio: 'ignore', windowsHide: true, ...childOptions });
|
|
13
|
+
child.on('error', (error) => { child.spawnError = error; });
|
|
14
|
+
return child;
|
|
15
|
+
}
|
|
9
16
|
const run = spawnSync(command, args, { encoding: 'utf8', maxBuffer: MAX_BUFFER, ...options });
|
|
10
17
|
return { status: run.status, stdout: run.stdout ?? '', stderr: run.stderr ?? '', error: run.error };
|
|
11
18
|
};
|
|
@@ -16,6 +23,8 @@ const realSleep = (ms) => new Promise((done) => { setTimeout(done, ms); });
|
|
|
16
23
|
* The context of one stack invocation.
|
|
17
24
|
* cwd, env the app root and the process environment
|
|
18
25
|
* spawn (command, args, { input, env, cwd }) -> { status, stdout, stderr, error }; never a shell
|
|
26
|
+
* { background: true } -> ChildProcess, owned and killed by the command session
|
|
27
|
+
* platform POSIX control master or win32 loopback tunnel; sshPath optionally selects the POSIX SSH executable
|
|
19
28
|
* sleep, now await sleep(ms); now() -> epoch ms
|
|
20
29
|
* stdout, stderr text writers
|
|
21
30
|
* fetch the HTTP seam of verify (url, init) -> Response
|
|
@@ -29,6 +38,9 @@ export function stackContext(io = {}) {
|
|
|
29
38
|
cwd,
|
|
30
39
|
env,
|
|
31
40
|
spawn: io.spawn ?? realSpawn,
|
|
41
|
+
platform: io.platform ?? process.platform,
|
|
42
|
+
username: io.username,
|
|
43
|
+
sshPath: io.sshPath,
|
|
32
44
|
sleep: io.sleep ?? realSleep,
|
|
33
45
|
now: io.now ?? Date.now,
|
|
34
46
|
stdout: io.stdout ?? ((text) => process.stdout.write(text)),
|