@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 +1 -1
- package/src/cli/commands/describe.test.ts +75 -0
- package/src/cli/commands/describe.ts +141 -0
- package/src/cli/handlers/misc.ts +30 -0
- package/src/cli/main.ts +5 -1
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/cli/handlers/misc.ts
CHANGED
|
@@ -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 },
|