@intentius/chant 0.1.15 → 0.1.17

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.17",
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",
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { resolve } from "path";
3
+ import { fileURLToPath } from "url";
4
+ import { describeCommand } from "./describe";
5
+
6
+ // The #203 layered-config example: one WebApp instantiated for dev/staging/prod
7
+ // from layered (spread) config. describe v1 projects the effective, resolved
8
+ // config for one named component over it.
9
+ const exampleSrc = resolve(
10
+ fileURLToPath(import.meta.url),
11
+ "../../../../../../lexicons/k8s/examples/layered-config/src",
12
+ );
13
+
14
+ function opts(component: string, format: "text" | "json" = "json") {
15
+ return { component, path: exampleSrc, format };
16
+ }
17
+
18
+ describe("describeCommand (resolved view, v1)", () => {
19
+ test("describes a composite instance by its export name (grouped resources)", async () => {
20
+ const r = await describeCommand(opts("prodApp"));
21
+ expect(r.success).toBe(true);
22
+ expect(r.composite).toBe(true);
23
+ const names = r.resources.map((x) => x.name).sort();
24
+ expect(names).toEqual([
25
+ "prodAppDeployment",
26
+ "prodAppIngress",
27
+ "prodAppPdb",
28
+ "prodAppService",
29
+ ]);
30
+ });
31
+
32
+ test("shows the effective, fully-resolved props (post-merge)", async () => {
33
+ const r = await describeCommand(opts("prodApp"));
34
+ const dep = r.resources.find((x) => x.entityType.endsWith("Deployment"))!;
35
+ const props = dep.props as { spec: { replicas: number }; metadata: { labels: Record<string, string> } };
36
+ // prod overrode replicas; base labels merged with prod-only ones.
37
+ expect(props.spec.replicas).toBe(6);
38
+ expect(props.metadata.labels["app.kubernetes.io/part-of"]).toBe("acme-web"); // base layer
39
+ expect(props.metadata.labels["acme.io/env"]).toBe("prod"); // env layer
40
+ expect(props.metadata.labels["acme.io/tier"]).toBe("critical"); // prod-only
41
+ });
42
+
43
+ test("environments resolve to different effective configs", async () => {
44
+ const dev = await describeCommand(opts("devApp"));
45
+ const devDep = dev.resources.find((x) => x.entityType.endsWith("Deployment"))!;
46
+ const devProps = devDep.props as { spec: { replicas: number }; metadata: { labels: Record<string, string> } };
47
+ expect(devProps.spec.replicas).toBe(2); // base default, not overridden in dev
48
+ expect(devProps.metadata.labels["acme.io/env"]).toBe("dev");
49
+ expect(devProps.metadata.labels["acme.io/tier"]).toBeUndefined(); // prod-only key absent
50
+ // dev declares no ingress; prod does.
51
+ expect(dev.resources.some((x) => x.entityType.endsWith("Ingress"))).toBe(false);
52
+ });
53
+
54
+ test("describes a single declarable by exact name (not a composite)", async () => {
55
+ const r = await describeCommand(opts("devAppService"));
56
+ expect(r.success).toBe(true);
57
+ expect(r.composite).toBe(false);
58
+ expect(r.resources).toHaveLength(1);
59
+ expect(r.resources[0].entityType).toBe("K8s::Core::Service");
60
+ });
61
+
62
+ test("unknown component fails with the known-component list", async () => {
63
+ const r = await describeCommand(opts("doesNotExist"));
64
+ expect(r.success).toBe(false);
65
+ expect(r.output).toContain("No component");
66
+ expect(r.output).toContain("prodAppDeployment");
67
+ });
68
+
69
+ test("text format renders the component header and props", async () => {
70
+ const r = await describeCommand(opts("prodApp", "text"));
71
+ expect(r.output).toContain("prodApp");
72
+ expect(r.output).toContain("K8s::Apps::Deployment");
73
+ expect(r.output).toContain("web-prod");
74
+ });
75
+ });
@@ -0,0 +1,141 @@
1
+ import { resolve } from "path";
2
+ import { discover } from "../../discovery/index";
3
+ import type { Declarable } from "../../declarable";
4
+ import { formatSuccess, formatBold } from "../format";
5
+
6
+ /**
7
+ * `chant describe <component>` — project the effective, fully-resolved
8
+ * configuration for one named component, offline (source only; no live calls).
9
+ *
10
+ * `chant list` shows inventory and `chant graph` shows edges; neither shows the
11
+ * resolved property bag for a single thing. `describe` does: for a layered
12
+ * config (see the layered-config guide) it answers "what is the effective config
13
+ * for this component, after every spread/override?" without merging the layers
14
+ * by hand.
15
+ */
16
+ export interface DescribeOptions {
17
+ /** The component to describe — a named export (declarable or composite instance). */
18
+ component: string;
19
+ /** Path to the infrastructure directory. */
20
+ path: string;
21
+ /** Output format. */
22
+ format: "text" | "json";
23
+ }
24
+
25
+ /** One resolved resource produced by the component. */
26
+ export interface DescribedResource {
27
+ name: string;
28
+ lexicon: string;
29
+ entityType: string;
30
+ kind: string;
31
+ /** The resolved (post-merge, post-composite-expansion) property bag. */
32
+ props: unknown;
33
+ }
34
+
35
+ export interface DescribeResult {
36
+ success: boolean;
37
+ component: string;
38
+ /** True when the component is a composite instance (≥1 produced resource by prefix). */
39
+ composite: boolean;
40
+ resources: DescribedResource[];
41
+ output: string;
42
+ }
43
+
44
+ /** Read a discovered entity's resolved props (same accessor the build pipeline uses). */
45
+ function readProps(entity: Declarable): unknown {
46
+ return "props" in entity ? (entity as { props: unknown }).props : undefined;
47
+ }
48
+
49
+ /**
50
+ * Find the entities a component name refers to. A composite instance is
51
+ * flattened at discovery: `WebApp(x)` exported as `foo` becomes `fooDeployment`,
52
+ * `fooService`, … — so an exact-name miss falls back to grouping entities whose
53
+ * name is the export prefix followed by a capitalized result key.
54
+ */
55
+ function matchComponent(
56
+ entities: Map<string, Declarable>,
57
+ component: string,
58
+ ): { matched: Array<[string, Declarable]>; composite: boolean } {
59
+ const exact = entities.get(component);
60
+ if (exact) return { matched: [[component, exact]], composite: false };
61
+
62
+ const grouped = [...entities].filter(
63
+ ([n]) =>
64
+ n.length > component.length &&
65
+ n.startsWith(component) &&
66
+ n.charAt(component.length) === n.charAt(component.length).toUpperCase() &&
67
+ n.charAt(component.length) !== n.charAt(component.length).toLowerCase(),
68
+ );
69
+ grouped.sort((a, b) => a[0].localeCompare(b[0]));
70
+ return { matched: grouped, composite: grouped.length > 0 };
71
+ }
72
+
73
+ export async function describeCommand(options: DescribeOptions): Promise<DescribeResult> {
74
+ const infraPath = resolve(options.path);
75
+ const result = await discover(infraPath);
76
+
77
+ if (result.errors.length > 0) {
78
+ return {
79
+ success: false,
80
+ component: options.component,
81
+ composite: false,
82
+ resources: [],
83
+ output: result.errors.map((e) => e.message).join("\n"),
84
+ };
85
+ }
86
+
87
+ const { matched, composite } = matchComponent(result.entities, options.component);
88
+
89
+ if (matched.length === 0) {
90
+ const known = [...result.entities.keys()].sort().join(", ");
91
+ return {
92
+ success: false,
93
+ component: options.component,
94
+ composite: false,
95
+ resources: [],
96
+ output: `No component "${options.component}" found.\nKnown components: ${known || "(none)"}`,
97
+ };
98
+ }
99
+
100
+ const resources: DescribedResource[] = matched.map(([name, decl]) => ({
101
+ name,
102
+ lexicon: decl.lexicon ?? "",
103
+ entityType: decl.entityType ?? "",
104
+ kind: decl.kind ?? "resource",
105
+ props: readProps(decl),
106
+ }));
107
+
108
+ const output =
109
+ options.format === "json"
110
+ ? JSON.stringify({ component: options.component, composite, resources }, null, 2)
111
+ : formatText(options.component, composite, resources);
112
+
113
+ return { success: true, component: options.component, composite, resources, output };
114
+ }
115
+
116
+ /** Human-readable effective-config view. */
117
+ function formatText(component: string, composite: boolean, resources: DescribedResource[]): string {
118
+ const lines: string[] = [];
119
+ const header = composite
120
+ ? `${formatBold(component)} — composite instance, ${resources.length} resource(s)`
121
+ : `${formatBold(component)}`;
122
+ lines.push(header);
123
+ for (const r of resources) {
124
+ lines.push("");
125
+ lines.push(` ${formatBold(r.name)} (${r.entityType})`);
126
+ const propsJson = JSON.stringify(r.props ?? {}, null, 2)
127
+ .split("\n")
128
+ .map((l) => ` ${l}`)
129
+ .join("\n");
130
+ lines.push(propsJson);
131
+ }
132
+ return lines.join("\n");
133
+ }
134
+
135
+ /** Print a describe result to the console. */
136
+ export function printDescribeResult(result: DescribeResult): void {
137
+ if (result.output) console.log(result.output);
138
+ if (result.success) {
139
+ console.error(formatSuccess(`Described ${formatBold(result.component)}`));
140
+ }
141
+ }
@@ -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;
@@ -1,4 +1,5 @@
1
1
  import { listCommand, printListResult } from "../commands/list";
