@intentius/chant 0.1.14 → 0.1.16
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/package.json +1 -1
- package/src/build.ts +18 -2
- package/src/cli/commands/build.ts +9 -1
- package/src/cli/commands/import-live.test.ts +126 -0
- package/src/cli/commands/import.ts +152 -2
- package/src/cli/commands/migrate.ts +2 -2
- package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +80 -37
- package/src/cli/handlers/{state.ts → lifecycle.ts} +166 -40
- package/src/cli/handlers/misc.ts +31 -2
- package/src/cli/handlers/run.test.ts +98 -0
- package/src/cli/handlers/run.ts +123 -0
- package/src/cli/main.test.ts +14 -0
- package/src/cli/main.ts +49 -15
- package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
- package/src/cli/mcp/op-tools.ts +2 -2
- package/src/cli/mcp/resource-handlers.ts +1 -1
- package/src/cli/mcp/server.test.ts +2 -2
- package/src/cli/mcp/server.ts +1 -1
- package/src/cli/registry.ts +23 -2
- package/src/codegen/fetch.test.ts +103 -2
- package/src/codegen/fetch.ts +62 -10
- package/src/config.ts +41 -0
- package/src/detectLexicon.test.ts +2 -2
- package/src/index.ts +2 -2
- package/src/lexicon-export.test.ts +92 -0
- package/src/lexicon.ts +88 -9
- package/src/lifecycle/change-set.test.ts +151 -0
- package/src/lifecycle/change-set.ts +172 -0
- package/src/{state → lifecycle}/git.test.ts +15 -15
- package/src/{state → lifecycle}/git.ts +14 -14
- package/src/{state → lifecycle}/index.ts +2 -0
- package/src/{state → lifecycle}/snapshot.test.ts +5 -5
- package/src/{state → lifecycle}/snapshot.ts +9 -9
- package/src/{state → lifecycle}/types.ts +1 -1
- package/src/op/activity-registry.test.ts +59 -0
- package/src/op/activity-registry.ts +98 -0
- package/src/op/builders.ts +56 -20
- package/src/op/index.ts +6 -1
- package/src/op/local-executor.test.ts +263 -0
- package/src/op/local-executor.ts +300 -0
- package/src/op/local-output.test.ts +54 -0
- package/src/op/local-output.ts +63 -0
- package/src/op/op.test.ts +41 -4
- package/src/op/types.ts +2 -2
- package/src/ownership.test.ts +109 -0
- package/src/ownership.ts +142 -0
- package/src/serializer.ts +19 -1
- package/src/toml-parse.ts +3 -3
- /package/src/{state → lifecycle}/digest.test.ts +0 -0
- /package/src/{state → lifecycle}/digest.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
- /package/src/{state → lifecycle}/live-diff.ts +0 -0
|
@@ -1,27 +1,42 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { build } from "../../build";
|
|
3
|
-
import { takeSnapshot } from "../../
|
|
4
|
-
import { readSnapshot, readEnvironmentSnapshots, listSnapshots,
|
|
5
|
-
import { computeBuildDigest, diffDigests } from "../../
|
|
6
|
-
import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../
|
|
3
|
+
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
4
|
+
import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
5
|
+
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
6
|
+
import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../lifecycle/live-diff";
|
|
7
|
+
import { buildChangeSet, renderChangeSet, type ChangeSet } from "../../lifecycle/change-set";
|
|
7
8
|
import { loadChantConfig } from "../../config";
|
|
8
9
|
import { formatError, formatWarning, formatSuccess, formatBold } from "../format";
|
|
9
10
|
import type { CommandContext } from "../registry";
|
|
10
|
-
import type {
|
|
11
|
+
import type { LifecycleSnapshot } from "../../lifecycle/types";
|
|
11
12
|
import type { SerializerResult } from "../../serializer";
|
|
12
|
-
import type {
|
|
13
|
+
import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
|
|
13
14
|
import type { BuildResult } from "../../build";
|
|
15
|
+
import type { ParsedArgs } from "../registry";
|
|
16
|
+
import type { ChantConfig } from "../../config";
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
|
-
*
|
|
19
|
+
* Resolve the build root for a lifecycle command. The project root (where
|
|
20
|
+
* chant.config.ts lives) is always ".", but the *build* can be scoped to a
|
|
21
|
+
* subdirectory so a mixed-layout project — chant `src/` next to app code with
|
|
22
|
+
* import side effects — only synthesizes its infra. Precedence: `--src` flag,
|
|
23
|
+
* then `config.sourceDir`, then "." (the root). Snapshot/diff/plan all use this
|
|
24
|
+
* so their build digests stay consistent.
|
|
17
25
|
*/
|
|
18
|
-
|
|
26
|
+
function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
|
|
27
|
+
return resolve(args.src ?? config.sourceDir ?? ".");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* chant lifecycle snapshot <environment> [lexicon]
|
|
32
|
+
*/
|
|
33
|
+
export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number> {
|
|
19
34
|
const { args, plugins } = ctx;
|
|
20
35
|
const environment = args.extraPositional;
|
|
21
36
|
const lexiconFilter = args.extraPositional2;
|
|
22
37
|
|
|
23
38
|
if (!environment) {
|
|
24
|
-
console.error(formatError({ message: "Environment is required: chant
|
|
39
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle snapshot <environment> [lexicon]" }));
|
|
25
40
|
return 1;
|
|
26
41
|
}
|
|
27
42
|
|
|
@@ -43,7 +58,7 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
|
|
|
43
58
|
const targetSerializers = targetPlugins.map((p) => p.serializer);
|
|
44
59
|
|
|
45
60
|
// Build first to get entity names and build output
|
|
46
|
-
const buildResult = await build(
|
|
61
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
|
|
47
62
|
if (buildResult.errors.length > 0) {
|
|
48
63
|
console.error(formatError({ message: "Build failed — fix errors before taking a snapshot" }));
|
|
49
64
|
return 1;
|
|
@@ -62,10 +77,10 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
|
|
|
62
77
|
try {
|
|
63
78
|
result = await takeSnapshot(environment, observingPlugins, buildResult);
|
|
64
79
|
} catch (err) {
|
|
65
|
-
if (err instanceof
|
|
80
|
+
if (err instanceof StaleLifecycleBranchError) {
|
|
66
81
|
console.error(formatError({
|
|
67
|
-
message: `Another snapshot completed for chant/
|
|
68
|
-
hint: `Pull and retry: \`git fetch origin ${"chant/
|
|
82
|
+
message: `Another snapshot completed for chant/lifecycle after this run started (env: ${environment}).`,
|
|
83
|
+
hint: `Pull and retry: \`git fetch origin ${"chant/lifecycle"}:${"chant/lifecycle"}\` && \`chant lifecycle snapshot ${environment}\`.`,
|
|
69
84
|
}));
|
|
70
85
|
return 1;
|
|
71
86
|
}
|
|
@@ -83,26 +98,26 @@ export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
|
|
|
83
98
|
const counts = result.snapshots
|
|
84
99
|
.map((s) => `${s.lexicon}(${Object.keys(s.resources).length})`)
|
|
85
100
|
.join(" ");
|
|
86
|
-
console.error(formatSuccess(`Snapshot saved to chant/
|
|
101
|
+
console.error(formatSuccess(`Snapshot saved to chant/lifecycle (${counts})`));
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
return result.errors.length > 0 && result.snapshots.length === 0 ? 1 : 0;
|
|
90
105
|
}
|
|
91
106
|
|
|
92
107
|
/**
|
|
93
|
-
* chant
|
|
108
|
+
* chant lifecycle show <environment> [lexicon]
|
|
94
109
|
*/
|
|
95
|
-
export async function
|
|
110
|
+
export async function runLifecycleShow(ctx: CommandContext): Promise<number> {
|
|
96
111
|
const environment = ctx.args.extraPositional;
|
|
97
112
|
const lexiconFilter = ctx.args.extraPositional2;
|
|
98
113
|
|
|
99
114
|
if (!environment) {
|
|
100
|
-
console.error(formatError({ message: "Environment is required: chant
|
|
115
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle show <environment> [lexicon]" }));
|
|
101
116
|
return 1;
|
|
102
117
|
}
|
|
103
118
|
|
|
104
119
|
// Fetch from remote first
|
|
105
|
-
await
|
|
120
|
+
await fetchLifecycle();
|
|
106
121
|
|
|
107
122
|
if (lexiconFilter) {
|
|
108
123
|
const content = await readSnapshot(environment, lexiconFilter);
|
|
@@ -111,7 +126,7 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
|
|
|
111
126
|
return 1;
|
|
112
127
|
}
|
|
113
128
|
|
|
114
|
-
const snapshot:
|
|
129
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
115
130
|
printSnapshotTable(snapshot);
|
|
116
131
|
} else {
|
|
117
132
|
const snapshots = await readEnvironmentSnapshots(environment);
|
|
@@ -121,7 +136,7 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
|
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
for (const [lexicon, content] of snapshots) {
|
|
124
|
-
const snapshot:
|
|
139
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
125
140
|
console.log(`\n${formatBold(`${environment}/${lexicon}`)} — ${Object.keys(snapshot.resources).length} resources — ${snapshot.timestamp}`);
|
|
126
141
|
printSnapshotTable(snapshot);
|
|
127
142
|
}
|
|
@@ -131,15 +146,15 @@ export async function runStateShow(ctx: CommandContext): Promise<number> {
|
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
/**
|
|
134
|
-
* chant
|
|
149
|
+
* chant lifecycle diff <environment> [lexicon]
|
|
135
150
|
*/
|
|
136
|
-
export async function
|
|
151
|
+
export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
137
152
|
const { args, plugins, serializers } = ctx;
|
|
138
153
|
const environment = args.extraPositional;
|
|
139
154
|
const lexiconFilter = args.extraPositional2;
|
|
140
155
|
|
|
141
156
|
if (!environment) {
|
|
142
|
-
console.error(formatError({ message: "Environment is required: chant
|
|
157
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle diff <environment> [lexicon]" }));
|
|
143
158
|
return 1;
|
|
144
159
|
}
|
|
145
160
|
|
|
@@ -148,26 +163,26 @@ export async function runStateDiff(ctx: CommandContext): Promise<number> {
|
|
|
148
163
|
? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
|
|
149
164
|
: serializers;
|
|
150
165
|
|
|
151
|
-
// Build to get current state
|
|
152
|
-
const
|
|
153
|
-
const buildResult = await build(
|
|
166
|
+
// Build to get current state (from the configured source root, not necessarily ".")
|
|
167
|
+
const { config } = await loadChantConfig(resolve("."));
|
|
168
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
|
|
154
169
|
if (buildResult.errors.length > 0) {
|
|
155
170
|
console.error(formatError({ message: "Build failed — fix errors before diffing" }));
|
|
156
171
|
return 1;
|
|
157
172
|
}
|
|
158
173
|
|
|
159
174
|
// Fetch and read previous snapshot
|
|
160
|
-
await
|
|
175
|
+
await fetchLifecycle();
|
|
161
176
|
|
|
162
177
|
const lexicons = lexiconFilter
|
|
163
178
|
? [lexiconFilter]
|
|
164
179
|
: Array.from(buildResult.manifest.lexicons);
|
|
165
180
|
|
|
166
181
|
if (args.live) {
|
|
167
|
-
return
|
|
182
|
+
return runLifecycleDiffLive({ environment, lexicons, plugins, buildResult });
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
return
|
|
185
|
+
return runLifecycleDiffDigest({ environment, lexicons, buildResult });
|
|
171
186
|
}
|
|
172
187
|
|
|
173
188
|
interface DigestDiffArgs {
|
|
@@ -176,14 +191,14 @@ interface DigestDiffArgs {
|
|
|
176
191
|
buildResult: BuildResult;
|
|
177
192
|
}
|
|
178
193
|
|
|
179
|
-
async function
|
|
194
|
+
async function runLifecycleDiffDigest(args: DigestDiffArgs): Promise<number> {
|
|
180
195
|
const currentDigest = computeBuildDigest(args.buildResult);
|
|
181
196
|
|
|
182
197
|
for (const lexicon of args.lexicons) {
|
|
183
198
|
const content = await readSnapshot(args.environment, lexicon);
|
|
184
199
|
let previousDigest = undefined;
|
|
185
200
|
if (content) {
|
|
186
|
-
const snapshot:
|
|
201
|
+
const snapshot: LifecycleSnapshot = JSON.parse(content);
|
|
187
202
|
previousDigest = snapshot.digest;
|
|
188
203
|
}
|
|
189
204
|
|
|
@@ -213,11 +228,11 @@ async function runStateDiffDigest(args: DigestDiffArgs): Promise<number> {
|
|
|
213
228
|
interface LiveDiffArgs {
|
|
214
229
|
environment: string;
|
|
215
230
|
lexicons: string[];
|
|
216
|
-
plugins:
|
|
231
|
+
plugins: ObservationLexicon[];
|
|
217
232
|
buildResult: BuildResult;
|
|
218
233
|
}
|
|
219
234
|
|
|
220
|
-
async function
|
|
235
|
+
async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<number> {
|
|
221
236
|
let totalDrift = 0;
|
|
222
237
|
let totalLexiconsChecked = 0;
|
|
223
238
|
|
|
@@ -256,7 +271,7 @@ async function runStateDiffLive(args: LiveDiffArgs): Promise<number> {
|
|
|
256
271
|
: (rawOutput as SerializerResult).primary;
|
|
257
272
|
|
|
258
273
|
// Read previous snapshot once; both flows pull what they need.
|
|
259
|
-
let prevSnapshot:
|
|
274
|
+
let prevSnapshot: LifecycleSnapshot | undefined;
|
|
260
275
|
const content = await readSnapshot(args.environment, lexiconName);
|
|
261
276
|
if (content) prevSnapshot = JSON.parse(content);
|
|
262
277
|
|
|
@@ -403,12 +418,123 @@ function renderLiveArtifactDiff(lexiconName: string, environment: string, diff:
|
|
|
403
418
|
}
|
|
404
419
|
|
|
405
420
|
/**
|
|
406
|
-
* chant
|
|
421
|
+
* chant lifecycle plan <environment> [lexicon]
|
|
422
|
+
*
|
|
423
|
+
* Promote the live diff to a typed, read-only change set: per-entity
|
|
424
|
+
* create / update / delete / adopt / noop. Strictly read-only — never
|
|
425
|
+
* mutates, never deploys. Deletes are never proposed without ownership
|
|
426
|
+
* data (added in #121); an undeclared live resource is `adopt`.
|
|
427
|
+
*/
|
|
428
|
+
export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
429
|
+
const { args, plugins, serializers } = ctx;
|
|
430
|
+
const environment = args.extraPositional;
|
|
431
|
+
const lexiconFilter = args.extraPositional2;
|
|
432
|
+
|
|
433
|
+
if (!environment) {
|
|
434
|
+
console.error(formatError({ message: "Environment is required: chant lifecycle plan <environment> [lexicon]" }));
|
|
435
|
+
return 1;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const targetSerializers = lexiconFilter
|
|
439
|
+
? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
|
|
440
|
+
: serializers;
|
|
441
|
+
|
|
442
|
+
const { config } = await loadChantConfig(resolve("."));
|
|
443
|
+
const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
|
|
444
|
+
if (buildResult.errors.length > 0) {
|
|
445
|
+
console.error(formatError({ message: "Build failed — fix errors before planning" }));
|
|
446
|
+
return 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
await fetchLifecycle();
|
|
450
|
+
|
|
451
|
+
const lexicons = lexiconFilter ? [lexiconFilter] : Array.from(buildResult.manifest.lexicons);
|
|
452
|
+
|
|
453
|
+
const merged: ChangeSet = { env: environment, entries: [] };
|
|
454
|
+
let checked = 0;
|
|
455
|
+
|
|
456
|
+
for (const lexiconName of lexicons) {
|
|
457
|
+
const plugin = plugins.find((p) => p.name === lexiconName);
|
|
458
|
+
if (!plugin) continue;
|
|
459
|
+
if (!plugin.describeResources) {
|
|
460
|
+
// Plan is entity-keyed; artifact-only lexicons have no declared axis.
|
|
461
|
+
if (!args.json) {
|
|
462
|
+
console.error(formatWarning({
|
|
463
|
+
message: `${lexiconName}: lexicon does not implement describeResources — skipping (no declared axis to plan against)`,
|
|
464
|
+
}));
|
|
465
|
+
}
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const declared = new Set<string>();
|
|
470
|
+
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
471
|
+
for (const [name, entity] of buildResult.entities) {
|
|
472
|
+
if (entity.lexicon === lexiconName) {
|
|
473
|
+
declared.add(name);
|
|
474
|
+
entities.set(name, {
|
|
475
|
+
entityType: entity.entityType,
|
|
476
|
+
props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const rawOutput = buildResult.outputs.get(lexiconName);
|
|
482
|
+
const buildOutput =
|
|
483
|
+
rawOutput === undefined
|
|
484
|
+
? ""
|
|
485
|
+
: typeof rawOutput === "string"
|
|
486
|
+
? rawOutput
|
|
487
|
+
: (rawOutput as SerializerResult).primary;
|
|
488
|
+
|
|
489
|
+
let observedNow: Record<string, ResourceMetadata>;
|
|
490
|
+
try {
|
|
491
|
+
observedNow = await plugin.describeResources({
|
|
492
|
+
environment,
|
|
493
|
+
buildOutput,
|
|
494
|
+
entityNames: Array.from(declared),
|
|
495
|
+
entities,
|
|
496
|
+
owned: args.owned,
|
|
497
|
+
});
|
|
498
|
+
} catch (err) {
|
|
499
|
+
console.error(formatError({
|
|
500
|
+
message: `${lexiconName}: describeResources failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
501
|
+
}));
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const content = await readSnapshot(environment, lexiconName);
|
|
506
|
+
const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
|
|
507
|
+
|
|
508
|
+
const cs = buildChangeSet(environment, { declared, observedNow, observedThen });
|
|
509
|
+
merged.entries.push(...cs.entries);
|
|
510
|
+
checked++;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (checked === 0) {
|
|
514
|
+
console.error(formatWarning({
|
|
515
|
+
message: "No lexicons implement describeResources — nothing to plan",
|
|
516
|
+
}));
|
|
517
|
+
return 1;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
521
|
+
|
|
522
|
+
if (args.json) {
|
|
523
|
+
console.log(JSON.stringify(merged, null, 2));
|
|
524
|
+
} else {
|
|
525
|
+
console.log(renderChangeSet(merged));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return 0;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* chant lifecycle log [environment]
|
|
407
533
|
*/
|
|
408
|
-
export async function
|
|
534
|
+
export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
|
|
409
535
|
const environment = ctx.args.extraPositional;
|
|
410
536
|
|
|
411
|
-
await
|
|
537
|
+
await fetchLifecycle();
|
|
412
538
|
|
|
413
539
|
const entries = await listSnapshots({ environment });
|
|
414
540
|
if (entries.length === 0) {
|
|
@@ -427,15 +553,15 @@ export async function runStateLog(ctx: CommandContext): Promise<number> {
|
|
|
427
553
|
/**
|
|
428
554
|
* Fallback for unknown state subcommands.
|
|
429
555
|
*/
|
|
430
|
-
export async function
|
|
556
|
+
export async function runLifecycleUnknown(ctx: CommandContext): Promise<number> {
|
|
431
557
|
console.error(formatError({
|
|
432
558
|
message: `Unknown state subcommand: ${ctx.args.extraPositional ?? ctx.args.path}`,
|
|
433
|
-
hint: "Available: chant
|
|
559
|
+
hint: "Available: chant lifecycle snapshot, chant lifecycle show, chant lifecycle diff, chant lifecycle plan, chant lifecycle log",
|
|
434
560
|
}));
|
|
435
561
|
return 1;
|
|
436
562
|
}
|
|
437
563
|
|
|
438
|
-
function printSnapshotTable(snapshot:
|
|
564
|
+
function printSnapshotTable(snapshot: LifecycleSnapshot): void {
|
|
439
565
|
console.log("RESOURCE".padEnd(20) + "TYPE".padEnd(28) + "PHYSICAL ID".padEnd(44) + "STATUS");
|
|
440
566
|
console.log("-".repeat(100));
|
|
441
567
|
|
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { listCommand, printListResult } from "../commands/list";
|
|
2
|
-
import { importCommand, printImportResult } from "../commands/import";
|
|
3
|
-
import {
|
|
2
|
+
import { importCommand, importFromLive, printImportResult } from "../commands/import";
|
|
3
|
+
import type { ResourceSelector } from "../../lexicon";
|
|
4
|
+
import { formatError, formatSuccess, formatWarning } from "../format";
|
|
4
5
|
import type { CommandContext } from "../registry";
|
|
5
6
|
|
|
6
7
|
export async function runList(ctx: CommandContext): Promise<number> {
|
|
@@ -21,6 +22,34 @@ export async function runList(ctx: CommandContext): Promise<number> {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export async function runImport(ctx: CommandContext): Promise<number> {
|
|
25
|
+
const { args } = ctx;
|
|
26
|
+
|
|
27
|
+
// `--from <env>` switches import from a template file to a live source.
|
|
28
|
+
if (args.migrateFrom) {
|
|
29
|
+
const selector: ResourceSelector | undefined =
|
|
30
|
+
args.selectType || args.selectName
|
|
31
|
+
? { type: args.selectType, name: args.selectName }
|
|
32
|
+
: undefined;
|
|
33
|
+
|
|
34
|
+
// Live config may carry secrets into generated source — warn before writing.
|
|
35
|
+
console.error(formatWarning({
|
|
36
|
+
message: "Live import may emit sensitive values (keys, tokens, passwords) into generated source. Review before committing.",
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
const result = await importFromLive({
|
|
40
|
+
environment: args.migrateFrom,
|
|
41
|
+
lexicon: args.lexicon,
|
|
42
|
+
output: args.output,
|
|
43
|
+
force: args.force,
|
|
44
|
+
selector,
|
|
45
|
+
owned: args.owned,
|
|
46
|
+
verbatim: args.verbatim,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
printImportResult(result);
|
|
50
|
+
return result.success ? 0 : 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
24
53
|
const result = await importCommand({
|
|
25
54
|
templatePath: ctx.args.path,
|
|
26
55
|
output: ctx.args.output,
|
|
@@ -43,6 +43,9 @@ function makeArgs(overrides: Partial<ParsedArgs> = {}): ParsedArgs {
|
|
|
43
43
|
return {
|
|
44
44
|
command: "run", path: ".",
|
|
45
45
|
format: "", fix: false, watch: false, verbose: false, help: false, live: false,
|
|
46
|
+
// These suites exercise the Temporal path; local mode is the CLI default,
|
|
47
|
+
// so opt in explicitly here. Local-mode behavior is covered separately below.
|
|
48
|
+
temporal: true,
|
|
46
49
|
...overrides,
|
|
47
50
|
};
|
|
48
51
|
}
|
|
@@ -446,3 +449,98 @@ describe("runOp", () => {
|
|
|
446
449
|
}
|
|
447
450
|
});
|
|
448
451
|
});
|
|
452
|
+
|
|
453
|
+
// ── local mode dispatcher + guards ──────────────────────────────────────────
|
|
454
|
+
|
|
455
|
+
function localOp(name: string, steps: unknown[]) {
|
|
456
|
+
return [name, { config: { name, overview: `${name} overview`, phases: [{ name: "Phase", steps }] } }] as const;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
describe("runOp dispatcher", () => {
|
|
460
|
+
beforeEach(() => {
|
|
461
|
+
discoverOpsMock.mockReset();
|
|
462
|
+
loadTemporalClientMock.mockReset();
|
|
463
|
+
loadChantConfigMock.mockReset();
|
|
464
|
+
resolveProfileMock.mockReset();
|
|
465
|
+
existsSyncMock.mockReset();
|
|
466
|
+
spawnChildMock.mockReset();
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
test("no flag → local executor (no Temporal client or worker spawned)", async () => {
|
|
470
|
+
discoverOpsMock.mockResolvedValue({
|
|
471
|
+
ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
472
|
+
errors: [],
|
|
473
|
+
});
|
|
474
|
+
const stderrWrite = vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
475
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", temporal: false }), plugins: [], serializers: [] });
|
|
476
|
+
expect(exit).toBe(0);
|
|
477
|
+
expect(loadTemporalClientMock).not.toHaveBeenCalled();
|
|
478
|
+
expect(spawnChildMock).not.toHaveBeenCalled();
|
|
479
|
+
stderrWrite.mockRestore();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("--temporal → Temporal path (missing worker.ts → exit 1)", async () => {
|
|
483
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([makeOp("hello")]), errors: [] });
|
|
484
|
+
loadChantConfigMock.mockResolvedValue({ config: {} });
|
|
485
|
+
resolveProfileMock.mockReturnValue({ address: "localhost:7233", namespace: "default", taskQueue: "q" });
|
|
486
|
+
existsSyncMock.mockReturnValue(false);
|
|
487
|
+
const stderr = makeStderrSpy();
|
|
488
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", temporal: true }), plugins: [], serializers: [] });
|
|
489
|
+
expect(exit).toBe(1);
|
|
490
|
+
expect(stderr.join("\n")).toContain("worker.ts not found");
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
test("gate in local mode → fast-fail before execution, suggests --temporal", async () => {
|
|
494
|
+
discoverOpsMock.mockResolvedValue({
|
|
495
|
+
ops: new Map([localOp("gated", [{ kind: "gate", signalName: "approve-prod" }])]),
|
|
496
|
+
errors: [],
|
|
497
|
+
});
|
|
498
|
+
const stderr = makeStderrSpy();
|
|
499
|
+
const exit = await runOp({ args: makeArgs({ path: "gated", temporal: false }), plugins: [], serializers: [] });
|
|
500
|
+
expect(exit).toBe(1);
|
|
501
|
+
expect(stderr.join("\n")).toContain("--temporal");
|
|
502
|
+
expect(loadTemporalClientMock).not.toHaveBeenCalled();
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("--local and --temporal together → exit 1 before any work", async () => {
|
|
506
|
+
const stderr = makeStderrSpy();
|
|
507
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", local: true, temporal: true }), plugins: [], serializers: [] });
|
|
508
|
+
expect(exit).toBe(1);
|
|
509
|
+
expect(stderr.join("\n")).toContain("mutually exclusive");
|
|
510
|
+
expect(discoverOpsMock).not.toHaveBeenCalled();
|
|
511
|
+
expect(loadTemporalClientMock).not.toHaveBeenCalled();
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("--json → structured result on stdout", async () => {
|
|
515
|
+
discoverOpsMock.mockResolvedValue({
|
|
516
|
+
ops: new Map([localOp("hello", [{ kind: "activity", fn: "shellCmd", args: { cmd: "true" } }])]),
|
|
517
|
+
errors: [],
|
|
518
|
+
});
|
|
519
|
+
const stdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
520
|
+
vi.spyOn(process.stderr, "write").mockImplementation(() => true);
|
|
521
|
+
const exit = await runOp({ args: makeArgs({ path: "hello", temporal: false, json: true }), plugins: [], serializers: [] });
|
|
522
|
+
expect(exit).toBe(0);
|
|
523
|
+
const printed = stdoutWrite.mock.calls.map((c) => String(c[0])).join("");
|
|
524
|
+
const parsed = JSON.parse(printed.trim());
|
|
525
|
+
expect(parsed.op).toBe("hello");
|
|
526
|
+
expect(parsed.ok).toBe(true);
|
|
527
|
+
vi.restoreAllMocks();
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
describe("Temporal-only subcommand guards", () => {
|
|
532
|
+
const cases: Array<[string, (ctx: { args: ParsedArgs; plugins: never[]; serializers: never[] }) => Promise<number>]> = [
|
|
533
|
+
["list", runOpList],
|
|
534
|
+
["status", runOpStatus],
|
|
535
|
+
["log", runOpLog],
|
|
536
|
+
["signal", runOpSignal],
|
|
537
|
+
["cancel", runOpCancel],
|
|
538
|
+
];
|
|
539
|
+
|
|
540
|
+
test.each(cases)("run %s without --temporal → exit 1 + actionable message", async (_name, handler) => {
|
|
541
|
+
const stderr = makeStderrSpy();
|
|
542
|
+
const exit = await handler({ args: makeArgs({ temporal: false, extraPositional: "x", extraPositional2: "y" }), plugins: [], serializers: [] });
|
|
543
|
+
expect(exit).toBe(1);
|
|
544
|
+
expect(stderr.join("\n")).toContain("not available in local mode");
|
|
545
|
+
});
|
|
546
|
+
});
|