Skip to content

Commit 0090e4f

Browse files
committed
feat: implement kubectl-like commands for kubernetesjs CLI
1 parent 559b940 commit 0090e4f

File tree

15 files changed

+1735
-7
lines changed

15 files changed

+1735
-7
lines changed

packages/cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434
"test:watch": "jest --watch",
3535
"dev": "ts-node src/index.ts"
3636
},
37+
"devDependencies": {
38+
"@types/js-yaml": "^4.0.9"
39+
},
3740
"dependencies": {
3841
"chalk": "^4.1.0",
3942
"deepmerge": "^4.3.1",

packages/cli/src/commands.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,46 @@ import { extractFirst, usageText } from './utils';
66

77
// Commands
88
import deploy from './commands/deploy';
9+
import get from './commands/get';
10+
import describe from './commands/describe';
11+
import logs from './commands/logs';
12+
import apply from './commands/apply';
13+
import deleteCmd from './commands/delete';
14+
import exec from './commands/exec';
15+
import portForward from './commands/port-forward';
16+
import clusterInfo from './commands/cluster-info';
17+
import config from './commands/config';
918

1019
const commandMap: Record<string, Function> = {
11-
deploy
20+
deploy,
21+
get,
22+
describe,
23+
logs,
24+
apply,
25+
delete: deleteCmd,
26+
exec,
27+
'port-forward': portForward,
28+
'cluster-info': clusterInfo,
29+
config
1230
};
1331

32+
import configHandler from './commands/config-handler';
33+
1434
export const commands = async (argv: Partial<ParsedArgs>, prompter: Inquirerer, options: CLIOptions) => {
1535
if (argv.version || argv.v) {
1636
const pkg = readAndParsePackageJson();
1737
console.log(pkg.version);
1838
process.exit(0);
1939
}
2040

41+
if (argv.config) {
42+
const handled = await configHandler(argv, prompter, options, commandMap);
43+
if (handled) {
44+
prompter.close();
45+
return argv;
46+
}
47+
}
48+
2149
let { first: command, newArgv } = extractFirst(argv);
2250

2351
// Show usage if explicitly requested

packages/cli/src/commands/apply.ts

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { CLIOptions, Inquirerer, Question } from 'inquirerer';
2+
import { ParsedArgs } from 'minimist';
3+
import chalk from 'chalk';
4+
import { KubernetesClient } from 'kubernetesjs';
5+
import { readYamlFile, inferResourceType } from '../config';
6+
import * as fs from 'fs';
7+
import * as path from 'path';
8+
9+
async function promptYamlFilePath(prompter: Inquirerer, argv: Partial<ParsedArgs>): Promise<string> {
10+
const question: Question = {
11+
type: 'text',
12+
name: 'filePath',
13+
message: 'Enter path to YAML file',
14+
required: true
15+
};
16+
17+
const { filePath } = await prompter.prompt(argv, [question]);
18+
return filePath;
19+
}
20+
21+
async function applyResource(client: KubernetesClient, resource: any, namespace: string): Promise<void> {
22+
const kind = resource.kind.toLowerCase();
23+
const name = resource.metadata?.name;
24+
25+
if (!name) {
26+
throw new Error('Resource must have a name');
27+
}
28+
29+
console.log(chalk.blue(`Applying ${kind} "${name}" in namespace ${namespace}...`));
30+
31+
try {
32+
switch (kind) {
33+
case 'deployment':
34+
await client.createAppsV1NamespacedDeployment({
35+
path: { namespace },
36+
query: {
37+
pretty: 'true',
38+
fieldManager: 'kubernetesjs-cli'
39+
},
40+
body: resource
41+
});
42+
console.log(chalk.green(`Deployment "${name}" created/updated successfully`));
43+
break;
44+
45+
case 'service':
46+
await client.createCoreV1NamespacedService({
47+
path: { namespace },
48+
query: {
49+
pretty: 'true',
50+
fieldManager: 'kubernetesjs-cli'
51+
},
52+
body: resource
53+
});
54+
console.log(chalk.green(`Service "${name}" created/updated successfully`));
55+
break;
56+
57+
case 'pod':
58+
await client.createCoreV1NamespacedPod({
59+
path: { namespace },
60+
query: {
61+
pretty: 'true',
62+
fieldManager: 'kubernetesjs-cli'
63+
},
64+
body: resource
65+
});
66+
console.log(chalk.green(`Pod "${name}" created/updated successfully`));
67+
break;
68+
69+
case 'configmap':
70+
await client.createCoreV1NamespacedConfigMap({
71+
path: { namespace },
72+
query: {
73+
pretty: 'true',
74+
fieldManager: 'kubernetesjs-cli'
75+
},
76+
body: resource
77+
});
78+
console.log(chalk.green(`ConfigMap "${name}" created/updated successfully`));
79+
break;
80+
81+
case 'secret':
82+
await client.createCoreV1NamespacedSecret({
83+
path: { namespace },
84+
query: {
85+
pretty: 'true',
86+
fieldManager: 'kubernetesjs-cli'
87+
},
88+
body: resource
89+
});
90+
console.log(chalk.green(`Secret "${name}" created/updated successfully`));
91+
break;
92+
93+
default:
94+
console.log(chalk.yellow(`Resource kind "${kind}" not implemented yet`));
95+
}
96+
} catch (error) {
97+
console.error(chalk.red(`Error applying ${kind} "${name}": ${error}`));
98+
throw error;
99+
}
100+
}
101+
102+
export default async (
103+
argv: Partial<ParsedArgs>,
104+
prompter: Inquirerer,
105+
_options: CLIOptions
106+
) => {
107+
try {
108+
const client = new KubernetesClient({
109+
restEndpoint: 'http://localhost:8001' // Default kube-proxy endpoint
110+
});
111+
112+
const filePath = argv.f || argv._?.[0] || await promptYamlFilePath(prompter, argv);
113+
114+
if (!filePath) {
115+
console.error(chalk.red('No file path provided'));
116+
return;
117+
}
118+
119+
if (!fs.existsSync(filePath)) {
120+
console.error(chalk.red(`File not found: ${filePath}`));
121+
return;
122+
}
123+
124+
let resources: any[];
125+
try {
126+
const content = readYamlFile(filePath);
127+
128+
if (Array.isArray(content)) {
129+
resources = content;
130+
} else if (content.kind === 'List' && Array.isArray(content.items)) {
131+
resources = content.items;
132+
} else {
133+
resources = [content];
134+
}
135+
} catch (error) {
136+
console.error(chalk.red(`Error parsing YAML file: ${error}`));
137+
return;
138+
}
139+
140+
for (const resource of resources) {
141+
try {
142+
const namespace = resource.metadata?.namespace || argv.n || argv.namespace || 'default';
143+
await applyResource(client, resource, namespace);
144+
} catch (error) {
145+
console.error(chalk.red(`Failed to apply resource: ${error}`));
146+
}
147+
}
148+
149+
console.log(chalk.green('Apply completed'));
150+
} catch (error) {
151+
console.error(chalk.red(`Error: ${error}`));
152+
}
153+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { CLIOptions, Inquirerer } from 'inquirerer';
2+
import { ParsedArgs } from 'minimist';
3+
import chalk from 'chalk';
4+
import { KubernetesClient } from 'kubernetesjs';
5+
6+
export default async (
7+
_argv: Partial<ParsedArgs>,
8+
_prompter: Inquirerer,
9+
_options: CLIOptions
10+
) => {
11+
try {
12+
const client = new KubernetesClient({
13+
restEndpoint: 'http://localhost:8001' // Default kube-proxy endpoint
14+
});
15+
16+
console.log(chalk.blue('Kubernetes cluster info:'));
17+
18+
const apiVersions = await client.getAPIVersions({
19+
params: {
20+
21+
},
22+
query: {
23+
24+
}
25+
});
26+
console.log(chalk.bold('\nAPI Versions:'));
27+
if (apiVersions.apiVersion) {
28+
console.log(apiVersions.apiVersion)
29+
}
30+
31+
} catch (error) {
32+
console.error(chalk.red(`Error: ${error}`));
33+
}
34+
};
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { CLIOptions, Inquirerer } from 'inquirerer';
2+
import { ParsedArgs } from 'minimist';
3+
import chalk from 'chalk';
4+
import * as fs from 'fs';
5+
import { readYamlFile, inferResourceType } from '../config';
6+
7+
/**
8+
* Handle the --config flag by parsing the YAML file and executing the appropriate command
9+
* @param argv Command line arguments
10+
* @param prompter Inquirerer instance
11+
* @param options CLI options
12+
* @param commandMap Map of available commands
13+
*/
14+
export default async (
15+
argv: Partial<ParsedArgs>,
16+
prompter: Inquirerer,
17+
options: CLIOptions,
18+
commandMap: Record<string, Function>
19+
): Promise<boolean> => {
20+
if (!argv.config) {
21+
return false;
22+
}
23+
24+
const configPath = argv.config as string;
25+
26+
if (!fs.existsSync(configPath)) {
27+
console.error(chalk.red(`Config file not found: ${configPath}`));
28+
return true;
29+
}
30+
31+
try {
32+
const resource = readYamlFile(configPath);
33+
34+
const resourceType = inferResourceType(resource);
35+
36+
console.log(chalk.blue(`Detected resource type: ${resourceType}`));
37+
38+
let command: string;
39+
40+
command = 'apply';
41+
42+
const newArgv = {
43+
...argv,
44+
_: [configPath],
45+
f: configPath
46+
};
47+
48+
if (commandMap[command]) {
49+
await commandMap[command](newArgv, prompter, options);
50+
} else {
51+
console.error(chalk.red(`No command found for resource type: ${resourceType}`));
52+
}
53+
54+
return true;
55+
} catch (error) {
56+
console.error(chalk.red(`Error processing config file: ${error}`));
57+
return true;
58+
}
59+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { CLIOptions, Inquirerer, Question } from 'inquirerer';
2+
import { ParsedArgs } from 'minimist';
3+
import chalk from 'chalk';
4+
import { KubernetesClient } from 'kubernetesjs';
5+
import { getCurrentNamespace, setCurrentNamespace } from '../config';
6+
7+
async function promptNamespace(
8+
prompter: Inquirerer,
9+
argv: Partial<ParsedArgs>,
10+
client: KubernetesClient
11+
): Promise<string> {
12+
try {
13+
const namespaces = await client.listCoreV1Namespace({
14+
query: {}
15+
});
16+
17+
if (!namespaces.items || namespaces.items.length === 0) {
18+
console.log(chalk.yellow('No namespaces found'));
19+
return '';
20+
}
21+
22+
const options = namespaces.items.map(ns => ({
23+
name: ns.metadata.name,
24+
value: ns.metadata.name
25+
}));
26+
27+
const question: Question = {
28+
type: 'autocomplete',
29+
name: 'namespace',
30+
message: 'Select namespace',
31+
options,
32+
maxDisplayLines: 10,
33+
required: true
34+
};
35+
36+
const { namespace } = await prompter.prompt(argv, [question]);
37+
return namespace;
38+
} catch (error) {
39+
console.error(chalk.red(`Error getting namespaces: ${error}`));
40+
return '';
41+
}
42+
}
43+
44+
export default async (
45+
argv: Partial<ParsedArgs>,
46+
prompter: Inquirerer,
47+
_options: CLIOptions
48+
) => {
49+
try {
50+
const client = new KubernetesClient({
51+
restEndpoint: 'http://localhost:8001' // Default kube-proxy endpoint
52+
});
53+
54+
const subcommand = argv._?.[0];
55+
56+
if (subcommand === 'get-context') {
57+
const namespace = getCurrentNamespace();
58+
console.log(chalk.green(`Current namespace: ${namespace}`));
59+
return;
60+
}
61+
62+
if (subcommand === 'set-context') {
63+
if (argv.current !== true) {
64+
console.error(chalk.red('Missing --current flag'));
65+
return;
66+
}
67+
68+
let namespace = argv.namespace;
69+
if (!namespace) {
70+
namespace = await promptNamespace(prompter, argv, client);
71+
if (!namespace) {
72+
return;
73+
}
74+
}
75+
76+
setCurrentNamespace(namespace as string);
77+
console.log(chalk.green(`Namespace set to "${namespace}"`));
78+
return;
79+
}
80+
81+
console.log(chalk.blue('Available config commands:'));
82+
console.log(' get-context Display the current context');
83+
console.log(' set-context --current --namespace=<namespace> Set the current namespace');
84+
} catch (error) {
85+
console.error(chalk.red(`Error: ${error}`));
86+
}
87+
};

0 commit comments

Comments
 (0)