@f5xc-salesdemos/pi-resource-management 19.30.1
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 +57 -0
- package/src/arg-parser.ts +73 -0
- package/src/diff-engine.ts +160 -0
- package/src/file-reader.ts +163 -0
- package/src/index.ts +34 -0
- package/src/kind-resolver.ts +157 -0
- package/src/manifest-parser.ts +72 -0
- package/src/manifest-validator.ts +109 -0
- package/src/output-formatter.ts +209 -0
- package/src/resource-client.ts +373 -0
- package/src/types.ts +149 -0
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "@f5xc-salesdemos/pi-resource-management",
|
|
4
|
+
"version": "19.30.1",
|
|
5
|
+
"description": "Shared resource management for F5 XC manifest parsing, validation, diff, and CRUD operations",
|
|
6
|
+
"homepage": "https://github.com/f5xc-salesdemos/xcsh",
|
|
7
|
+
"author": "Robin Mordasiewicz",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/f5xc-salesdemos/xcsh.git",
|
|
12
|
+
"directory": "packages/resource-management"
|
|
13
|
+
},
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/f5xc-salesdemos/xcsh/issues"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"f5xc",
|
|
19
|
+
"resource-management",
|
|
20
|
+
"kubectl",
|
|
21
|
+
"apply",
|
|
22
|
+
"manifest"
|
|
23
|
+
],
|
|
24
|
+
"main": "./src/index.ts",
|
|
25
|
+
"types": "./src/index.ts",
|
|
26
|
+
"scripts": {
|
|
27
|
+
"check": "biome check . && bun run check:types",
|
|
28
|
+
"check:types": "tsgo -p tsconfig.json --noEmit",
|
|
29
|
+
"lint": "biome lint .",
|
|
30
|
+
"test": "bun test",
|
|
31
|
+
"fix": "biome check --write --unsafe .",
|
|
32
|
+
"fmt": "biome format --write ."
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"yaml": "2.9.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/bun": "^1.3"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"bun": ">=1.3.7"
|
|
42
|
+
},
|
|
43
|
+
"files": [
|
|
44
|
+
"src"
|
|
45
|
+
],
|
|
46
|
+
"exports": {
|
|
47
|
+
".": {
|
|
48
|
+
"types": "./src/index.ts",
|
|
49
|
+
"import": "./src/index.ts"
|
|
50
|
+
},
|
|
51
|
+
"./*": {
|
|
52
|
+
"types": "./src/*.ts",
|
|
53
|
+
"import": "./src/*.ts"
|
|
54
|
+
},
|
|
55
|
+
"./*.js": "./src/*.ts"
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { ParsedResourceArgs } from "./types";
|
|
2
|
+
|
|
3
|
+
const VALID_OUTPUT_FORMATS = new Set(["json", "yaml", "table", "wide"]);
|
|
4
|
+
const VALID_DRY_RUN_MODES = new Set(["client", "server"]);
|
|
5
|
+
|
|
6
|
+
export function parseResourceArgs(argsString: string): ParsedResourceArgs | { error: string } {
|
|
7
|
+
const tokens = argsString.split(/\s+/).filter(Boolean);
|
|
8
|
+
const filenames: string[] = [];
|
|
9
|
+
let namespace: string | undefined;
|
|
10
|
+
let outputFormat: ParsedResourceArgs["outputFormat"] = "table";
|
|
11
|
+
let dryRun: ParsedResourceArgs["dryRun"];
|
|
12
|
+
let recursive = false;
|
|
13
|
+
let force = false;
|
|
14
|
+
const positionals: string[] = [];
|
|
15
|
+
|
|
16
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
17
|
+
const token = tokens[i];
|
|
18
|
+
|
|
19
|
+
if (token === "-f" || token === "--filename") {
|
|
20
|
+
if (i + 1 >= tokens.length) return { error: `${token} requires a file path.` };
|
|
21
|
+
filenames.push(tokens[++i]);
|
|
22
|
+
} else if (token.startsWith("-f") && token.length > 2 && token[2] !== "-") {
|
|
23
|
+
filenames.push(token.slice(2));
|
|
24
|
+
} else if (token === "-n" || token === "--namespace") {
|
|
25
|
+
if (i + 1 >= tokens.length) return { error: `${token} requires a namespace value.` };
|
|
26
|
+
namespace = tokens[++i];
|
|
27
|
+
} else if (token.startsWith("-n") && token.length > 2 && token[2] !== "-") {
|
|
28
|
+
namespace = token.slice(2);
|
|
29
|
+
} else if (token === "-o" || token === "--output") {
|
|
30
|
+
if (i + 1 >= tokens.length) return { error: `${token} requires a format value (json, yaml, table, wide).` };
|
|
31
|
+
const fmt = tokens[++i];
|
|
32
|
+
if (!VALID_OUTPUT_FORMATS.has(fmt)) {
|
|
33
|
+
return { error: `Invalid output format: "${fmt}". Must be one of: json, yaml, table, wide.` };
|
|
34
|
+
}
|
|
35
|
+
outputFormat = fmt as ParsedResourceArgs["outputFormat"];
|
|
36
|
+
} else if (token.startsWith("-o") && token.length > 2 && token[2] !== "-") {
|
|
37
|
+
const fmt = token.slice(2);
|
|
38
|
+
if (!VALID_OUTPUT_FORMATS.has(fmt)) {
|
|
39
|
+
return { error: `Invalid output format: "${fmt}". Must be one of: json, yaml, table, wide.` };
|
|
40
|
+
}
|
|
41
|
+
outputFormat = fmt as ParsedResourceArgs["outputFormat"];
|
|
42
|
+
} else if (token.startsWith("--dry-run")) {
|
|
43
|
+
if (token.includes("=")) {
|
|
44
|
+
const mode = token.split("=")[1];
|
|
45
|
+
if (!VALID_DRY_RUN_MODES.has(mode)) {
|
|
46
|
+
return { error: `Invalid --dry-run mode: "${mode}". Must be "client" or "server".` };
|
|
47
|
+
}
|
|
48
|
+
dryRun = mode as ParsedResourceArgs["dryRun"];
|
|
49
|
+
} else {
|
|
50
|
+
dryRun = "client";
|
|
51
|
+
}
|
|
52
|
+
} else if (token === "-R" || token === "--recursive") {
|
|
53
|
+
recursive = true;
|
|
54
|
+
} else if (token === "--force") {
|
|
55
|
+
force = true;
|
|
56
|
+
} else if (token.startsWith("-")) {
|
|
57
|
+
return { error: `Unknown flag: "${token}".` };
|
|
58
|
+
} else {
|
|
59
|
+
positionals.push(token);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
filenames,
|
|
65
|
+
namespace,
|
|
66
|
+
outputFormat,
|
|
67
|
+
dryRun,
|
|
68
|
+
recursive,
|
|
69
|
+
force,
|
|
70
|
+
kind: positionals[0],
|
|
71
|
+
name: positionals[1],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import type { DiffEntry, ResourceDiff } from "./types";
|
|
2
|
+
|
|
3
|
+
export function computeResourceDiff(current: Record<string, unknown>, desired: Record<string, unknown>): ResourceDiff {
|
|
4
|
+
const added: DiffEntry[] = [];
|
|
5
|
+
const removed: DiffEntry[] = [];
|
|
6
|
+
const changed: DiffEntry[] = [];
|
|
7
|
+
let unchangedCount = 0;
|
|
8
|
+
|
|
9
|
+
diffObjects(current, desired, "", added, removed, changed, { count: 0 });
|
|
10
|
+
unchangedCount =
|
|
11
|
+
countLeafKeys(current) + countLeafKeys(desired) - added.length - removed.length - changed.length * 2;
|
|
12
|
+
if (unchangedCount < 0) unchangedCount = 0;
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
hasDifferences: added.length > 0 || removed.length > 0 || changed.length > 0,
|
|
16
|
+
added,
|
|
17
|
+
removed,
|
|
18
|
+
changed,
|
|
19
|
+
unchangedCount,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function diffObjects(
|
|
24
|
+
current: unknown,
|
|
25
|
+
desired: unknown,
|
|
26
|
+
prefix: string,
|
|
27
|
+
added: DiffEntry[],
|
|
28
|
+
removed: DiffEntry[],
|
|
29
|
+
changed: DiffEntry[],
|
|
30
|
+
_ctx: { count: number },
|
|
31
|
+
): void {
|
|
32
|
+
if (current === desired) return;
|
|
33
|
+
if (current === undefined || current === null) {
|
|
34
|
+
if (desired !== undefined && desired !== null) {
|
|
35
|
+
added.push({ path: prefix || "(root)", newValue: desired });
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (desired === undefined || desired === null) {
|
|
40
|
+
removed.push({ path: prefix || "(root)", oldValue: current });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (Array.isArray(current) && Array.isArray(desired)) {
|
|
45
|
+
diffArrays(current, desired, prefix, added, removed, changed, _ctx);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (
|
|
50
|
+
typeof current === "object" &&
|
|
51
|
+
typeof desired === "object" &&
|
|
52
|
+
!Array.isArray(current) &&
|
|
53
|
+
!Array.isArray(desired)
|
|
54
|
+
) {
|
|
55
|
+
const curObj = current as Record<string, unknown>;
|
|
56
|
+
const desObj = desired as Record<string, unknown>;
|
|
57
|
+
const allKeys = new Set([...Object.keys(curObj), ...Object.keys(desObj)]);
|
|
58
|
+
|
|
59
|
+
for (const key of allKeys) {
|
|
60
|
+
const childPath = prefix ? `${prefix}.${key}` : key;
|
|
61
|
+
const curVal = curObj[key];
|
|
62
|
+
const desVal = desObj[key];
|
|
63
|
+
|
|
64
|
+
if (!(key in curObj)) {
|
|
65
|
+
added.push({ path: childPath, newValue: desVal });
|
|
66
|
+
} else if (!(key in desObj)) {
|
|
67
|
+
removed.push({ path: childPath, oldValue: curVal });
|
|
68
|
+
} else {
|
|
69
|
+
diffObjects(curVal, desVal, childPath, added, removed, changed, _ctx);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!deepEqual(current, desired)) {
|
|
76
|
+
changed.push({ path: prefix || "(root)", oldValue: current, newValue: desired });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function diffArrays(
|
|
81
|
+
current: unknown[],
|
|
82
|
+
desired: unknown[],
|
|
83
|
+
prefix: string,
|
|
84
|
+
added: DiffEntry[],
|
|
85
|
+
removed: DiffEntry[],
|
|
86
|
+
changed: DiffEntry[],
|
|
87
|
+
_ctx: { count: number },
|
|
88
|
+
): void {
|
|
89
|
+
const maxLen = Math.max(current.length, desired.length);
|
|
90
|
+
for (let i = 0; i < maxLen; i++) {
|
|
91
|
+
const childPath = `${prefix}[${i}]`;
|
|
92
|
+
if (i >= current.length) {
|
|
93
|
+
added.push({ path: childPath, newValue: desired[i] });
|
|
94
|
+
} else if (i >= desired.length) {
|
|
95
|
+
removed.push({ path: childPath, oldValue: current[i] });
|
|
96
|
+
} else {
|
|
97
|
+
diffObjects(current[i], desired[i], childPath, added, removed, changed, _ctx);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
103
|
+
if (a === b) return true;
|
|
104
|
+
if (a == null || b == null) return a === b;
|
|
105
|
+
if (typeof a !== typeof b) return false;
|
|
106
|
+
if (typeof a !== "object") return false;
|
|
107
|
+
|
|
108
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
109
|
+
if (a.length !== b.length) return false;
|
|
110
|
+
return a.every((val, i) => deepEqual(val, b[i]));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (Array.isArray(a) || Array.isArray(b)) return false;
|
|
114
|
+
|
|
115
|
+
const aObj = a as Record<string, unknown>;
|
|
116
|
+
const bObj = b as Record<string, unknown>;
|
|
117
|
+
const aKeys = Object.keys(aObj);
|
|
118
|
+
const bKeys = Object.keys(bObj);
|
|
119
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
120
|
+
return aKeys.every(key => key in bObj && deepEqual(aObj[key], bObj[key]));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function countLeafKeys(obj: unknown): number {
|
|
124
|
+
if (obj == null || typeof obj !== "object") return 1;
|
|
125
|
+
if (Array.isArray(obj)) return obj.reduce((sum, item) => sum + countLeafKeys(item), 0);
|
|
126
|
+
return Object.values(obj as Record<string, unknown>).reduce((sum: number, val) => sum + countLeafKeys(val), 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function formatDiff(diff: ResourceDiff, kind: string, name: string): string {
|
|
130
|
+
if (!diff.hasDifferences) {
|
|
131
|
+
return `${kind}/${name}: no changes detected.`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const lines: string[] = [];
|
|
135
|
+
lines.push(`diff ${kind}/${name}`);
|
|
136
|
+
|
|
137
|
+
for (const entry of diff.removed) {
|
|
138
|
+
lines.push(`- ${entry.path}: ${formatValue(entry.oldValue)}`);
|
|
139
|
+
}
|
|
140
|
+
for (const added of diff.added) {
|
|
141
|
+
lines.push(`+ ${added.path}: ${formatValue(added.newValue)}`);
|
|
142
|
+
}
|
|
143
|
+
for (const entry of diff.changed) {
|
|
144
|
+
lines.push(`~ ${entry.path}: ${formatValue(entry.oldValue)} → ${formatValue(entry.newValue)}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lines.push(` ${diff.unchangedCount} field(s) unchanged`);
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function formatValue(value: unknown): string {
|
|
152
|
+
if (value === undefined) return "<undefined>";
|
|
153
|
+
if (value === null) return "null";
|
|
154
|
+
if (typeof value === "string") return `"${value}"`;
|
|
155
|
+
if (typeof value === "object") {
|
|
156
|
+
const json = JSON.stringify(value);
|
|
157
|
+
return json.length > 80 ? `${json.slice(0, 77)}...` : json;
|
|
158
|
+
}
|
|
159
|
+
return String(value);
|
|
160
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { parseAllDocuments } from "yaml";
|
|
4
|
+
|
|
5
|
+
const JSON_EXTENSIONS = new Set([".json"]);
|
|
6
|
+
const YAML_EXTENSIONS = new Set([".yaml", ".yml"]);
|
|
7
|
+
const SUPPORTED_EXTENSIONS = new Set([...JSON_EXTENSIONS, ...YAML_EXTENSIONS]);
|
|
8
|
+
|
|
9
|
+
export interface FileReadResult {
|
|
10
|
+
objects: Record<string, unknown>[];
|
|
11
|
+
sourcePath: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readManifestFiles(filenames: string[], recursive: boolean): Promise<FileReadResult[]> {
|
|
15
|
+
const results: FileReadResult[] = [];
|
|
16
|
+
for (const filename of filenames) {
|
|
17
|
+
if (filename === "-") {
|
|
18
|
+
results.push(await readFromStdin());
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const resolved = path.resolve(filename);
|
|
23
|
+
const stat = await fs.promises.stat(resolved).catch(() => null);
|
|
24
|
+
if (!stat) {
|
|
25
|
+
throw new ManifestFileError(`File not found: ${filename}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (stat.isDirectory()) {
|
|
29
|
+
const files = await collectDirectoryFiles(resolved, recursive);
|
|
30
|
+
for (const file of files) {
|
|
31
|
+
results.push(await readSingleFile(file));
|
|
32
|
+
}
|
|
33
|
+
} else {
|
|
34
|
+
results.push(await readSingleFile(resolved));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return results;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function readSingleFile(filePath: string): Promise<FileReadResult> {
|
|
41
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
42
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
43
|
+
|
|
44
|
+
if (JSON_EXTENSIONS.has(ext)) {
|
|
45
|
+
return { objects: parseJsonContent(content, filePath), sourcePath: filePath };
|
|
46
|
+
}
|
|
47
|
+
if (YAML_EXTENSIONS.has(ext)) {
|
|
48
|
+
return { objects: parseYamlContent(content, filePath), sourcePath: filePath };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return { objects: parseAutoDetect(content, filePath), sourcePath: filePath };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseJsonContent(content: string, sourcePath: string): Record<string, unknown>[] {
|
|
55
|
+
const trimmed = content.trim();
|
|
56
|
+
if (!trimmed) return [];
|
|
57
|
+
try {
|
|
58
|
+
const parsed = JSON.parse(trimmed);
|
|
59
|
+
if (Array.isArray(parsed)) {
|
|
60
|
+
return parsed.filter((item): item is Record<string, unknown> => item != null && typeof item === "object");
|
|
61
|
+
}
|
|
62
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
63
|
+
return [parsed as Record<string, unknown>];
|
|
64
|
+
}
|
|
65
|
+
throw new ManifestFileError(`Expected object or array in ${sourcePath}, got ${typeof parsed}`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err instanceof ManifestFileError) throw err;
|
|
68
|
+
throw new ManifestFileError(`Invalid JSON in ${sourcePath}: ${(err as Error).message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseYamlContent(content: string, sourcePath: string): Record<string, unknown>[] {
|
|
73
|
+
const trimmed = content.trim();
|
|
74
|
+
if (!trimmed) return [];
|
|
75
|
+
try {
|
|
76
|
+
const docs = parseAllDocuments(trimmed);
|
|
77
|
+
const objects: Record<string, unknown>[] = [];
|
|
78
|
+
for (const doc of docs) {
|
|
79
|
+
if (doc.errors.length > 0) {
|
|
80
|
+
const firstError = doc.errors[0];
|
|
81
|
+
throw new ManifestFileError(`Invalid YAML in ${sourcePath}: ${firstError.message}`);
|
|
82
|
+
}
|
|
83
|
+
const value = doc.toJSON();
|
|
84
|
+
if (value != null && typeof value === "object" && !Array.isArray(value)) {
|
|
85
|
+
objects.push(value as Record<string, unknown>);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return objects;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
if (err instanceof ManifestFileError) throw err;
|
|
91
|
+
throw new ManifestFileError(`Invalid YAML in ${sourcePath}: ${(err as Error).message}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseAutoDetect(content: string, sourcePath: string): Record<string, unknown>[] {
|
|
96
|
+
const trimmed = content.trim();
|
|
97
|
+
if (!trimmed) return [];
|
|
98
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
99
|
+
return parseJsonContent(content, sourcePath);
|
|
100
|
+
}
|
|
101
|
+
return parseYamlContent(content, sourcePath);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function collectDirectoryFiles(dirPath: string, recursive: boolean): Promise<string[]> {
|
|
105
|
+
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
106
|
+
const files: string[] = [];
|
|
107
|
+
|
|
108
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
109
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
110
|
+
if (entry.isFile()) {
|
|
111
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
112
|
+
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
113
|
+
files.push(fullPath);
|
|
114
|
+
}
|
|
115
|
+
} else if (entry.isDirectory() && recursive) {
|
|
116
|
+
files.push(...(await collectDirectoryFiles(fullPath, recursive)));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return files;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function readFromStdin(): Promise<FileReadResult> {
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const chunks: Buffer[] = [];
|
|
125
|
+
let timedOut = false;
|
|
126
|
+
|
|
127
|
+
const timer = setTimeout(() => {
|
|
128
|
+
timedOut = true;
|
|
129
|
+
if (chunks.length === 0) {
|
|
130
|
+
reject(new ManifestFileError("No data received from stdin (timed out after 5s)."));
|
|
131
|
+
}
|
|
132
|
+
}, 5000);
|
|
133
|
+
|
|
134
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
135
|
+
chunks.push(chunk);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
process.stdin.on("end", () => {
|
|
139
|
+
clearTimeout(timer);
|
|
140
|
+
if (timedOut && chunks.length === 0) return;
|
|
141
|
+
const content = Buffer.concat(chunks).toString("utf-8");
|
|
142
|
+
try {
|
|
143
|
+
resolve({ objects: parseAutoDetect(content, "stdin"), sourcePath: "stdin" });
|
|
144
|
+
} catch (err) {
|
|
145
|
+
reject(err);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
process.stdin.on("error", err => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
reject(new ManifestFileError(`Failed to read from stdin: ${err.message}`));
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
process.stdin.resume();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export class ManifestFileError extends Error {
|
|
159
|
+
constructor(message: string) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.name = "ManifestFileError";
|
|
162
|
+
}
|
|
163
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export { parseResourceArgs } from "./arg-parser";
|
|
2
|
+
export { computeResourceDiff, formatDiff } from "./diff-engine";
|
|
3
|
+
export { ManifestFileError, readManifestFiles } from "./file-reader";
|
|
4
|
+
export { createKindResolver, KindResolutionError } from "./kind-resolver";
|
|
5
|
+
export { ManifestParseError, parseManifests } from "./manifest-parser";
|
|
6
|
+
export { formatValidationErrors, validateManifest, validateManifests } from "./manifest-validator";
|
|
7
|
+
export {
|
|
8
|
+
formatMultiOperationSummary,
|
|
9
|
+
formatOperationResult,
|
|
10
|
+
formatResourceDetail,
|
|
11
|
+
formatResourceList,
|
|
12
|
+
} from "./output-formatter";
|
|
13
|
+
export { ResourceClient } from "./resource-client";
|
|
14
|
+
export type {
|
|
15
|
+
ApiSpecDomainEntry,
|
|
16
|
+
ApiSpecDomainResource,
|
|
17
|
+
ApiSpecIndex,
|
|
18
|
+
ApiSpecValidationResourceEntry,
|
|
19
|
+
DiffEntry,
|
|
20
|
+
KindResolver,
|
|
21
|
+
ManifestValidationResult,
|
|
22
|
+
OperationResult,
|
|
23
|
+
ParsedResourceArgs,
|
|
24
|
+
ResolvedKind,
|
|
25
|
+
ResourceClientOptions,
|
|
26
|
+
ResourceDiff,
|
|
27
|
+
ResourceError,
|
|
28
|
+
ResourceErrorKind,
|
|
29
|
+
ResourceManifest,
|
|
30
|
+
ValidationError,
|
|
31
|
+
ValidationErrorCode,
|
|
32
|
+
ValidationWarning,
|
|
33
|
+
ValidationWarningCode,
|
|
34
|
+
} from "./types";
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ApiSpecDomainResource,
|
|
3
|
+
ApiSpecIndex,
|
|
4
|
+
ApiSpecValidationResourceEntry,
|
|
5
|
+
KindResolver,
|
|
6
|
+
ResolvedKind,
|
|
7
|
+
} from "./types";
|
|
8
|
+
|
|
9
|
+
export class KindResolutionError extends Error {
|
|
10
|
+
readonly suggestions: string[];
|
|
11
|
+
constructor(message: string, suggestions: string[] = []) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "KindResolutionError";
|
|
14
|
+
this.suggestions = suggestions;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createKindResolver(
|
|
19
|
+
specIndex: ApiSpecIndex,
|
|
20
|
+
validationData?: Readonly<Record<string, ApiSpecValidationResourceEntry>>,
|
|
21
|
+
): KindResolver {
|
|
22
|
+
function resolveKind(kind: string): ResolvedKind {
|
|
23
|
+
let foundWithoutPaths = false;
|
|
24
|
+
for (const domain of specIndex.domains) {
|
|
25
|
+
for (const resource of domain.resources) {
|
|
26
|
+
if (resource.name === kind) {
|
|
27
|
+
const paths = extractPaths(resource);
|
|
28
|
+
if (!paths) {
|
|
29
|
+
foundWithoutPaths = true;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
kind,
|
|
34
|
+
domain: domain.domain,
|
|
35
|
+
resource,
|
|
36
|
+
paths,
|
|
37
|
+
validation: validationData?.[kind],
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (foundWithoutPaths) {
|
|
44
|
+
throw new KindResolutionError(`Resource kind "${kind}" exists but has no CRUD API paths defined.`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const allKinds = getAllKnownKinds();
|
|
48
|
+
const suggestions = findSimilarKinds(kind, allKinds).slice(0, 5);
|
|
49
|
+
const suggestionText = suggestions.length > 0 ? ` Did you mean: ${suggestions.join(", ")}?` : "";
|
|
50
|
+
throw new KindResolutionError(`Unknown resource kind: "${kind}".${suggestionText}`, suggestions);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getAllKnownKinds(): string[] {
|
|
54
|
+
const kinds: string[] = [];
|
|
55
|
+
for (const domain of specIndex.domains) {
|
|
56
|
+
for (const resource of domain.resources) {
|
|
57
|
+
kinds.push(resource.name);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return kinds.sort();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getKindsWithApiPaths(): string[] {
|
|
64
|
+
const kinds: string[] = [];
|
|
65
|
+
for (const domain of specIndex.domains) {
|
|
66
|
+
for (const resource of domain.resources) {
|
|
67
|
+
if (resource.apiPaths && resource.apiPaths.length > 0) {
|
|
68
|
+
kinds.push(resource.name);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return kinds.sort();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { resolveKind, getAllKnownKinds, getKindsWithApiPaths };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractPaths(resource: ApiSpecDomainResource): ResolvedKind["paths"] | null {
|
|
79
|
+
if (!resource.apiPaths || resource.apiPaths.length === 0) return null;
|
|
80
|
+
|
|
81
|
+
let listPath: string | undefined;
|
|
82
|
+
let itemPath: string | undefined;
|
|
83
|
+
|
|
84
|
+
for (const apiPath of resource.apiPaths) {
|
|
85
|
+
const normalized = normalizePathPlaceholders(apiPath);
|
|
86
|
+
const hasNamespace = normalized.includes("{namespace}");
|
|
87
|
+
const hasName = normalized.includes("{name}");
|
|
88
|
+
|
|
89
|
+
if (hasNamespace && !hasName && !listPath) {
|
|
90
|
+
listPath = normalized;
|
|
91
|
+
} else if (hasNamespace && hasName && !itemPath) {
|
|
92
|
+
itemPath = normalized;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!listPath && !itemPath) {
|
|
97
|
+
for (const apiPath of resource.apiPaths) {
|
|
98
|
+
const normalized = normalizePathPlaceholders(apiPath);
|
|
99
|
+
if (!normalized.includes("{name}") && !listPath) {
|
|
100
|
+
listPath = normalized;
|
|
101
|
+
} else if (normalized.includes("{name}") && !itemPath) {
|
|
102
|
+
itemPath = normalized;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!listPath) return null;
|
|
108
|
+
if (!itemPath && listPath) {
|
|
109
|
+
itemPath = `${listPath}/{name}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
list: listPath,
|
|
114
|
+
get: itemPath!,
|
|
115
|
+
create: listPath,
|
|
116
|
+
update: itemPath!,
|
|
117
|
+
delete: itemPath!,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizePathPlaceholders(apiPath: string): string {
|
|
122
|
+
return apiPath.replace(/\{metadata\.namespace\}/g, "{namespace}").replace(/\{metadata\.name\}/g, "{name}");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findSimilarKinds(input: string, candidates: string[]): string[] {
|
|
126
|
+
const scored = candidates.map(candidate => ({
|
|
127
|
+
kind: candidate,
|
|
128
|
+
score: similarityScore(input, candidate),
|
|
129
|
+
}));
|
|
130
|
+
return scored
|
|
131
|
+
.filter(s => s.score > 0.3)
|
|
132
|
+
.sort((a, b) => b.score - a.score)
|
|
133
|
+
.map(s => s.kind);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function similarityScore(a: string, b: string): number {
|
|
137
|
+
const la = a.toLowerCase();
|
|
138
|
+
const lb = b.toLowerCase();
|
|
139
|
+
|
|
140
|
+
if (la === lb) return 1;
|
|
141
|
+
if (lb.startsWith(la) || la.startsWith(lb)) return 0.8;
|
|
142
|
+
if (lb.includes(la) || la.includes(lb)) return 0.6;
|
|
143
|
+
|
|
144
|
+
const aParts = la.split("_");
|
|
145
|
+
const bParts = lb.split("_");
|
|
146
|
+
let matchCount = 0;
|
|
147
|
+
for (const ap of aParts) {
|
|
148
|
+
if (bParts.some(bp => bp === ap || bp.startsWith(ap) || ap.startsWith(bp))) {
|
|
149
|
+
matchCount++;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (aParts.length > 0 && matchCount > 0) {
|
|
153
|
+
return (matchCount / Math.max(aParts.length, bParts.length)) * 0.5;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return 0;
|
|
157
|
+
}
|