@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.
Files changed (52) hide show
  1. package/package.json +1 -1
  2. package/src/build.ts +18 -2
  3. package/src/cli/commands/build.ts +9 -1
  4. package/src/cli/commands/import-live.test.ts +126 -0
  5. package/src/cli/commands/import.ts +152 -2
  6. package/src/cli/commands/migrate.ts +2 -2
  7. package/src/cli/handlers/{state.test.ts → lifecycle.test.ts} +80 -37
  8. package/src/cli/handlers/{state.ts → lifecycle.ts} +166 -40
  9. package/src/cli/handlers/misc.ts +31 -2
  10. package/src/cli/handlers/run.test.ts +98 -0
  11. package/src/cli/handlers/run.ts +123 -0
  12. package/src/cli/main.test.ts +14 -0
  13. package/src/cli/main.ts +49 -15
  14. package/src/cli/mcp/{state-tools.ts → lifecycle-tools.ts} +9 -9
  15. package/src/cli/mcp/op-tools.ts +2 -2
  16. package/src/cli/mcp/resource-handlers.ts +1 -1
  17. package/src/cli/mcp/server.test.ts +2 -2
  18. package/src/cli/mcp/server.ts +1 -1
  19. package/src/cli/registry.ts +23 -2
  20. package/src/codegen/fetch.test.ts +103 -2
  21. package/src/codegen/fetch.ts +62 -10
  22. package/src/config.ts +41 -0
  23. package/src/detectLexicon.test.ts +2 -2
  24. package/src/index.ts +2 -2
  25. package/src/lexicon-export.test.ts +92 -0
  26. package/src/lexicon.ts +88 -9
  27. package/src/lifecycle/change-set.test.ts +151 -0
  28. package/src/lifecycle/change-set.ts +172 -0
  29. package/src/{state → lifecycle}/git.test.ts +15 -15
  30. package/src/{state → lifecycle}/git.ts +14 -14
  31. package/src/{state → lifecycle}/index.ts +2 -0
  32. package/src/{state → lifecycle}/snapshot.test.ts +5 -5
  33. package/src/{state → lifecycle}/snapshot.ts +9 -9
  34. package/src/{state → lifecycle}/types.ts +1 -1
  35. package/src/op/activity-registry.test.ts +59 -0
  36. package/src/op/activity-registry.ts +98 -0
  37. package/src/op/builders.ts +56 -20
  38. package/src/op/index.ts +6 -1
  39. package/src/op/local-executor.test.ts +263 -0
  40. package/src/op/local-executor.ts +300 -0
  41. package/src/op/local-output.test.ts +54 -0
  42. package/src/op/local-output.ts +63 -0
  43. package/src/op/op.test.ts +41 -4
  44. package/src/op/types.ts +2 -2
  45. package/src/ownership.test.ts +109 -0
  46. package/src/ownership.ts +142 -0
  47. package/src/serializer.ts +19 -1
  48. package/src/toml-parse.ts +3 -3
  49. /package/src/{state → lifecycle}/digest.test.ts +0 -0
  50. /package/src/{state → lifecycle}/digest.ts +0 -0
  51. /package/src/{state → lifecycle}/live-diff.test.ts +0 -0
  52. /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 "../../state/snapshot";
4
- import { readSnapshot, readEnvironmentSnapshots, listSnapshots, fetchState, StaleStateBranchError } from "../../state/git";
5
- import { computeBuildDigest, diffDigests } from "../../state/digest";
6
- import { diffLive, diffLiveArtifacts, type LiveDiffResult, type LiveArtifactDiffResult } from "../../state/live-diff";
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 { StateSnapshot } from "../../state/types";
11
+ import type { LifecycleSnapshot } from "../../lifecycle/types";
11
12
  import type { SerializerResult } from "../../serializer";
12
- import type { LexiconPlugin, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
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
- * chant state snapshot <environment> [lexicon]
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
- export async function runStateSnapshot(ctx: CommandContext): Promise<number> {
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 state snapshot <environment> [lexicon]" }));
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(projectPath, targetSerializers);
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 StaleStateBranchError) {
80
+ if (err instanceof StaleLifecycleBranchError) {
66
81
  console.error(formatError({
67
- message: `Another snapshot completed for chant/state after this run started (env: ${environment}).`,
68
- hint: `Pull and retry: \`git fetch origin ${"chant/state"}:${"chant/state"}\` && \`chant state snapshot ${environment}\`.`,
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/state (${counts})`));
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 state show <environment> [lexicon]
108
+ * chant lifecycle show <environment> [lexicon]
94
109
  */
95
- export async function runStateShow(ctx: CommandContext): Promise<number> {
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 state show <environment> [lexicon]" }));
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 fetchState();
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: StateSnapshot = JSON.parse(content);
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: StateSnapshot = JSON.parse(content);
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 state diff <environment> [lexicon]
149
+ * chant lifecycle diff <environment> [lexicon]
135
150
  */
136
- export async function runStateDiff(ctx: CommandContext): Promise<number> {
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 state diff <environment> [lexicon]" }));
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 projectPath = resolve(".");
153
- const buildResult = await build(projectPath, targetSerializers);
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 fetchState();
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 runStateDiffLive({ environment, lexicons, plugins, buildResult });
182
+ return runLifecycleDiffLive({ environment, lexicons, plugins, buildResult });
168
183
  }
169
184
 
170
- return runStateDiffDigest({ environment, lexicons, buildResult });
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 runStateDiffDigest(args: DigestDiffArgs): Promise<number> {
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: StateSnapshot = JSON.parse(content);
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: LexiconPlugin[];
231
+ plugins: ObservationLexicon[];
217
232
  buildResult: BuildResult;
218
233
  }
219
234
 
220
- async function runStateDiffLive(args: LiveDiffArgs): Promise<number> {
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: StateSnapshot | undefined;
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 state log [environment]
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 runStateLog(ctx: CommandContext): Promise<number> {
534
+ export async function runLifecycleLog(ctx: CommandContext): Promise<number> {
409
535
  const environment = ctx.args.extraPositional;
410
536
 
411
- await fetchState();
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 runStateUnknown(ctx: CommandContext): Promise<number> {
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 state snapshot, chant state show, chant state diff, chant state log",
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: StateSnapshot): void {
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
 
@@ -1,6 +1,7 @@
1
1
  import { listCommand, printListResult } from "../commands/list";
2
- import { importCommand, printImportResult } from "../commands/import";
3
- import { formatError, formatSuccess } from "../format";
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
+ });