@intentius/chant 0.1.15 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect, vi, beforeEach } from "vitest";
2
+ import { sep } from "node:path";
2
3
  import { createMockPlugin, staticDescribeResources, staticListArtifacts } from "@intentius/chant-test-utils";
3
4
  import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
4
5
  import type { BuildResult } from "../../build";
@@ -85,6 +86,8 @@ describe("runLifecycleDiff --live", () => {
85
86
  buildMock.mockReset();
86
87
  fetchLifecycleMock.mockReset();
87
88
  readSnapshotMock.mockReset();
89
+ loadChantConfigMock.mockReset();
90
+ loadChantConfigMock.mockResolvedValue({ config: {} });
88
91
  });
89
92
 
90
93
  test("surfaces drift between previous snapshot and live state", async () => {
@@ -124,6 +127,46 @@ describe("runLifecycleDiff --live", () => {
124
127
  expect(output).toContain("UPDATE_COMPLETE");
125
128
  });
126
129
 
130
+ test("builds from config.sourceDir on a mixed-layout project", async () => {
131
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
132
+ fetchLifecycleMock.mockResolvedValue(undefined);
133
+ readSnapshotMock.mockResolvedValue(null);
134
+ loadChantConfigMock.mockResolvedValue({ config: { sourceDir: "src" } });
135
+
136
+ const plugins: LexiconPlugin[] = [
137
+ createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
138
+ ];
139
+ const exit = await runLifecycleDiff({
140
+ args: makeArgs({ path: "diff", extraPositional: "prod", extraPositional2: "aws", live: true }),
141
+ plugins,
142
+ serializers: plugins.map((p) => p.serializer),
143
+ });
144
+
145
+ expect(exit).toBe(0);
146
+ const builtPath = buildMock.mock.calls[0][0] as string;
147
+ expect(builtPath.endsWith(`${sep}src`)).toBe(true);
148
+ });
149
+
150
+ test("--src overrides config.sourceDir for the build root", async () => {
151
+ buildMock.mockResolvedValue(makeBuildResult({ aws: ["bucket"] }));
152
+ fetchLifecycleMock.mockResolvedValue(undefined);
153
+ readSnapshotMock.mockResolvedValue(null);
154
+ loadChantConfigMock.mockResolvedValue({ config: { sourceDir: "src" } });
155
+
156
+ const plugins: LexiconPlugin[] = [
157
+ createMockPlugin({ name: "aws", describeResources: staticDescribeResources({}) }),
158
+ ];
159
+ const exit = await runLifecycleDiff({
160
+ args: makeArgs({ path: "diff", extraPositional: "prod", extraPositional2: "aws", live: true, src: "infra" }),
161
+ plugins,
162
+ serializers: plugins.map((p) => p.serializer),
163
+ });
164
+
165
+ expect(exit).toBe(0);
166
+ const builtPath = buildMock.mock.calls[0][0] as string;
167
+ expect(builtPath.endsWith(`${sep}infra`)).toBe(true);
168
+ });
169
+
127
170
  test("warns and skips lexicons without describeResources", async () => {
128
171
  buildMock.mockResolvedValue(makeBuildResult({ k8s: ["pod"] }));
129
172
  fetchLifecycleMock.mockResolvedValue(undefined);
@@ -12,6 +12,20 @@ import type { LifecycleSnapshot } from "../../lifecycle/types";
12
12
  import type { SerializerResult } from "../../serializer";
13
13
  import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../../lexicon";
14
14
  import type { BuildResult } from "../../build";
15
+ import type { ParsedArgs } from "../registry";
16
+ import type { ChantConfig } from "../../config";
17
+
18
+ /**
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.
25
+ */
26
+ function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
27
+ return resolve(args.src ?? config.sourceDir ?? ".");
28
+ }
15
29
 
16
30
  /**
17
31
  * chant lifecycle snapshot <environment> [lexicon]
@@ -44,7 +58,7 @@ export async function runLifecycleSnapshot(ctx: CommandContext): Promise<number>
44
58
  const targetSerializers = targetPlugins.map((p) => p.serializer);
45
59
 
46
60
  // Build first to get entity names and build output
47
- const buildResult = await build(projectPath, targetSerializers);
61
+ const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
48
62
  if (buildResult.errors.length > 0) {
49
63
  console.error(formatError({ message: "Build failed — fix errors before taking a snapshot" }));
50
64
  return 1;
@@ -149,9 +163,9 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
149
163
  ? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
150
164
  : serializers;
151
165
 
152
- // Build to get current state
153
- const projectPath = resolve(".");
154
- 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);
155
169
  if (buildResult.errors.length > 0) {
156
170
  console.error(formatError({ message: "Build failed — fix errors before diffing" }));
157
171
  return 1;
@@ -425,8 +439,8 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
425
439
  ? plugins.filter((p) => p.name === lexiconFilter).map((p) => p.serializer)
426
440
  : serializers;
427
441
 
428
- const projectPath = resolve(".");
429
- const buildResult = await build(projectPath, targetSerializers);
442
+ const { config } = await loadChantConfig(resolve("."));
443
+ const buildResult = await build(resolveBuildRoot(args, config), targetSerializers);
430
444
  if (buildResult.errors.length > 0) {
431
445
  console.error(formatError({ message: "Build failed — fix errors before planning" }));
432
446
  return 1;
package/src/cli/main.ts CHANGED
@@ -49,6 +49,7 @@ export function parseArgs(args: string[]): ParsedArgs {
49
49
  useComposites: false,
50
50
  reportFile: undefined,
51
51
  skill: undefined,
52
+ src: undefined,
52
53
  };
53
54
 
54
55
  let i = 0;
@@ -111,6 +112,8 @@ export function parseArgs(args: string[]): ParsedArgs {
111
112
  result.useComposites = true;
112
113
  } else if (arg === "--skill") {
113
114
  result.skill = args[++i];
115
+ } else if (arg === "--src") {
116
+ result.src = args[++i];
114
117
  } else if (arg === "--local") {
115
118
  result.local = true;
116
119
  } else if (arg === "--temporal") {
@@ -332,9 +335,14 @@ async function main(): Promise<void> {
332
335
  process.exit(1);
333
336
  }
334
337
 
335
- // For compound commands (e.g. "run list"), args.path is the subcommand,
336
- // so always use "." as the project path. For simple commands, use args.path.
337
- const projectPath = match.compound ? (args.extraPositional || ".") : args.path;
338
+ // For compound commands (e.g. "run list", "lifecycle plan <env>"), the first
339
+ // positional is a subcommand argument an environment, op, or lexicon name
340
+ // not a project path. Plugins always load from the cwd; the handler reads its
341
+ // own positionals from args.extraPositional. Using extraPositional as the path
342
+ // here pointed plugin resolution at e.g. "./local" for `lifecycle plan local`,
343
+ // which then fell through to import-detection on an empty file set and failed
344
+ // with "No lexicon detected" even though chant.config.ts lists the lexicons.
345
+ const projectPath = match.compound ? "." : args.path;
338
346
  const plugins = match.def.requiresPlugins
339
347
  ? await loadPluginsOrExit(projectPath)
340
348
  : [];
@@ -51,6 +51,8 @@ export interface ParsedArgs {
51
51
  owned?: boolean;
52
52
  /** `chant import --verbatim` — keep server-defaulted fields in live import */
53
53
  verbatim?: boolean;
54
+ /** `chant lifecycle … --src <dir>` — build root override for lifecycle commands */
55
+ src?: string;
54
56
  }
55
57
 
56
58
  /**
package/src/config.ts CHANGED
@@ -10,6 +10,7 @@ import type { OwnershipMarker } from "./ownership";
10
10
  export const ChantConfigSchema = z.object({
11
11
  lexicons: z.array(z.string().min(1)).optional(),
12
12
  environments: z.array(z.string().min(1)).optional(),
13
+ sourceDir: z.string().min(1).optional(),
13
14
  lint: z.record(z.string(), z.unknown()).optional(),
14
15
  ownership: z.object({
15
16
  stack: z.string().min(1).optional(),
@@ -30,6 +31,15 @@ export interface ChantConfig {
30
31
  /** Environment names (e.g. ["staging", "prod"]) */
31
32
  environments?: string[];
32
33
 
34
+ /**
35
+ * Directory (relative to the project root) that holds the chant infrastructure
36
+ * source. Lifecycle commands (`snapshot`/`diff`/`plan`) build from here instead
37
+ * of the project root, so a mixed-layout project — chant `src/` alongside app
38
+ * code that has import side effects — can scope the build to just the infra.
39
+ * Defaults to "." (the project root). The `--src` flag overrides it.
40
+ */
41
+ sourceDir?: string;
42
+
33
43
  /** Lint configuration (rules, extends, overrides, plugins) */
34
44
  lint?: LintConfig;
35
45
 
@@ -63,10 +63,17 @@ export interface ActivityProfile {
63
63
  * Dynamically import the lexicon's `TEMPORAL_ACTIVITY_PROFILES` (pure data, no
64
64
  * Temporal SDK). Returns an empty record if the lexicon is absent — the
65
65
  * executor then falls back to built-in defaults per step.
66
+ *
67
+ * Imports the lexicon's `/config` entry, not its root index. `/config` is a
68
+ * side-effect-free data module; the root index pulls in the plugin, serializer,
69
+ * composites, and re-exported Op machinery, any of which could throw on load
70
+ * and make this silently return `{}` — which would drop every profiled step to
71
+ * the 5-minute default while the Temporal path (reading the same table) kept the
72
+ * declared timeout. Importing the narrow module keeps the two executors agreed.
66
73
  */
67
74
  export async function loadProfiles(): Promise<Record<string, ActivityProfile>> {
68
75
  try {
69
- const spec = "@intentius/chant-lexicon-temporal";
76
+ const spec = "@intentius/chant-lexicon-temporal/config";
70
77
  const mod = (await import(spec)) as { TEMPORAL_ACTIVITY_PROFILES?: Record<string, ActivityProfile> };
71
78
  return mod.TEMPORAL_ACTIVITY_PROFILES ?? {};
72
79
  } catch {
@@ -60,36 +60,72 @@ export function gate(
60
60
 
61
61
  // ── Pre-built activity shortcuts ──────────────────────────────────────────────
62
62
 
63
+ /**
64
+ * Pull an optional `profile` override out of an opts bag, returning the
65
+ * remaining keys (which become activity args) separately.
66
+ *
67
+ * Without this, a `profile` passed in opts would spread into the activity's
68
+ * **args** rather than set the step's `profile` — a silent no-op on the step's
69
+ * timeout. The activity then runs under the default profile, so a step the
70
+ * author tagged `longInfra` (20m) would still get the 5m default. Routing it
71
+ * here lets every shortcut accept a `profile` override that actually takes.
72
+ */
73
+ function takeProfile(
74
+ opts: Record<string, unknown> | undefined,
75
+ ): { args: Record<string, unknown>; profile?: ActivityStep["profile"] } {
76
+ if (!opts) return { args: {} };
77
+ const { profile, ...args } = opts as { profile?: ActivityStep["profile"] } & Record<string, unknown>;
78
+ return { args, profile };
79
+ }
80
+
63
81
  /** Run `npm run build` (or `chant build`) in the given project directory. */
64
- export const build = (path: string, opts?: Record<string, unknown>): ActivityStep =>
65
- activity("chantBuild", { path, ...opts });
82
+ export const build = (path: string, opts?: Record<string, unknown>): ActivityStep => {
83
+ const { args, profile } = takeProfile(opts);
84
+ return activity("chantBuild", { path, ...args }, profile);
85
+ };
66
86
 
67
- /** Run `kubectl apply -f <manifest>`. Uses `longInfra` profile. */
68
- export const kubectlApply = (manifest: string, opts?: Record<string, unknown>): ActivityStep =>
69
- activity("kubectlApply", { manifest, ...opts }, "longInfra");
87
+ /** Run `kubectl apply -f <manifest>`. Defaults to the `longInfra` profile (override via `opts.profile`). */
88
+ export const kubectlApply = (manifest: string, opts?: Record<string, unknown>): ActivityStep => {
89
+ const { args, profile } = takeProfile(opts);
90
+ return activity("kubectlApply", { manifest, ...args }, profile ?? "longInfra");
91
+ };
70
92
 
71
- /** Run `helm upgrade --install`. Uses `longInfra` profile. */
93
+ /** Run `helm upgrade --install`. Defaults to the `longInfra` profile (override via `opts.profile`). */
72
94
  export const helmInstall = (
73
95
  name: string,
74
96
  chart: string,
75
- opts?: { values?: string; namespace?: string; [k: string]: unknown },
76
- ): ActivityStep => activity("helmInstall", { name, chart, ...opts }, "longInfra");
97
+ opts?: { values?: string; namespace?: string; profile?: ActivityStep["profile"]; [k: string]: unknown },
98
+ ): ActivityStep => {
99
+ const { args, profile } = takeProfile(opts);
100
+ return activity("helmInstall", { name, chart, ...args }, profile ?? "longInfra");
101
+ };
77
102
 
78
- /** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc). Uses `k8sWait` profile. */
79
- export const waitForStack = (name: string, opts?: Record<string, unknown>): ActivityStep =>
80
- activity("waitForStack", { name, ...opts }, "k8sWait");
103
+ /** Poll for stack readiness (kubectl rollout, CloudFormation complete, etc). Defaults to the `k8sWait` profile (override via `opts.profile`). */
104
+ export const waitForStack = (name: string, opts?: Record<string, unknown>): ActivityStep => {
105
+ const { args, profile } = takeProfile(opts);
106
+ return activity("waitForStack", { name, ...args }, profile ?? "k8sWait");
107
+ };
81
108
 
82
- /** Trigger and wait for a GitLab CI pipeline to complete. Uses `longInfra` profile. */
83
- export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep =>
84
- activity("gitlabPipeline", { name, ...opts }, "longInfra");
109
+ /** Trigger and wait for a GitLab CI pipeline to complete. Defaults to the `longInfra` profile (override via `opts.profile`). */
110
+ export const gitlabPipeline = (name: string, opts?: Record<string, unknown>): ActivityStep => {
111
+ const { args, profile } = takeProfile(opts);
112
+ return activity("gitlabPipeline", { name, ...args }, profile ?? "longInfra");
113
+ };
85
114
 
86
115
  /** Take a chant lifecycle snapshot for the given environment. */
87
116
  export const lifecycleSnapshot = (env: string): ActivityStep =>
88
117
  activity("lifecycleSnapshot", { env });
89
118
 
90
- /** Run an arbitrary shell command. */
91
- export const shell = (cmd: string, opts?: { env?: Record<string, string> }): ActivityStep =>
92
- activity("shellCmd", { cmd, ...opts });
119
+ /**
120
+ * Run an arbitrary shell command. Tag long-running commands with a `profile`
121
+ * (e.g. `longInfra` for a multi-GB image push) so they get the right
122
+ * start-to-close timeout under both the local executor and Temporal.
123
+ */
124
+ export const shell = (
125
+ cmd: string,
126
+ opts?: { env?: Record<string, string>; profile?: ActivityStep["profile"] },
127
+ ): ActivityStep =>
128
+ activity("shellCmd", { cmd, ...(opts?.env ? { env: opts.env } : {}) }, opts?.profile);
93
129
 
94
130
  /** Run `chant teardown` in the given project directory. Uses `longInfra` profile. */
95
131
  export const teardown = (path: string): ActivityStep =>
@@ -110,6 +110,22 @@ describe("runOpLocally — retry + timeout", () => {
110
110
  const config = op({ phases: [{ name: "P", steps: [{ kind: "activity", fn: "always" }] }] });
111
111
  await expect(runOpLocally(config, new Map([["always", always]]), PROFILES)).rejects.toBeInstanceOf(OpRunFailure);
112
112
  });
113
+
114
+ test("honors a step's non-default profile timeout (not the default)", async () => {
115
+ // The default profile would time out at 50ms; the step is tagged longInfra,
116
+ // which gives it room. Guards the bug where a profiled step silently got the
117
+ // default cap (local vs --temporal disagreement).
118
+ const slow: ActivityFn = async () => { await new Promise((r) => setTimeout(r, 150)); return "done"; };
119
+ const profiles = {
120
+ fastIdempotent: { startToCloseTimeout: "50ms", retry: { maximumAttempts: 1 } },
121
+ longInfra: { startToCloseTimeout: "5m", retry: { maximumAttempts: 1 } },
122
+ };
123
+ const config = op({
124
+ phases: [{ name: "P", steps: [{ kind: "activity", fn: "slow", profile: "longInfra" }] }],
125
+ });
126
+ const result = await runOpLocally(config, new Map([["slow", slow]]), profiles);
127
+ expect(result.records[0].status).toBe("ok");
128
+ });
113
129
  });
114
130
 
115
131
  describe("runOpLocally — cancellation", () => {
package/src/op/op.test.ts CHANGED
@@ -197,3 +197,40 @@ describe("pre-built shortcuts", () => {
197
197
  expect(a.profile).toBe("longInfra");
198
198
  });
199
199
  });
200
+
201
+ describe("profile routing (opts.profile sets the step profile, never leaks into args)", () => {
202
+ it("shell() routes profile to the step, keeps env in args", () => {
203
+ const a = shell("docker compose push", { env: { TAG: "v1" }, profile: "longInfra" });
204
+ expect(a.profile).toBe("longInfra");
205
+ expect(a.args?.cmd).toBe("docker compose push");
206
+ expect(a.args?.env).toEqual({ TAG: "v1" });
207
+ // The footgun this fixes: profile must NOT end up as an activity arg.
208
+ expect("profile" in (a.args ?? {})).toBe(false);
209
+ });
210
+
211
+ it("shell() without profile leaves the step unprofiled (defaults apply downstream)", () => {
212
+ const a = shell("echo hi", { env: { A: "1" } });
213
+ expect("profile" in a).toBe(false);
214
+ expect(a.args?.env).toEqual({ A: "1" });
215
+ });
216
+
217
+ it("build() accepts a profile override and does not leak it into args", () => {
218
+ const a = build("./p", { profile: "longInfra" });
219
+ expect(a.profile).toBe("longInfra");
220
+ expect(a.args?.path).toBe("./p");
221
+ expect("profile" in (a.args ?? {})).toBe(false);
222
+ });
223
+
224
+ it("kubectlApply() lets opts.profile override the longInfra default", () => {
225
+ const a = kubectlApply("dist/infra.yaml", { profile: "k8sWait" });
226
+ expect(a.profile).toBe("k8sWait");
227
+ expect(a.args?.manifest).toBe("dist/infra.yaml");
228
+ expect("profile" in (a.args ?? {})).toBe(false);
229
+ });
230
+
231
+ it("waitForStack() supports the argoSync profile", () => {
232
+ const a = waitForStack("argo-app", { profile: "argoSync" });
233
+ expect(a.profile).toBe("argoSync");
234
+ expect("profile" in (a.args ?? {})).toBe(false);
235
+ });
236
+ });
package/src/op/types.ts CHANGED
@@ -45,7 +45,7 @@ export interface ActivityStep {
45
45
  * Key from TEMPORAL_ACTIVITY_PROFILES controlling timeout + retry.
46
46
  * Default: "fastIdempotent"
47
47
  */
48
- profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate";
48
+ profile?: "fastIdempotent" | "longInfra" | "k8sWait" | "humanGate" | "argoSync";
49
49
  /**
50
50
  * Surface this activity's return value as a workflow search attribute.
51
51
  *