2
+ import { describeCommand, printDescribeResult } from "../commands/describe";
2
3
  import { importCommand, importFromLive, printImportResult } from "../commands/import";
3
4
  import type { ResourceSelector } from "../../lexicon";
4
5
  import { formatError, formatSuccess, formatWarning } from "../format";
@@ -21,6 +22,35 @@ export async function runList(ctx: CommandContext): Promise<number> {
21
22
  return result.success ? 0 : 1;
22
23
  }
23
24
 
25
+ export async function runDescribe(ctx: CommandContext): Promise<number> {
26
+ const { args } = ctx;
27
+ // `chant describe <component> [path]` — component is the first positional
28
+ // (args.path), the optional project dir is the second (args.extraPositional).
29
+ const component = args.path;
30
+ if (!component || component === ".") {
31
+ console.error(formatError({
32
+ message: "Component is required: chant describe <component> [path]",
33
+ hint: "Run `chant list` to see component names.",
34
+ }));
35
+ return 1;
36
+ }
37
+
38
+ const describeFormat = (args.format || "text") as "text" | "json";
39
+ if (describeFormat !== "text" && describeFormat !== "json") {
40
+ console.error(formatError({ message: `Invalid format for describe: ${describeFormat}. Expected 'text' or 'json'.` }));
41
+ return 1;
42
+ }
43
+
44
+ const result = await describeCommand({
45
+ component,
46
+ path: args.extraPositional ?? ".",
47
+ format: describeFormat,
48
+ });
49
+
50
+ printDescribeResult(result);
51
+ return result.success ? 0 : 1;
52
+ }
53
+
24
54
  export async function runImport(ctx: CommandContext): Promise<number> {
25
55
  const { args } = ctx;
26
56
 
package/src/cli/main.ts CHANGED
@@ -11,7 +11,7 @@ import { runLint } from "./handlers/lint";
11
11
  import { runDevGenerate, runDevPublish, runDevOnboard, runDevCheckLexicon, runDevUnknown } from "./handlers/dev";
12
12
  import { runServeLsp, runServeMcp, runServeUnknown } from "./handlers/serve";
13
13
  import { runInit, runInitLexicon } from "./handlers/init";
14
- import { runList, runImport, runUpdate, runDoctor } from "./handlers/misc";
14
+ import { runList, runDescribe, runImport, runUpdate, runDoctor } from "./handlers/misc";
15
15
  import { runMigrate } from "./handlers/migrate";
16
16
  import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecyclePlan, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
17
17
  import { runGraph } from "./handlers/graph";
@@ -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") {
@@ -151,6 +154,7 @@ Commands:
151
154
  build Build infrastructure from specification files
152
155
  lint Check specifications for issues
153
156
  list List discovered entities
157
+ describe Show the effective config for one component
154
158
  import Import external template into TypeScript
155
159
  migrate <file> Translate a workflow between lexicons
156
160
  (default: --from github --to gitlab)
@@ -227,6 +231,8 @@ Examples:
227
231
  chant lint ./infra/ --watch
228
232
  chant list ./infra/
229
233
  chant list ./infra/ --format json
234
+ chant describe myComponent src/
235
+ chant describe myComponent src/ --format json
230
236
  `);
231
237
  }
232
238
 
@@ -262,6 +268,7 @@ const registry: CommandDef[] = [
262
268
  { name: "build", requiresPlugins: true, handler: runBuild },
263
269
  { name: "lint", handler: runLint },
264
270
  { name: "list", handler: runList },
271
+ { name: "describe", handler: runDescribe },
265
272
  { name: "import", handler: runImport },
266
273
  { name: "migrate", handler: runMigrate },
267
274
  { name: "init", handler: runInit },
@@ -332,9 +339,14 @@ async function main(): Promise<void> {
332
339
  process.exit(1);
333
340
  }
334
341
 
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;
342
+ // For compound commands (e.g. "run list", "lifecycle plan <env>"), the first
343
+ // positional is a subcommand argument an environment, op, or lexicon name
344
+ // not a project path. Plugins always load from the cwd; the handler reads its
345
+ // own positionals from args.extraPositional. Using extraPositional as the path
346
+ // here pointed plugin resolution at e.g. "./local" for `lifecycle plan local`,
347
+ // which then fell through to import-detection on an empty file set and failed
348
+ // with "No lexicon detected" even though chant.config.ts lists the lexicons.
349
+ const projectPath = match.compound ? "." : args.path;
338
350
  const plugins = match.def.requiresPlugins
339
351
  ? await loadPluginsOrExit(projectPath)
340
352
  : [];
@@ -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
  *