kubepile 0.0.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/README.md +87 -0
- package/dist/package.json +49 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +83 -0
- package/dist/src/kubepile.d.ts +68 -0
- package/dist/src/kubepile.js +271 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Kubepile
|
|
2
|
+
|
|
3
|
+
Have you ever tried to maintain a Kubernetes config for multiple clusters, and
|
|
4
|
+
multiple Kubernetes providers? It's gross! It's hard to visually track which
|
|
5
|
+
users, clusters, and contexts relate to each other, and as you add and remove
|
|
6
|
+
clusters your config inevitably bloats into a mess and becomes hard to reason
|
|
7
|
+
about.
|
|
8
|
+
|
|
9
|
+

|
|
11
|
+
|
|
12
|
+
Kubepile lets you maintain individual, per-provider kubeconfigs in a
|
|
13
|
+
`~/.config/kubepile` directory, and compile them into a single, merged
|
|
14
|
+
kubeconfig.
|
|
15
|
+
|
|
16
|
+
Each `*.yaml` file is a normal kubeconfig. You can paste in kubeconfigs from
|
|
17
|
+
providers without converting them to a kubepile-specific schema. During
|
|
18
|
+
`compile`, kubepile reads every file and merges its `clusters`, `users`, and
|
|
19
|
+
`contexts` directly into the generated kubeconfig.
|
|
20
|
+
|
|
21
|
+
Kubepile automatically ensures the following:
|
|
22
|
+
|
|
23
|
+
- No kubepile files set a `current-context`.
|
|
24
|
+
- No cluster, user, or context names clash.
|
|
25
|
+
|
|
26
|
+
If a new file is added that clashes or sets a `current-context`, kubepile will
|
|
27
|
+
intentionally fail compilation with a helpful message explaining which file
|
|
28
|
+
broke the kubepile rules.
|
|
29
|
+
|
|
30
|
+
Kubepile will never set a `current-context`, out of the design belief that
|
|
31
|
+
`current-context` is a dangerous footgun in multi-cluster setups.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm install -g kubepile
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Compile
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
kubepile compile
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This reads `~/.config/kubepile/*.yaml`, then writes `~/.kube/config`. If
|
|
46
|
+
`~/.kube/config` already exists, `kubepile compile` prompts before copying it to
|
|
47
|
+
`~/.kube/config.bak`.
|
|
48
|
+
|
|
49
|
+
Explicit command and options:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
kubepile compile --config-dir ~/.config/kubepile --output ~/.kube/config
|
|
53
|
+
kubepile compile --backup
|
|
54
|
+
kubepile compile --no-backup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Running `kubepile` with no command prints help.
|
|
58
|
+
|
|
59
|
+
## Split
|
|
60
|
+
|
|
61
|
+
Do you already have a giant unmaintainable mess of a kubeconfig? No worries!
|
|
62
|
+
Kubepile ships a `split` command that auto-splits your existing kubeconfig into
|
|
63
|
+
separate per-context kubepile config files, and tells you on the command line
|
|
64
|
+
if you have unsplittable configs due to impossible settings from config drift —
|
|
65
|
+
and which keys exactly are the problem, so you can clean up your config before
|
|
66
|
+
splitting it.
|
|
67
|
+
|
|
68
|
+
To split your config, run:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
kubepile split
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
This reads `~/.kube/config` and writes one kubeconfig per context into
|
|
75
|
+
`~/.config/kubepile`.
|
|
76
|
+
|
|
77
|
+
If there are errors in your kubeconfig that prevent splitting, it'll tell you
|
|
78
|
+
what they are.
|
|
79
|
+
|
|
80
|
+
You can optionally override the source kubeconfig and the output kubepile
|
|
81
|
+
directory. These are the defaults:
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
kubepile split --source ~/.kube/config --output-dir ~/.config/kubepile
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Context names that are not safe as filenames are percent-encoded when split.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kubepile",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Compile and split kubeconfig files from ~/.config/kubepile.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/src/kubepile.js",
|
|
7
|
+
"types": "./dist/src/kubepile.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/src/kubepile.d.ts",
|
|
11
|
+
"import": "./dist/src/kubepile.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"kubepile": "./dist/src/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/package.json",
|
|
19
|
+
"dist/src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"package.json"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"clean": "rm -rf dist",
|
|
25
|
+
"build": "npm run clean && tsc",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run --dir test",
|
|
28
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm test"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"kubernetes",
|
|
32
|
+
"kubeconfig",
|
|
33
|
+
"kubectl"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.12.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@commander-js/extra-typings": "^15.0.0",
|
|
41
|
+
"commander": "^15.0.0",
|
|
42
|
+
"yaml": "^2.9.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.9.3",
|
|
46
|
+
"typescript": "^6.0.3",
|
|
47
|
+
"vitest": "^4.1.8"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { Command } from "@commander-js/extra-typings";
|
|
6
|
+
import packageJson from "../package.json" with { type: "json" };
|
|
7
|
+
import { compileToKubeConfig, defaultKubeConfigPath, defaultKubepileDir, splitKubeConfigFile, } from "./kubepile.js";
|
|
8
|
+
export async function runCli(argv) {
|
|
9
|
+
const program = createProgram();
|
|
10
|
+
if (argv.length === 0) {
|
|
11
|
+
program.outputHelp();
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
await program.parseAsync(argv, { from: "user" });
|
|
15
|
+
}
|
|
16
|
+
async function askBackup(existingPath, backupPath) {
|
|
17
|
+
const rl = createInterface({
|
|
18
|
+
input: process.stdin,
|
|
19
|
+
output: process.stderr,
|
|
20
|
+
});
|
|
21
|
+
try {
|
|
22
|
+
const answer = await rl.question(`Existing kubeconfig found at ${existingPath}. Back it up to ${backupPath}? [y/N] `);
|
|
23
|
+
return /^(y|yes)$/i.test(answer.trim());
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
rl.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function createProgram() {
|
|
30
|
+
const program = new Command()
|
|
31
|
+
.name("kubepile")
|
|
32
|
+
.description("Compile and split kubeconfig files.")
|
|
33
|
+
.version(packageJson.version)
|
|
34
|
+
.showHelpAfterError()
|
|
35
|
+
.allowExcessArguments(false)
|
|
36
|
+
.addHelpText("after", `
|
|
37
|
+
|
|
38
|
+
Defaults:
|
|
39
|
+
config dir: ${defaultKubepileDir()}
|
|
40
|
+
kubeconfig: ${defaultKubeConfigPath()}`);
|
|
41
|
+
program
|
|
42
|
+
.command("compile")
|
|
43
|
+
.description("Compile ~/.config/kubepile/*.yaml into ~/.kube/config.")
|
|
44
|
+
.option("--config-dir <dir>", "directory containing kubeconfig YAML files")
|
|
45
|
+
.option("--input-dir <dir>", "alias for --config-dir")
|
|
46
|
+
.option("--output <file>", "output kubeconfig path")
|
|
47
|
+
.option("--backup", "back up an existing output file without prompting")
|
|
48
|
+
.option("--no-backup", "replace an existing output file without prompting")
|
|
49
|
+
.action(async (options) => {
|
|
50
|
+
const result = await compileToKubeConfig({
|
|
51
|
+
inputDir: options.configDir ?? options.inputDir,
|
|
52
|
+
outputPath: options.output,
|
|
53
|
+
shouldBackup: options.backup === undefined
|
|
54
|
+
? askBackup
|
|
55
|
+
: () => options.backup === true,
|
|
56
|
+
});
|
|
57
|
+
if (result.backedUpTo) {
|
|
58
|
+
process.stdout.write(`Backed up existing kubeconfig to ${result.backedUpTo}\n`);
|
|
59
|
+
}
|
|
60
|
+
process.stdout.write(`Compiled ${result.inputFiles.length} kubeconfig file(s) into ${result.outputPath}\n`);
|
|
61
|
+
});
|
|
62
|
+
program
|
|
63
|
+
.command("split")
|
|
64
|
+
.description("Split an existing kubeconfig into one file per context.")
|
|
65
|
+
.option("--source <file>", "source kubeconfig path")
|
|
66
|
+
.option("--output-dir <dir>", "directory to write split kubeconfigs")
|
|
67
|
+
.action(async (options) => {
|
|
68
|
+
const result = await splitKubeConfigFile({
|
|
69
|
+
sourcePath: options.source,
|
|
70
|
+
outputDir: options.outputDir,
|
|
71
|
+
});
|
|
72
|
+
process.stdout.write(`Wrote ${result.writtenFiles.length} kubeconfig file(s) into ${result.outputDir}\n`);
|
|
73
|
+
});
|
|
74
|
+
return program;
|
|
75
|
+
}
|
|
76
|
+
const entryPoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : undefined;
|
|
77
|
+
if (import.meta.url === entryPoint) {
|
|
78
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
79
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
80
|
+
process.stderr.write(`kubepile: ${message}\n`);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export interface NamedCluster {
|
|
2
|
+
name: string;
|
|
3
|
+
cluster: Record<string, unknown>;
|
|
4
|
+
}
|
|
5
|
+
export interface NamedUser {
|
|
6
|
+
name: string;
|
|
7
|
+
user: Record<string, unknown>;
|
|
8
|
+
}
|
|
9
|
+
export interface KubeContext {
|
|
10
|
+
cluster: string;
|
|
11
|
+
user?: string;
|
|
12
|
+
namespace?: string;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface NamedContext {
|
|
16
|
+
name: string;
|
|
17
|
+
context: KubeContext;
|
|
18
|
+
}
|
|
19
|
+
export interface KubeConfig {
|
|
20
|
+
apiVersion?: string;
|
|
21
|
+
kind?: string;
|
|
22
|
+
preferences?: Record<string, unknown>;
|
|
23
|
+
clusters?: NamedCluster[];
|
|
24
|
+
users?: NamedUser[];
|
|
25
|
+
contexts?: NamedContext[];
|
|
26
|
+
"current-context"?: string;
|
|
27
|
+
[key: string]: unknown;
|
|
28
|
+
}
|
|
29
|
+
export interface CompileOptions {
|
|
30
|
+
inputDir?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface CompileToFileOptions extends CompileOptions {
|
|
33
|
+
outputPath?: string;
|
|
34
|
+
shouldBackup?: (existingPath: string, backupPath: string) => Promise<boolean> | boolean;
|
|
35
|
+
}
|
|
36
|
+
export interface CompileResult {
|
|
37
|
+
config: KubeConfig;
|
|
38
|
+
inputFiles: string[];
|
|
39
|
+
outputPath: string;
|
|
40
|
+
backedUpTo?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface SplitOptions {
|
|
43
|
+
sourcePath?: string;
|
|
44
|
+
outputDir?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface SplitConfig {
|
|
47
|
+
contextName: string;
|
|
48
|
+
fileName: string;
|
|
49
|
+
config: KubeConfig;
|
|
50
|
+
}
|
|
51
|
+
export interface SplitResult {
|
|
52
|
+
outputDir: string;
|
|
53
|
+
writtenFiles: string[];
|
|
54
|
+
}
|
|
55
|
+
export declare function defaultKubepileDir(): string;
|
|
56
|
+
export declare function defaultKubeConfigPath(): string;
|
|
57
|
+
export declare function buildMergedConfig(options?: CompileOptions): Promise<{
|
|
58
|
+
config: KubeConfig;
|
|
59
|
+
inputFiles: string[];
|
|
60
|
+
}>;
|
|
61
|
+
export declare function compileToKubeConfig(options?: CompileToFileOptions): Promise<CompileResult>;
|
|
62
|
+
export declare function splitKubeConfigFile(options?: SplitOptions): Promise<SplitResult>;
|
|
63
|
+
export declare function splitKubeConfig(config: KubeConfig, sourceLabel?: string): SplitConfig[];
|
|
64
|
+
export declare function readKubeConfigFile(filePath: string): Promise<KubeConfig>;
|
|
65
|
+
export declare function parseKubeConfig(source: string, sourceLabel?: string): KubeConfig;
|
|
66
|
+
export declare function serializeKubeConfig(config: KubeConfig): string;
|
|
67
|
+
export declare function serializeGeneratedKubeConfig(config: KubeConfig, inputDir?: string): string;
|
|
68
|
+
export declare function fileNameForContext(contextName: string): string;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, copyFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { parse, stringify } from "yaml";
|
|
5
|
+
export function defaultKubepileDir() {
|
|
6
|
+
return path.join(os.homedir(), ".config", "kubepile");
|
|
7
|
+
}
|
|
8
|
+
export function defaultKubeConfigPath() {
|
|
9
|
+
return path.join(os.homedir(), ".kube", "config");
|
|
10
|
+
}
|
|
11
|
+
export async function buildMergedConfig(options = {}) {
|
|
12
|
+
const inputDir = options.inputDir ?? defaultKubepileDir();
|
|
13
|
+
const inputFiles = await listKubeConfigFiles(inputDir);
|
|
14
|
+
const configs = await Promise.all(inputFiles.map(async (filePath) => ({
|
|
15
|
+
filePath,
|
|
16
|
+
config: await readKubeConfigFile(filePath),
|
|
17
|
+
})));
|
|
18
|
+
const seenNames = {
|
|
19
|
+
clusters: new Set(),
|
|
20
|
+
users: new Set(),
|
|
21
|
+
contexts: new Set(),
|
|
22
|
+
};
|
|
23
|
+
const merged = {
|
|
24
|
+
apiVersion: "v1",
|
|
25
|
+
kind: "Config",
|
|
26
|
+
preferences: {},
|
|
27
|
+
clusters: [],
|
|
28
|
+
users: [],
|
|
29
|
+
contexts: [],
|
|
30
|
+
};
|
|
31
|
+
for (const source of configs) {
|
|
32
|
+
appendSourceConfig(merged, source.config, source.filePath, seenNames);
|
|
33
|
+
}
|
|
34
|
+
delete merged["current-context"];
|
|
35
|
+
return { config: merged, inputFiles };
|
|
36
|
+
}
|
|
37
|
+
export async function compileToKubeConfig(options = {}) {
|
|
38
|
+
const inputDir = options.inputDir ?? defaultKubepileDir();
|
|
39
|
+
const outputPath = options.outputPath ?? defaultKubeConfigPath();
|
|
40
|
+
const backupPath = `${outputPath}.bak`;
|
|
41
|
+
const { config, inputFiles } = await buildMergedConfig({ ...options, inputDir });
|
|
42
|
+
let backedUpTo;
|
|
43
|
+
if (await pathExists(outputPath)) {
|
|
44
|
+
const shouldBackup = await options.shouldBackup?.(outputPath, backupPath);
|
|
45
|
+
if (shouldBackup) {
|
|
46
|
+
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
47
|
+
await copyFile(outputPath, backupPath);
|
|
48
|
+
backedUpTo = backupPath;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
52
|
+
await writeFile(outputPath, serializeGeneratedKubeConfig(config, inputDir), "utf8");
|
|
53
|
+
return { config, inputFiles, outputPath, backedUpTo };
|
|
54
|
+
}
|
|
55
|
+
async function pathExists(filePath) {
|
|
56
|
+
try {
|
|
57
|
+
await stat(filePath);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (error.code === "ENOENT") {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export async function splitKubeConfigFile(options = {}) {
|
|
68
|
+
const sourcePath = options.sourcePath ?? defaultKubeConfigPath();
|
|
69
|
+
const outputDir = options.outputDir ?? defaultKubepileDir();
|
|
70
|
+
const sourceConfig = await readKubeConfigFile(sourcePath);
|
|
71
|
+
const splitConfigs = splitKubeConfig(sourceConfig, sourcePath);
|
|
72
|
+
const writtenFiles = [];
|
|
73
|
+
await mkdir(outputDir, { recursive: true });
|
|
74
|
+
for (const splitConfig of splitConfigs) {
|
|
75
|
+
const outputPath = path.join(outputDir, splitConfig.fileName);
|
|
76
|
+
await writeFile(outputPath, serializeKubeConfig(splitConfig.config), "utf8");
|
|
77
|
+
writtenFiles.push(outputPath);
|
|
78
|
+
}
|
|
79
|
+
return { outputDir, writtenFiles };
|
|
80
|
+
}
|
|
81
|
+
export function splitKubeConfig(config, sourceLabel = "kubeconfig") {
|
|
82
|
+
const contexts = getNamedContexts(config, sourceLabel);
|
|
83
|
+
const clusters = getNamedClusters(config, sourceLabel);
|
|
84
|
+
const users = getNamedUsers(config, sourceLabel);
|
|
85
|
+
validateAndTrackUniqueNamedEntries(clusters, "cluster", sourceLabel, new Set());
|
|
86
|
+
validateAndTrackUniqueNamedEntries(users, "user", sourceLabel, new Set());
|
|
87
|
+
validateAndTrackUniqueNamedEntries(contexts, "context", sourceLabel, new Set());
|
|
88
|
+
return contexts.map((context) => {
|
|
89
|
+
const contextName = getNonEmptyString(context.name, `${sourceLabel} context name`);
|
|
90
|
+
const contextBody = getContextBody(context, sourceLabel);
|
|
91
|
+
const clusterName = getNonEmptyString(contextBody.cluster, `${sourceLabel} context "${contextName}" cluster`);
|
|
92
|
+
const userName = getOptionalString(contextBody.user, `${sourceLabel} context "${contextName}" user`);
|
|
93
|
+
const cluster = findNamedEntry(clusters, clusterName, "cluster", sourceLabel);
|
|
94
|
+
const user = userName
|
|
95
|
+
? findNamedEntry(users, userName, "user", sourceLabel)
|
|
96
|
+
: undefined;
|
|
97
|
+
const splitContext = deepClone(contextBody);
|
|
98
|
+
return {
|
|
99
|
+
contextName,
|
|
100
|
+
fileName: fileNameForContext(contextName),
|
|
101
|
+
config: {
|
|
102
|
+
apiVersion: "v1",
|
|
103
|
+
kind: "Config",
|
|
104
|
+
preferences: deepClone(config.preferences ?? {}),
|
|
105
|
+
clusters: [deepClone(cluster)],
|
|
106
|
+
users: user ? [deepClone(user)] : [],
|
|
107
|
+
contexts: [
|
|
108
|
+
{
|
|
109
|
+
name: contextName,
|
|
110
|
+
context: splitContext,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
export async function readKubeConfigFile(filePath) {
|
|
118
|
+
const source = await readFile(filePath, "utf8");
|
|
119
|
+
return parseKubeConfig(source, filePath);
|
|
120
|
+
}
|
|
121
|
+
export function parseKubeConfig(source, sourceLabel = "kubeconfig") {
|
|
122
|
+
const parsed = parse(source);
|
|
123
|
+
if (!isRecord(parsed)) {
|
|
124
|
+
throw new Error(`${sourceLabel} must be a YAML object`);
|
|
125
|
+
}
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
export function serializeKubeConfig(config) {
|
|
129
|
+
return stringify(config, { lineWidth: 0 });
|
|
130
|
+
}
|
|
131
|
+
export function serializeGeneratedKubeConfig(config, inputDir = defaultKubepileDir()) {
|
|
132
|
+
return `${generatedKubeConfigHeader(inputDir)}${serializeKubeConfig(config)}`;
|
|
133
|
+
}
|
|
134
|
+
export function fileNameForContext(contextName) {
|
|
135
|
+
const safeName = getNonEmptyString(contextName, "context name");
|
|
136
|
+
if (/^[A-Za-z0-9._-]+$/.test(safeName)) {
|
|
137
|
+
return `${safeName}.yaml`;
|
|
138
|
+
}
|
|
139
|
+
return `${encodeURIComponent(safeName)}.yaml`;
|
|
140
|
+
}
|
|
141
|
+
function generatedKubeConfigHeader(inputDir) {
|
|
142
|
+
const exampleConfigPath = path.join(inputDir, "dev.yaml");
|
|
143
|
+
const compileCommand = inputDir === defaultKubepileDir()
|
|
144
|
+
? "kubepile compile"
|
|
145
|
+
: `kubepile compile --config-dir ${shellQuote(inputDir)}`;
|
|
146
|
+
return [
|
|
147
|
+
"# GENERATED BY KUBEPILE: DO NOT MODIFY",
|
|
148
|
+
"#",
|
|
149
|
+
"# To add a kubepile config:",
|
|
150
|
+
`# 1. Save a kubeconfig file in ${inputDir}.`,
|
|
151
|
+
`# Example: ${exampleConfigPath}`,
|
|
152
|
+
"# 2. Rebuild this generated config with:",
|
|
153
|
+
`# ${compileCommand}`,
|
|
154
|
+
"",
|
|
155
|
+
"",
|
|
156
|
+
].join("\n");
|
|
157
|
+
}
|
|
158
|
+
function shellQuote(value) {
|
|
159
|
+
if (/^[A-Za-z0-9_./:=@%+-]+$/.test(value)) {
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
163
|
+
}
|
|
164
|
+
async function listKubeConfigFiles(inputDir) {
|
|
165
|
+
try {
|
|
166
|
+
const inputStat = await stat(inputDir);
|
|
167
|
+
if (!inputStat.isDirectory()) {
|
|
168
|
+
throw new Error(`Config path is not a directory: ${inputDir}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
if (error.code === "ENOENT") {
|
|
173
|
+
throw new Error(`Config directory does not exist: ${inputDir}`);
|
|
174
|
+
}
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
const entries = await readdir(inputDir, { withFileTypes: true });
|
|
178
|
+
const unsupportedYamlFiles = entries
|
|
179
|
+
.filter((entry) => entry.isFile() && /\.ya?ml$/i.test(entry.name) && !entry.name.endsWith(".yaml"))
|
|
180
|
+
.map((entry) => entry.name)
|
|
181
|
+
.sort((a, b) => a.localeCompare(b));
|
|
182
|
+
if (unsupportedYamlFiles.length > 0) {
|
|
183
|
+
throw new Error(`Unsupported kubeconfig file extension in ${inputDir}: ${unsupportedYamlFiles.join(", ")}. Use .yaml only.`);
|
|
184
|
+
}
|
|
185
|
+
const files = entries
|
|
186
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".yaml"))
|
|
187
|
+
.map((entry) => path.join(inputDir, entry.name))
|
|
188
|
+
.sort((a, b) => a.localeCompare(b));
|
|
189
|
+
if (files.length === 0) {
|
|
190
|
+
throw new Error(`No kubeconfig files found in ${inputDir}`);
|
|
191
|
+
}
|
|
192
|
+
return files;
|
|
193
|
+
}
|
|
194
|
+
function appendSourceConfig(merged, sourceConfig, sourceLabel, seenNames) {
|
|
195
|
+
rejectCurrentContext(sourceConfig, sourceLabel);
|
|
196
|
+
const sourceClusters = getNamedClusters(sourceConfig, sourceLabel);
|
|
197
|
+
const sourceUsers = getNamedUsers(sourceConfig, sourceLabel);
|
|
198
|
+
const sourceContexts = getNamedContexts(sourceConfig, sourceLabel);
|
|
199
|
+
validateAndTrackUniqueNamedEntries(sourceClusters, "cluster", sourceLabel, seenNames.clusters);
|
|
200
|
+
validateAndTrackUniqueNamedEntries(sourceUsers, "user", sourceLabel, seenNames.users);
|
|
201
|
+
validateAndTrackUniqueNamedEntries(sourceContexts, "context", sourceLabel, seenNames.contexts);
|
|
202
|
+
merged.clusters?.push(...deepClone(sourceClusters));
|
|
203
|
+
merged.users?.push(...deepClone(sourceUsers));
|
|
204
|
+
merged.contexts?.push(...deepClone(sourceContexts));
|
|
205
|
+
}
|
|
206
|
+
function rejectCurrentContext(config, sourceLabel) {
|
|
207
|
+
if (Object.hasOwn(config, "current-context")) {
|
|
208
|
+
throw new Error(`${sourceLabel} must not set current-context`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
function getNamedClusters(config, sourceLabel) {
|
|
212
|
+
return getArray(config.clusters, `${sourceLabel} clusters`);
|
|
213
|
+
}
|
|
214
|
+
function getNamedUsers(config, sourceLabel) {
|
|
215
|
+
return getArray(config.users, `${sourceLabel} users`);
|
|
216
|
+
}
|
|
217
|
+
function getNamedContexts(config, sourceLabel) {
|
|
218
|
+
return getArray(config.contexts, `${sourceLabel} contexts`);
|
|
219
|
+
}
|
|
220
|
+
function getArray(value, label) {
|
|
221
|
+
if (value === undefined) {
|
|
222
|
+
return [];
|
|
223
|
+
}
|
|
224
|
+
if (!Array.isArray(value)) {
|
|
225
|
+
throw new Error(`${label} must be an array`);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
function validateAndTrackUniqueNamedEntries(entries, entryType, sourceLabel, seen) {
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
const name = getNonEmptyString(entry.name, `${sourceLabel} ${entryType} name`);
|
|
232
|
+
if (seen.has(name)) {
|
|
233
|
+
throw new Error(`Duplicate ${entryType} name "${name}" found in ${sourceLabel}`);
|
|
234
|
+
}
|
|
235
|
+
seen.add(name);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function getContextBody(context, sourceLabel) {
|
|
239
|
+
if (!isRecord(context) || !isRecord(context.context)) {
|
|
240
|
+
throw new Error(`${sourceLabel} context "${String(context.name)}" must contain a context object`);
|
|
241
|
+
}
|
|
242
|
+
return context.context;
|
|
243
|
+
}
|
|
244
|
+
function findNamedEntry(entries, name, entryType, sourceLabel) {
|
|
245
|
+
const entry = entries.find((candidate) => candidate.name === name);
|
|
246
|
+
if (!entry) {
|
|
247
|
+
throw new Error(`${sourceLabel} references missing ${entryType} "${name}"`);
|
|
248
|
+
}
|
|
249
|
+
return entry;
|
|
250
|
+
}
|
|
251
|
+
function getNonEmptyString(value, label) {
|
|
252
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
253
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
254
|
+
}
|
|
255
|
+
return value;
|
|
256
|
+
}
|
|
257
|
+
function getOptionalString(value, label) {
|
|
258
|
+
if (value === undefined) {
|
|
259
|
+
return undefined;
|
|
260
|
+
}
|
|
261
|
+
if (typeof value !== "string") {
|
|
262
|
+
throw new Error(`${label} must be a string`);
|
|
263
|
+
}
|
|
264
|
+
return value.length > 0 ? value : undefined;
|
|
265
|
+
}
|
|
266
|
+
function isRecord(value) {
|
|
267
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
268
|
+
}
|
|
269
|
+
function deepClone(value) {
|
|
270
|
+
return JSON.parse(JSON.stringify(value));
|
|
271
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kubepile",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Compile and split kubeconfig files from ~/.config/kubepile.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/src/kubepile.js",
|
|
7
|
+
"types": "./dist/src/kubepile.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/src/kubepile.d.ts",
|
|
11
|
+
"import": "./dist/src/kubepile.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"kubepile": "./dist/src/cli.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/package.json",
|
|
19
|
+
"dist/src",
|
|
20
|
+
"README.md",
|
|
21
|
+
"package.json"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"clean": "rm -rf dist",
|
|
25
|
+
"build": "npm run clean && tsc",
|
|
26
|
+
"typecheck": "tsc --noEmit",
|
|
27
|
+
"test": "vitest run --dir test",
|
|
28
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm test"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"kubernetes",
|
|
32
|
+
"kubeconfig",
|
|
33
|
+
"kubectl"
|
|
34
|
+
],
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=22.12.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@commander-js/extra-typings": "^15.0.0",
|
|
41
|
+
"commander": "^15.0.0",
|
|
42
|
+
"yaml": "^2.9.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.9.3",
|
|
46
|
+
"typescript": "^6.0.3",
|
|
47
|
+
"vitest": "^4.1.8"
|
|
48
|
+
}
|
|
49
|
+
}
|