@intentius/chant 0.78.0 → 0.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +38 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +14 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +27 -2
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +17 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +17 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +9 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +18 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +211 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/unbound-gate-approval.d.ts +24 -0
- package/dist/components/unbound-gate-approval.d.ts.map +1 -0
- package/dist/config.d.ts +82 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/discovery/convergence.d.ts +97 -0
- package/dist/discovery/convergence.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +41 -2
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -0
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +13 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +65 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +24 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +22 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +26 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +25 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +2 -1
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops015-gate-approval.d.ts +14 -0
- package/dist/lint/rules/op/ops015-gate-approval.d.ts.map +1 -0
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/builders.d.ts +4 -0
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts +142 -0
- package/dist/op/gate-approval.d.ts.map +1 -0
- package/dist/op/gate.d.ts +54 -0
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -1
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +4 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +2 -0
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +57 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +90 -12
- package/src/audit/terraform-state.test.ts +86 -1
- package/src/audit/terraform-state.ts +26 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-walk-warnings.test.ts +156 -0
- package/src/cli/commands/audit.ts +93 -19
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/doctor.ts +3 -0
- package/src/cli/commands/import-agents.ts +11 -4
- package/src/cli/commands/init.ts +57 -25
- package/src/cli/commands/lint.test.ts +58 -1
- package/src/cli/commands/lint.ts +48 -18
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +5 -1
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +1 -1
- package/src/cli/handlers/dev.ts +3 -0
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +85 -0
- package/src/cli/handlers/operator.ts +118 -2
- package/src/cli/handlers/promote.test.ts +261 -0
- package/src/cli/handlers/promote.ts +325 -0
- package/src/cli/handlers/run-generate.test.ts +160 -0
- package/src/cli/handlers/run-generate.ts +133 -0
- package/src/cli/handlers/run.ts +13 -1
- package/src/cli/main.test.ts +33 -1
- package/src/cli/main.ts +78 -16
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +44 -6
- package/src/cli/registry.ts +18 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +3 -2
- package/src/components/discover.ts +26 -2
- package/src/components/driver.test.ts +12 -11
- package/src/components/driver.ts +2 -0
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +371 -0
- package/src/components/promote.ts +537 -0
- package/src/components/unbound-gate-approval.test.ts +82 -0
- package/src/components/unbound-gate-approval.ts +51 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +128 -11
- package/src/discovery/convergence.test.ts +231 -0
- package/src/discovery/convergence.ts +399 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +97 -9
- package/src/discovery/fold-import.ts +83 -9
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/graph-ir.ts +14 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +117 -0
- package/src/lexicon.ts +26 -0
- package/src/lifecycle/build-ledger-store.ts +11 -1
- package/src/lifecycle/gate-ledger.ts +22 -0
- package/src/lifecycle/legacy-digest.test.ts +114 -0
- package/src/lifecycle/legacy-digest.ts +62 -0
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/lint/rules/op/index.ts +4 -2
- package/src/lint/rules/op/ops015-gate-approval.test.ts +67 -0
- package/src/lint/rules/op/ops015-gate-approval.ts +57 -0
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/builders.ts +10 -1
- package/src/op/discover-convergence.test.ts +70 -0
- package/src/op/discover.ts +4 -0
- package/src/op/gate-approval.test.ts +258 -0
- package/src/op/gate-approval.ts +260 -0
- package/src/op/gate.ts +148 -10
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/index.ts +9 -1
- package/src/op/local-executor.test.ts +66 -0
- package/src/op/local-executor.ts +44 -1
- package/src/op/op-ir.ts +4 -0
- package/src/op/runtimes/local.ts +2 -1
- package/src/op/types.ts +7 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +126 -0
- package/src/workspace/records-contract.test.ts +88 -0
- package/src/workspace/records.schema.json +120 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +383 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant components promote --from <env> --to <env>` (#2530) and `chant
|
|
3
|
+
* components rollback <env>` (#2531): deploy digests a release ledger already
|
|
4
|
+
* records, without a build. A promote takes them from another environment's
|
|
5
|
+
* ledger; a rollback takes an earlier release from the environment's own. The
|
|
6
|
+
* mechanism lives in ../../components/promote.ts; these handlers read the
|
|
7
|
+
* ledger, print the plan, run it, and append the release records.
|
|
8
|
+
*
|
|
9
|
+
* Every component that deployed gets a record, even when a later component
|
|
10
|
+
* failed or stopped at a gate, so the ledger never misses a deploy that
|
|
11
|
+
* happened. Unlike an auto-recorded release, the record is what these
|
|
12
|
+
* commands are for, so a failed write is an error rather than a warning.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { resolve } from "node:path";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { loadChantConfig, type ChantConfig } from "../../config";
|
|
18
|
+
import { fetchLifecycle, getHeadCommit, pushLifecycle } from "../../lifecycle/git";
|
|
19
|
+
import {
|
|
20
|
+
appendReleaseRecord,
|
|
21
|
+
readReleaseLedger,
|
|
22
|
+
resolveRunId,
|
|
23
|
+
type ReleaseRecord,
|
|
24
|
+
type ReleaseRecordInput,
|
|
25
|
+
} from "../../lifecycle/release-ledger";
|
|
26
|
+
import { resolveComponentTargets } from "../../components/cli-support";
|
|
27
|
+
import { applyConfigDefaults } from "../../components/config-defaults";
|
|
28
|
+
import { fanOutRegistry } from "../../components/fan-out-support";
|
|
29
|
+
import { renderDriverHuman } from "../../components/driver-output";
|
|
30
|
+
import { ndjsonProgressSink } from "../../components/run-progress";
|
|
31
|
+
import {
|
|
32
|
+
planPromotion,
|
|
33
|
+
planRollback,
|
|
34
|
+
withoutBuildSteps,
|
|
35
|
+
runPromotion,
|
|
36
|
+
promotionRecord,
|
|
37
|
+
rollbackRecord,
|
|
38
|
+
gateApprover,
|
|
39
|
+
type DeployRunInfo,
|
|
40
|
+
type PromotionItem,
|
|
41
|
+
type PromotionPlan,
|
|
42
|
+
} from "../../components/promote";
|
|
43
|
+
import type { DriverComponent } from "../../components/driver";
|
|
44
|
+
import { writeGatedRunSummary } from "../../op/gate-summary";
|
|
45
|
+
import { approveCommand } from "../../op/gate";
|
|
46
|
+
import { resolveCliBuildParams, parseParamFlags } from "../build-params-cli";
|
|
47
|
+
import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
|
|
48
|
+
import { GATED_EXIT_CODE } from "./run";
|
|
49
|
+
import type { CommandContext } from "../registry";
|
|
50
|
+
|
|
51
|
+
const PROMOTE_USAGE =
|
|
52
|
+
"chant components promote --from <env> --to <env> [--component <name> [--digest <sha256:...>]] [--dry-run] [--json]";
|
|
53
|
+
const ROLLBACK_USAGE =
|
|
54
|
+
"chant components rollback <env> --component <name> [--digest <sha256:...>] [--dry-run] [--json]";
|
|
55
|
+
|
|
56
|
+
type Verb = "promote" | "rollback";
|
|
57
|
+
|
|
58
|
+
function renderPlan(verb: Verb, plan: PromotionPlan, removed: Map<string, string[]>): void {
|
|
59
|
+
console.error(formatBold(verb === "promote" ? `promote ${plan.from} -> ${plan.to}` : `rollback ${plan.to}`));
|
|
60
|
+
for (const item of plan.items) {
|
|
61
|
+
console.error(` ${item.component} ${item.digest}`);
|
|
62
|
+
console.error(` released to ${plan.from} at ${item.source.timestamp} (run ${item.source.runId}, git ${item.source.gitSha.slice(0, 12)})`);
|
|
63
|
+
const kinds = removed.get(item.component) ?? [];
|
|
64
|
+
if (kinds.length > 0) console.error(` not run: ${kinds.join(", ")}`);
|
|
65
|
+
}
|
|
66
|
+
for (const skipped of plan.notPromoted) {
|
|
67
|
+
console.error(` ${skipped.component} not promoted: ${skipped.reason}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function planJson(plan: PromotionPlan, removed: Map<string, string[]>) {
|
|
72
|
+
return {
|
|
73
|
+
from: plan.from,
|
|
74
|
+
to: plan.to,
|
|
75
|
+
items: plan.items.map((i) => ({
|
|
76
|
+
component: i.component,
|
|
77
|
+
digest: i.digest,
|
|
78
|
+
source: i.source,
|
|
79
|
+
notRun: removed.get(i.component) ?? [],
|
|
80
|
+
})),
|
|
81
|
+
notPromoted: plan.notPromoted,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function resolveActor(ctx: CommandContext): string | undefined {
|
|
86
|
+
return ctx.args.actor ?? process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Config, params and the declared components, or the exit code of a refusal already printed. */
|
|
90
|
+
async function loadProject(ctx: CommandContext, selector: string) {
|
|
91
|
+
const { args } = ctx;
|
|
92
|
+
const projectPath = resolve(".");
|
|
93
|
+
const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
|
|
94
|
+
const paramsResolution = resolveCliBuildParams(config.buildParams, {
|
|
95
|
+
cli: parseParamFlags(args.param),
|
|
96
|
+
paramsFile: args.paramsFile,
|
|
97
|
+
verbose: args.verbose,
|
|
98
|
+
});
|
|
99
|
+
if (!paramsResolution.success) {
|
|
100
|
+
for (const message of paramsResolution.errors) console.error(message);
|
|
101
|
+
return 1;
|
|
102
|
+
}
|
|
103
|
+
const targets = await resolveComponentTargets(projectPath, selector, args.sandbox, paramsResolution.provenance);
|
|
104
|
+
if (!targets.success) {
|
|
105
|
+
console.error(formatError({ message: targets.error ?? "Could not discover components" }));
|
|
106
|
+
return 1;
|
|
107
|
+
}
|
|
108
|
+
return { projectPath, config, targets: targets.targets };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readLedger(env: string): Promise<ReleaseRecord[]> {
|
|
112
|
+
const ledger = await readReleaseLedger(env);
|
|
113
|
+
if (ledger.malformed > 0) {
|
|
114
|
+
console.error(formatWarning({ message: `${ledger.malformed} malformed line(s) in the "${env}" release ledger were skipped` }));
|
|
115
|
+
}
|
|
116
|
+
return ledger.records;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Prepare, run and record a plan. Shared by promote and rollback, which differ
|
|
121
|
+
* only in how the plan was chosen, whether the publish step runs, and which
|
|
122
|
+
* field names the earlier release on the new record.
|
|
123
|
+
*/
|
|
124
|
+
async function deployPlan(
|
|
125
|
+
ctx: CommandContext,
|
|
126
|
+
verb: Verb,
|
|
127
|
+
plan: PromotionPlan,
|
|
128
|
+
project: { projectPath: string; config: ChantConfig; targets: DriverComponent[] },
|
|
129
|
+
actor: string | undefined,
|
|
130
|
+
record: (item: PromotionItem, env: string, run: DeployRunInfo) => ReleaseRecordInput,
|
|
131
|
+
): Promise<number> {
|
|
132
|
+
const { args } = ctx;
|
|
133
|
+
const byName = new Map(project.targets.map((c) => [c.name, applyConfigDefaults(c, project.config)]));
|
|
134
|
+
const prepared: DriverComponent[] = [];
|
|
135
|
+
const removed = new Map<string, string[]>();
|
|
136
|
+
const refusals: string[] = [];
|
|
137
|
+
for (const item of plan.items) {
|
|
138
|
+
const result = withoutBuildSteps(byName.get(item.component)!, verb === "rollback" ? { pinDigest: item.digest } : {});
|
|
139
|
+
if ("error" in result) refusals.push(result.error);
|
|
140
|
+
else {
|
|
141
|
+
prepared.push(result.component);
|
|
142
|
+
removed.set(item.component, result.removed);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (refusals.length > 0) {
|
|
146
|
+
for (const message of refusals) console.error(formatError({ message }));
|
|
147
|
+
console.error(formatError({ message: `nothing was deployed` }));
|
|
148
|
+
return 1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// The composition deployed is this checkout's. Say so when it is not the
|
|
152
|
+
// commit the artifact was built from.
|
|
153
|
+
const head = await getHeadCommit().catch(() => undefined);
|
|
154
|
+
for (const item of plan.items) {
|
|
155
|
+
if (head && head !== item.source.gitSha) {
|
|
156
|
+
console.error(formatWarning({
|
|
157
|
+
message: `${item.component}: the artifact was built from ${item.source.gitSha.slice(0, 12)}, ` +
|
|
158
|
+
`but its deploy steps come from this checkout at ${head.slice(0, 12)}`,
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (args.dryRun) {
|
|
164
|
+
if (args.json) console.log(JSON.stringify(planJson(plan, removed), null, 2));
|
|
165
|
+
else renderPlan(verb, plan, removed);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
if (!args.json) renderPlan(verb, plan, removed);
|
|
169
|
+
|
|
170
|
+
const seededOutputs: Record<string, Record<string, unknown>> = {};
|
|
171
|
+
for (const file of args.seedOutputs ?? []) {
|
|
172
|
+
try {
|
|
173
|
+
Object.assign(seededOutputs, JSON.parse(readFileSync(resolve(file), "utf8")));
|
|
174
|
+
} catch (err) {
|
|
175
|
+
console.error(formatError({ message: `--seed-outputs: could not read "${file}": ${err instanceof Error ? err.message : String(err)}` }));
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const registry = await fanOutRegistry(project.projectPath, project.config);
|
|
181
|
+
const run = await runPromotion({
|
|
182
|
+
plan,
|
|
183
|
+
components: prepared,
|
|
184
|
+
registry,
|
|
185
|
+
componentOutputs: seededOutputs,
|
|
186
|
+
...(args.progressJson ? { onProgress: ndjsonProgressSink() } : {}),
|
|
187
|
+
});
|
|
188
|
+
if (!args.json) renderDriverHuman(run);
|
|
189
|
+
|
|
190
|
+
// Record every component that deployed, whatever happened to the rest: a
|
|
191
|
+
// component that reached the environment and is missing from its ledger
|
|
192
|
+
// would be exactly the unrecorded deploy the ledger exists to rule out.
|
|
193
|
+
const deployed = plan.items.filter((item) => run.results.some((r) => r.component === item.component && r.status === "ok"));
|
|
194
|
+
const { runId, runOrigin } = resolveRunId(args.runId);
|
|
195
|
+
const timestamp = new Date().toISOString();
|
|
196
|
+
const recorded: ReleaseRecord[] = [];
|
|
197
|
+
let recordError: string | undefined;
|
|
198
|
+
if (deployed.length > 0) {
|
|
199
|
+
try {
|
|
200
|
+
for (const item of deployed) {
|
|
201
|
+
const approver = gateApprover(run.results.find((r) => r.component === item.component));
|
|
202
|
+
const { record: written } = await appendReleaseRecord(
|
|
203
|
+
record(item, plan.to, { runId, ...(runOrigin ? { runOrigin } : {}), actor: actor!, timestamp, ...(approver ? { approver } : {}) }),
|
|
204
|
+
);
|
|
205
|
+
recorded.push(written);
|
|
206
|
+
}
|
|
207
|
+
await pushLifecycle();
|
|
208
|
+
} catch (err) {
|
|
209
|
+
recordError = err instanceof Error ? err.message : String(err);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (args.json) {
|
|
214
|
+
console.log(JSON.stringify({ ...planJson(plan, removed), run, recorded }, null, 2));
|
|
215
|
+
} else {
|
|
216
|
+
for (const r of recorded) {
|
|
217
|
+
console.error(formatSuccess(
|
|
218
|
+
verb === "promote"
|
|
219
|
+
? `Promoted ${formatBold(r.component)} ${plan.from} -> ${plan.to}: ${r.digest}`
|
|
220
|
+
: `Rolled back ${formatBold(r.component)} in ${plan.to} to ${r.digest}`,
|
|
221
|
+
));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (recordError !== undefined) {
|
|
226
|
+
console.error(formatError({
|
|
227
|
+
message: `deployed to "${plan.to}", but the release record was not written: ${recordError}`,
|
|
228
|
+
hint: `Pull the chant/lifecycle branch and check \`chant components status ${plan.to}\` before running this again.`,
|
|
229
|
+
}));
|
|
230
|
+
return 1;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (run.status === "gated" && run.gate) {
|
|
234
|
+
const pending = run.gate;
|
|
235
|
+
console.error(formatWarning({ message: `component "${run.gatedComponent}" is gated on "${pending.gate}" — pending approval` }));
|
|
236
|
+
console.error(formatInfo(`approve : ${approveCommand(pending.op, pending.gate)}`));
|
|
237
|
+
console.error(formatInfo(`then run the same ${verb} again`));
|
|
238
|
+
writeGatedRunSummary({
|
|
239
|
+
op: pending.op,
|
|
240
|
+
gate: pending.gate,
|
|
241
|
+
...(pending.description ? { description: pending.description } : {}),
|
|
242
|
+
expiresAt: pending.expiresAt,
|
|
243
|
+
...(pending.url ? { url: pending.url } : {}),
|
|
244
|
+
...(pending.planDigest ? { planDigest: pending.planDigest } : {}),
|
|
245
|
+
});
|
|
246
|
+
return GATED_EXIT_CODE;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (run.status !== "ok") {
|
|
250
|
+
console.error(formatError({ message: `${verb} failed at "${run.failedComponent ?? "unknown"}"` }));
|
|
251
|
+
return 1;
|
|
252
|
+
}
|
|
253
|
+
return 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Refuse before anything deploys when nobody can be named on the record. */
|
|
257
|
+
function actorOrRefuse(ctx: CommandContext): { actor: string | undefined } | number {
|
|
258
|
+
const actor = resolveActor(ctx);
|
|
259
|
+
if (!actor && !ctx.args.dryRun) {
|
|
260
|
+
console.error(formatError({
|
|
261
|
+
message: "Could not resolve --actor from the environment",
|
|
262
|
+
hint: "Pass --actor explicitly, or set GITHUB_ACTOR / GITLAB_USER_LOGIN / USER.",
|
|
263
|
+
}));
|
|
264
|
+
return 1;
|
|
265
|
+
}
|
|
266
|
+
return { actor };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function runComponentsPromote(ctx: CommandContext): Promise<number> {
|
|
270
|
+
const { args } = ctx;
|
|
271
|
+
const from = args.migrateFrom;
|
|
272
|
+
const to = args.migrateTo;
|
|
273
|
+
if (!from || !to) {
|
|
274
|
+
console.error(formatError({ message: "--from <env> and --to <env> are required", hint: PROMOTE_USAGE }));
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
const who = actorOrRefuse(ctx);
|
|
278
|
+
if (typeof who === "number") return who;
|
|
279
|
+
|
|
280
|
+
const project = await loadProject(ctx, args.component ?? "all");
|
|
281
|
+
if (typeof project === "number") return project;
|
|
282
|
+
|
|
283
|
+
await fetchLifecycle().catch(() => false);
|
|
284
|
+
const plan = planPromotion({
|
|
285
|
+
from,
|
|
286
|
+
to,
|
|
287
|
+
sourceRecords: await readLedger(from),
|
|
288
|
+
declared: project.targets.map((c) => c.name),
|
|
289
|
+
...(args.component ? { component: args.component } : {}),
|
|
290
|
+
...(args.digest ? { digest: args.digest } : {}),
|
|
291
|
+
});
|
|
292
|
+
if ("error" in plan) {
|
|
293
|
+
console.error(formatError({ message: plan.error, hint: PROMOTE_USAGE }));
|
|
294
|
+
return 1;
|
|
295
|
+
}
|
|
296
|
+
return deployPlan(ctx, "promote", plan, project, who.actor, promotionRecord);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export async function runComponentsRollback(ctx: CommandContext): Promise<number> {
|
|
300
|
+
const { args } = ctx;
|
|
301
|
+
const env = args.extraPositional;
|
|
302
|
+
if (!env || !args.component) {
|
|
303
|
+
console.error(formatError({ message: "an environment and --component <name> are required", hint: ROLLBACK_USAGE }));
|
|
304
|
+
return 1;
|
|
305
|
+
}
|
|
306
|
+
const who = actorOrRefuse(ctx);
|
|
307
|
+
if (typeof who === "number") return who;
|
|
308
|
+
|
|
309
|
+
const project = await loadProject(ctx, args.component);
|
|
310
|
+
if (typeof project === "number") return project;
|
|
311
|
+
|
|
312
|
+
await fetchLifecycle().catch(() => false);
|
|
313
|
+
const plan = planRollback({
|
|
314
|
+
env,
|
|
315
|
+
records: await readLedger(env),
|
|
316
|
+
declared: project.targets.map((c) => c.name),
|
|
317
|
+
component: args.component,
|
|
318
|
+
...(args.digest ? { digest: args.digest } : {}),
|
|
319
|
+
});
|
|
320
|
+
if ("error" in plan) {
|
|
321
|
+
console.error(formatError({ message: plan.error, hint: ROLLBACK_USAGE }));
|
|
322
|
+
return 1;
|
|
323
|
+
}
|
|
324
|
+
return deployPlan(ctx, "rollback", plan, project, who.actor, rollbackRecord);
|
|
325
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant run --generate <provider>` (#2533). Discovery is stubbed so the
|
|
3
|
+
* scheduled Ops are fixed; the github, gitlab and forgejo lexicon plugins are
|
|
4
|
+
* the real ones, so "identical to calling `generateOpsPipeline` directly" is
|
|
5
|
+
* checked against the real generators.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
9
|
+
import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { ParsedArgs } from "../registry";
|
|
13
|
+
import type { OpConfig } from "../../op/types";
|
|
14
|
+
|
|
15
|
+
const discovered = new Map<string, { config: Partial<OpConfig>; filePath: string; exportName: string }>();
|
|
16
|
+
|
|
17
|
+
vi.mock("../../op/discover", () => ({
|
|
18
|
+
discoverOps: async () => ({ ops: discovered, errors: [] }),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
const { runOp } = await import("./run");
|
|
22
|
+
const { generateOpsPipeline } = await import("../../op/generate-pipeline");
|
|
23
|
+
const { GENERATED_MARKER } = await import("../../discovery/files");
|
|
24
|
+
const { parseArgs } = await import("../main");
|
|
25
|
+
|
|
26
|
+
function ctx(argv: string[]) {
|
|
27
|
+
const args = parseArgs(argv) as ParsedArgs;
|
|
28
|
+
return { args, plugins: [], serializers: [] };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let dir: string;
|
|
32
|
+
let errSpy: ReturnType<typeof vi.spyOn>;
|
|
33
|
+
let logSpy: ReturnType<typeof vi.spyOn>;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
dir = mkdtempSync(join(tmpdir(), "chant-run-generate-"));
|
|
37
|
+
discovered.clear();
|
|
38
|
+
discovered.set("nightly-audit", {
|
|
39
|
+
config: { name: "nightly-audit", phases: [], schedule: { cron: "0 6 * * *" } } as Partial<OpConfig>,
|
|
40
|
+
filePath: "nightly-audit.op.ts",
|
|
41
|
+
exportName: "default",
|
|
42
|
+
});
|
|
43
|
+
discovered.set("weekly-report", {
|
|
44
|
+
config: { name: "weekly-report", phases: [], schedule: { cron: "0 7 * * 1" } } as Partial<OpConfig>,
|
|
45
|
+
filePath: "weekly-report.op.ts",
|
|
46
|
+
exportName: "default",
|
|
47
|
+
});
|
|
48
|
+
discovered.set("deploy", {
|
|
49
|
+
config: { name: "deploy", phases: [] } as Partial<OpConfig>,
|
|
50
|
+
filePath: "deploy.op.ts",
|
|
51
|
+
exportName: "default",
|
|
52
|
+
});
|
|
53
|
+
errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
54
|
+
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
errSpy.mockRestore();
|
|
59
|
+
logSpy.mockRestore();
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("chant run --generate <provider>", () => {
|
|
64
|
+
test.each(["github", "gitlab", "forgejo"])(
|
|
65
|
+
"%s: each file is the generator's own output under one marker line",
|
|
66
|
+
async (provider) => {
|
|
67
|
+
const out = join(dir, provider);
|
|
68
|
+
expect(await runOp(ctx(["run", "--generate", provider, "--output", out]))).toBe(0);
|
|
69
|
+
|
|
70
|
+
const direct = await generateOpsPipeline([{ name: "nightly-audit" }, { name: "weekly-report" }], provider);
|
|
71
|
+
expect(direct.success).toBe(true);
|
|
72
|
+
const header = `# ${GENERATED_MARKER}. Regenerate with: chant run --generate ${provider}\n`;
|
|
73
|
+
|
|
74
|
+
expect(readdirSync(out).sort()).toEqual(direct.files!.map((f) => f.name).sort());
|
|
75
|
+
for (const file of direct.files!) {
|
|
76
|
+
expect(readFileSync(join(out, file.name), "utf-8")).toBe(header + file.yaml);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
test("github and forgejo get one file per scheduled Op; an Op with no schedule is left out", async () => {
|
|
82
|
+
for (const provider of ["github", "forgejo"]) {
|
|
83
|
+
const out = join(dir, provider);
|
|
84
|
+
expect(await runOp(ctx(["run", "--generate", provider, "--output", out]))).toBe(0);
|
|
85
|
+
expect(readdirSync(out).sort()).toEqual(["nightly-audit.yml", "weekly-report.yml"]);
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("each github workflow is named after its Op (#2580); forgejo workflows carry no name", async () => {
|
|
90
|
+
for (const provider of ["github", "forgejo"]) {
|
|
91
|
+
expect(await runOp(ctx(["run", "--generate", provider, "--output", join(dir, provider)]))).toBe(0);
|
|
92
|
+
}
|
|
93
|
+
const header = `# ${GENERATED_MARKER}. Regenerate with: chant run --generate github\n`;
|
|
94
|
+
for (const op of ["nightly-audit", "weekly-report"]) {
|
|
95
|
+
const github = readFileSync(join(dir, "github", `${op}.yml`), "utf-8");
|
|
96
|
+
expect(github.startsWith(`${header}name: ${op}\n\non:\n`)).toBe(true);
|
|
97
|
+
const forgejo = readFileSync(join(dir, "forgejo", `${op}.yml`), "utf-8");
|
|
98
|
+
expect(forgejo).not.toMatch(/^name:/m);
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("--spec passes its ops and options to the generator unchanged", async () => {
|
|
103
|
+
const specFile = join(dir, "ops.json");
|
|
104
|
+
const spec = {
|
|
105
|
+
ops: [
|
|
106
|
+
{ name: "deploy", trigger: { kind: "pull_request", branches: ["main"] }, findingMode: "comment" },
|
|
107
|
+
{ name: "nightly-audit" },
|
|
108
|
+
],
|
|
109
|
+
options: { opsStage: "chant" },
|
|
110
|
+
};
|
|
111
|
+
writeFileSync(specFile, JSON.stringify(spec));
|
|
112
|
+
const out = join(dir, "gitlab");
|
|
113
|
+
expect(await runOp(ctx(["run", "--generate", "gitlab", "--spec", specFile, "--output", out]))).toBe(0);
|
|
114
|
+
|
|
115
|
+
const direct = await generateOpsPipeline(spec.ops as never, "gitlab", spec.options);
|
|
116
|
+
expect(direct.files!.map((f) => f.name)).toEqual(["chant.gitlab-ci.yml"]);
|
|
117
|
+
const written = readFileSync(join(out, "chant.gitlab-ci.yml"), "utf-8");
|
|
118
|
+
expect(written).toBe(
|
|
119
|
+
`# ${GENERATED_MARKER}. Regenerate with: chant run --generate gitlab --spec ${specFile}\n` + direct.files![0].yaml,
|
|
120
|
+
);
|
|
121
|
+
expect(written).toContain('$CI_PIPELINE_SOURCE == "merge_request_event"');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("--format json prints the files and jobs and writes nothing", async () => {
|
|
125
|
+
const out = join(dir, "none");
|
|
126
|
+
expect(await runOp(ctx(["run", "--generate", "github", "--output", out, "--format", "json"]))).toBe(0);
|
|
127
|
+
const printed = JSON.parse(String(logSpy.mock.calls[0][0]));
|
|
128
|
+
expect(printed.files.map((f: { path: string }) => f.path)).toEqual([
|
|
129
|
+
join(out, "nightly-audit.yml"),
|
|
130
|
+
join(out, "weekly-report.yml"),
|
|
131
|
+
]);
|
|
132
|
+
expect(printed.files[0].content.startsWith(`# ${GENERATED_MARKER}`)).toBe(true);
|
|
133
|
+
expect(printed.jobs).toHaveLength(2);
|
|
134
|
+
expect(() => readdirSync(out)).toThrow();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("with no scheduled Op and no --spec, it fails and says what to do", async () => {
|
|
138
|
+
discovered.delete("nightly-audit");
|
|
139
|
+
discovered.delete("weekly-report");
|
|
140
|
+
expect(await runOp(ctx(["run", "--generate", "github", "--output", dir]))).toBe(1);
|
|
141
|
+
expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/No Op declares a schedule/);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a provider with no Op generator fails by name", async () => {
|
|
145
|
+
expect(await runOp(ctx(["run", "--generate", "aws", "--output", dir]))).toBe(1);
|
|
146
|
+
expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/does not support Op generate mode/);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("a --spec entry without a name is refused", async () => {
|
|
150
|
+
const specFile = join(dir, "bad.json");
|
|
151
|
+
writeFileSync(specFile, JSON.stringify([{ trigger: { kind: "push" } }]));
|
|
152
|
+
expect(await runOp(ctx(["run", "--generate", "github", "--spec", specFile, "--output", dir]))).toBe(1);
|
|
153
|
+
expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/entry 0 has no "name"/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("--components with --generate on run is refused", async () => {
|
|
157
|
+
expect(await runOp(ctx(["run", "--components", "--generate", "github"]))).toBe(1);
|
|
158
|
+
expect(String(errSpy.mock.calls.flat().join("\n"))).toMatch(/build --components --generate/);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant run --generate <provider>` (#2533): the CLI for `generateOpsPipeline`
|
|
3
|
+
* (../../op/generate-pipeline.ts), the Op counterpart to `chant build
|
|
4
|
+
* --components --generate <lexicon>`.
|
|
5
|
+
*
|
|
6
|
+
* The generator is a library function with no caller of its own. A pipeline
|
|
7
|
+
* committed to a repository needs a named command that regenerates it, so a
|
|
8
|
+
* drift check (#2524 D14) can rerun that command and compare. This handler is
|
|
9
|
+
* that command and adds nothing to the generator's output beyond one header
|
|
10
|
+
* line: every file it writes is the generator's own `yaml`, byte for byte,
|
|
11
|
+
* under a first line carrying {@link GENERATED_MARKER} and the command that
|
|
12
|
+
* produced it.
|
|
13
|
+
*
|
|
14
|
+
* Where the specs come from:
|
|
15
|
+
*
|
|
16
|
+
* - by default, every discovered Op that declares its own `schedule`, as a
|
|
17
|
+
* bare `{ name }` spec. `generateOpsPipeline` fills the cron in from the Op
|
|
18
|
+
* (`withOpSchedules`), so the result is exactly what a direct call with
|
|
19
|
+
* those names returns;
|
|
20
|
+
* - with `--spec <file>`, a JSON file holding either a `ScheduledOpSpec[]`
|
|
21
|
+
* or `{ ops, options }`, for Ops triggered by a pull request or a push, a
|
|
22
|
+
* finding mode, setup steps, or generator options. The file is passed to
|
|
23
|
+
* the generator unchanged.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { dirname, join, resolve } from "node:path";
|
|
28
|
+
import { discoverOps } from "../../op/discover";
|
|
29
|
+
import { generateOpsPipeline } from "../../op/generate-pipeline";
|
|
30
|
+
import { GENERATED_MARKER } from "../../discovery/files";
|
|
31
|
+
import type { ComponentPipelineOptions, ScheduledOpSpec } from "../../lexicon";
|
|
32
|
+
import { formatBold, formatError, formatSuccess } from "../format";
|
|
33
|
+
import type { CommandContext } from "../registry";
|
|
34
|
+
|
|
35
|
+
/** Where each forge reads its pipeline files from, relative to the repository root. `--output` overrides it. */
|
|
36
|
+
export const DEFAULT_OP_PIPELINE_DIRS: Record<string, string> = {
|
|
37
|
+
github: ".github/workflows",
|
|
38
|
+
forgejo: ".forgejo/workflows",
|
|
39
|
+
gitlab: ".",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** The first line of every file this command writes. */
|
|
43
|
+
export function opPipelineHeader(provider: string, specFile?: string): string {
|
|
44
|
+
const command = `chant run --generate ${provider}${specFile ? ` --spec ${specFile}` : ""}`;
|
|
45
|
+
return `# ${GENERATED_MARKER}. Regenerate with: ${command}\n`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface SpecFile {
|
|
49
|
+
ops: ScheduledOpSpec[];
|
|
50
|
+
options?: ComponentPipelineOptions;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Read `--spec`: a `ScheduledOpSpec[]`, or `{ ops, options }`. Throws with the file named on anything else. */
|
|
54
|
+
export function readOpSpecFile(path: string): SpecFile {
|
|
55
|
+
let parsed: unknown;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(readFileSync(path, "utf-8"));
|
|
58
|
+
} catch (err) {
|
|
59
|
+
throw new Error(`--spec ${path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
60
|
+
}
|
|
61
|
+
const shape = "an array of Op specs, or { \"ops\": [...], \"options\": {...} }";
|
|
62
|
+
const ops = Array.isArray(parsed) ? parsed : (parsed as { ops?: unknown } | null)?.ops;
|
|
63
|
+
if (!Array.isArray(ops)) throw new Error(`--spec ${path} must hold ${shape}`);
|
|
64
|
+
for (const [i, spec] of ops.entries()) {
|
|
65
|
+
if (typeof (spec as { name?: unknown })?.name !== "string") {
|
|
66
|
+
throw new Error(`--spec ${path}: entry ${i} has no "name". Each entry names the Op it generates a pipeline for.`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const options = Array.isArray(parsed) ? undefined : (parsed as { options?: ComponentPipelineOptions }).options;
|
|
70
|
+
return { ops: ops as ScheduledOpSpec[], ...(options ? { options } : {}) };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function runOpGenerate(ctx: CommandContext): Promise<number> {
|
|
74
|
+
const { args } = ctx;
|
|
75
|
+
const provider = args.generate as string;
|
|
76
|
+
const specPath = args.opsSpec;
|
|
77
|
+
|
|
78
|
+
let ops: ScheduledOpSpec[];
|
|
79
|
+
let options: ComponentPipelineOptions | undefined;
|
|
80
|
+
if (specPath) {
|
|
81
|
+
try {
|
|
82
|
+
({ ops, options } = readOpSpecFile(resolve(specPath)));
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
|
|
85
|
+
return 1;
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const discovered = await discoverOps();
|
|
89
|
+
if (discovered.errors.length > 0) {
|
|
90
|
+
for (const e of discovered.errors) console.error(formatError({ message: e }));
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
ops = [...discovered.ops.entries()]
|
|
94
|
+
.filter(([, op]) => op.config.schedule)
|
|
95
|
+
.map(([name]) => name)
|
|
96
|
+
.sort()
|
|
97
|
+
.map((name) => ({ name }));
|
|
98
|
+
if (ops.length === 0) {
|
|
99
|
+
console.error(formatError({
|
|
100
|
+
message: "No Op declares a schedule, so there is nothing to generate",
|
|
101
|
+
hint: "Give an Op a `schedule`, or pass --spec <file.json> naming the Ops and their triggers.",
|
|
102
|
+
}));
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const result = await generateOpsPipeline(ops, provider, options);
|
|
108
|
+
if (!result.success) {
|
|
109
|
+
console.error(formatError({ message: result.error ?? "Failed to generate Op pipelines" }));
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const header = opPipelineHeader(provider, specPath);
|
|
114
|
+
const outDir = args.output ?? DEFAULT_OP_PIPELINE_DIRS[provider] ?? ".";
|
|
115
|
+
const files = (result.files ?? []).map((f) => ({
|
|
116
|
+
name: f.name,
|
|
117
|
+
path: join(outDir, f.name),
|
|
118
|
+
content: header + f.yaml,
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
if (args.format === "json") {
|
|
122
|
+
console.log(JSON.stringify({ files, jobs: result.jobs ?? [] }, null, 2));
|
|
123
|
+
return 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const target = resolve(file.path);
|
|
128
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
129
|
+
writeFileSync(target, file.content);
|
|
130
|
+
console.error(formatSuccess(`wrote ${formatBold(file.path)}`));
|
|
131
|
+
}
|
|
132
|
+
return 0;
|
|
133
|
+
}
|
package/src/cli/handlers/run.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lexiconNames } from "../../lexicon-module";
|
|
1
2
|
import { resolve, dirname } from "node:path";
|
|
2
3
|
import { writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
3
4
|
import { loadChantConfig, resolveAutoReleaseDisabled, type ChantConfig } from "../../config";
|
|
@@ -20,6 +21,7 @@ import { ndjsonProgressSink } from "../../components/run-progress";
|
|
|
20
21
|
import { maybeRecordAutoRelease } from "../../components/auto-release";
|
|
21
22
|
import { maybePersistBuildManifest } from "../../components/manifest-persistence";
|
|
22
23
|
import type { DriverComponentResult } from "../../components/driver";
|
|
24
|
+
import { runOpGenerate } from "./run-generate";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* The exit code a run that stopped at an unapproved gate uses (#2119).
|
|
@@ -124,7 +126,7 @@ async function resolveOpRuntime(ctx: CommandContext): Promise<OpRuntimeProvider
|
|
|
124
126
|
// long wait to be told a name is wrong.
|
|
125
127
|
let configured: string[] = [];
|
|
126
128
|
try {
|
|
127
|
-
configured = (await loadChantConfig(resolve("."))).config.lexicons ?? [];
|
|
129
|
+
configured = lexiconNames((await loadChantConfig(resolve("."))).config.lexicons ?? []);
|
|
128
130
|
} catch {
|
|
129
131
|
// No/unreadable chant.config.ts — the error below says so by listing nothing.
|
|
130
132
|
}
|
|
@@ -524,6 +526,16 @@ function refusesPolicyGateUnderSandbox(ctx: CommandContext, config: OpConfig, op
|
|
|
524
526
|
}
|
|
525
527
|
|
|
526
528
|
export async function runOp(ctx: CommandContext): Promise<number> {
|
|
529
|
+
if (ctx.args.generate) {
|
|
530
|
+
if (ctx.args.components) {
|
|
531
|
+
console.error(formatError({
|
|
532
|
+
message: "`chant run --components --generate` is not a command",
|
|
533
|
+
hint: "Generate a component pipeline with `chant build --components --generate <lexicon>`, or Op pipelines with `chant run --generate <provider>`.",
|
|
534
|
+
}));
|
|
535
|
+
return 1;
|
|
536
|
+
}
|
|
537
|
+
return runOpGenerate(ctx);
|
|
538
|
+
}
|
|
527
539
|
if (ctx.args.components && ctx.args.report) {
|
|
528
540
|
console.error(formatError({
|
|
529
541
|
message: "--report is not supported with --components",
|