@intentius/chant 0.1.16 → 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.16",
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 { 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";
@@ -154,6 +154,7 @@ Commands:
154
154
  build Build infrastructure from specification files
155
155
  lint Check specifications for issues
156
156
  list List discovered entities
157
+ describe Show the effective config for one component
157
158
  import Import external template into TypeScript
158
159
  migrate <file> Translate a workflow between lexicons
159
160
  (default: --from github --to gitlab)
@@ -230,6 +231,8 @@ Examples:
230
231
  chant lint ./infra/ --watch
231
232
  chant list ./infra/
232
233
  chant list ./infra/ --format json
234
+ chant describe myComponent src/
235
+ chant describe myComponent src/ --format json
233
236
  `);
234
237
  }
235
238
 
@@ -265,6 +268,7 @@ const registry: CommandDef[] = [
265
268
  { name: "build", requiresPlugins: true, handler: runBuild },
266
269
  { name: "lint", handler: runLint },
267
270
  { name: "list", handler: runList },
271
+ { name: "describe", handler: runDescribe },
268
272
  { name: "import", handler: runImport },
269
273
  { name: "migrate", handler: runMigrate },
270
274
  { name: "init", handler: runInit },