xuanwu-cli 1.0.0

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.
Files changed (45) hide show
  1. package/README.md +171 -0
  2. package/bin/xuanwu +20 -0
  3. package/dist/api/client.d.ts +30 -0
  4. package/dist/api/client.js +332 -0
  5. package/dist/commands/build.d.ts +5 -0
  6. package/dist/commands/build.js +57 -0
  7. package/dist/commands/connect.d.ts +5 -0
  8. package/dist/commands/connect.js +67 -0
  9. package/dist/commands/deploy.d.ts +5 -0
  10. package/dist/commands/deploy.js +85 -0
  11. package/dist/commands/env.d.ts +5 -0
  12. package/dist/commands/env.js +119 -0
  13. package/dist/commands/logs.d.ts +5 -0
  14. package/dist/commands/logs.js +39 -0
  15. package/dist/commands/pods.d.ts +5 -0
  16. package/dist/commands/pods.js +56 -0
  17. package/dist/commands/scale.d.ts +5 -0
  18. package/dist/commands/scale.js +32 -0
  19. package/dist/commands/svc.d.ts +5 -0
  20. package/dist/commands/svc.js +100 -0
  21. package/dist/config/store.d.ts +18 -0
  22. package/dist/config/store.js +108 -0
  23. package/dist/config/types.d.ts +86 -0
  24. package/dist/config/types.js +5 -0
  25. package/dist/index.d.ts +4 -0
  26. package/dist/index.js +142 -0
  27. package/dist/output/formatter.d.ts +15 -0
  28. package/dist/output/formatter.js +95 -0
  29. package/docs/DESIGN.md +363 -0
  30. package/docs//345/276/205/344/274/230/345/214/226.md +89 -0
  31. package/package.json +31 -0
  32. package/src/api/client.ts +380 -0
  33. package/src/commands/build.ts +67 -0
  34. package/src/commands/connect.ts +75 -0
  35. package/src/commands/deploy.ts +90 -0
  36. package/src/commands/env.ts +144 -0
  37. package/src/commands/logs.ts +47 -0
  38. package/src/commands/pods.ts +60 -0
  39. package/src/commands/scale.ts +35 -0
  40. package/src/commands/svc.ts +114 -0
  41. package/src/config/store.ts +86 -0
  42. package/src/config/types.ts +99 -0
  43. package/src/index.ts +127 -0
  44. package/src/output/formatter.ts +112 -0
  45. package/tsconfig.json +17 -0
@@ -0,0 +1,89 @@
1
+ # CLI 待优化清单
2
+
3
+ **更新日期**: 2026-03-10
4
+ **状态**: 需要修改 API 端点
5
+
6
+ ---
7
+
8
+ ## 一、需要修改的 API 端点
9
+
10
+ ### 服务删除 API 路径变更
11
+
12
+ **原因**: 后端路由冲突,已将服务删除 API 迁移到新路径
13
+
14
+ | 命令 | 旧端点 | 新端点 |
15
+ |------|--------|--------|
16
+ | `xuanwu svc rm <namespace> <name>` | `DELETE /api/services/[ns]/[name]` | `DELETE /api/services/by-ns/[ns]/[name]` |
17
+
18
+ ---
19
+
20
+ ## 二、CLI 代码修改位置
21
+
22
+ 需要修改 CLI 中调用服务删除 API 的代码,将端点从:
23
+ ```
24
+ /api/services/${namespace}/${name}
25
+ ```
26
+ 改为:
27
+ ```
28
+ /api/services/by-ns/${namespace}/${name}
29
+ ```
30
+
31
+ ### 修改示例
32
+
33
+ ```typescript
34
+ // 旧代码
35
+ const response = await fetch(`${endpoint}/api/services/${namespace}/${name}`, {
36
+ method: 'DELETE',
37
+ headers: { 'Authorization': `Bearer ${token}` }
38
+ })
39
+
40
+ // 新代码
41
+ const response = await fetch(`${endpoint}/api/services/by-ns/${namespace}/${name}`, {
42
+ method: 'DELETE',
43
+ headers: { 'Authorization': `Bearer ${token}` }
44
+ })
45
+ ```
46
+
47
+ ---
48
+
49
+ ## 三、验证命令
50
+
51
+ 修改完成后,使用以下命令验证:
52
+
53
+ ```bash
54
+ # 部署测试服务
55
+ xuanwu deploy test-ns test-delete --image nginx:latest
56
+
57
+ # 删除服务(验证新端点)
58
+ xuanwu svc rm test-ns test-delete
59
+ ```
60
+
61
+ 预期结果:返回 `{ "success": true }`
62
+
63
+ ---
64
+
65
+ ## 四、API 端点完整映射(更新后)
66
+
67
+ | CLI 命令 | API 端点 | 状态 |
68
+ |---------|---------|------|
69
+ | env ls | GET /api/deploy-spaces | ✅ |
70
+ | env create | POST /api/deploy-spaces | ✅ |
71
+ | env rm | DELETE /api/deploy-spaces/[id] | ✅ |
72
+ | deploy | POST /api/services | ✅ |
73
+ | svc ls | GET /api/k8s?kind=deployments | ✅ |
74
+ | svc status | GET /api/k8s/deployments/[name] | ✅ |
75
+ | **svc rm** | **DELETE /api/services/by-ns/[ns]/[name]** | **🔄 需修改** |
76
+ | scale | POST /api/k8s?action=scale | ✅ |
77
+ | logs | GET /api/k8s/logs | ✅ |
78
+ | logs -f | GET /api/k8s/logs/stream | ✅ |
79
+ | pods | GET /api/k8s?kind=pods | ✅ |
80
+ | top | GET /api/k8s/metrics | ✅ |
81
+ | exec | POST /api/debug/pod-exec | ✅ |
82
+
83
+ ---
84
+
85
+ ## 五、后端已完成
86
+
87
+ - [x] 创建新路由 `/api/services/by-ns/[namespace]/[name]`
88
+ - [x] 实现 DELETE 方法
89
+ - [x] 构建验证通过
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "xuanwu-cli",
3
+ "version": "1.0.0",
4
+ "description": "玄武工厂平台 CLI 工具",
5
+ "main": "dist/index.js",
6
+ "bin": {
7
+ "xuanwu": "./bin/xuanwu"
8
+ },
9
+ "keywords": [
10
+ "cli",
11
+ "kubernetes",
12
+ "devops"
13
+ ],
14
+ "author": "",
15
+ "license": "MIT",
16
+ "dependencies": {
17
+ "commander": "^11.1.0",
18
+ "inquirer": "^9.2.12",
19
+ "axios": "^1.6.5"
20
+ },
21
+ "devDependencies": {
22
+ "@types/inquirer": "^9.0.7",
23
+ "@types/node": "^20.11.0",
24
+ "typescript": "^5.3.3"
25
+ },
26
+ "scripts": {
27
+ "build": "tsc",
28
+ "dev": "tsc --watch",
29
+ "start": "node dist/index.js"
30
+ }
31
+ }
@@ -0,0 +1,380 @@
1
+ /**
2
+ * API 客户端
3
+ */
4
+
5
+ import axios, { AxiosInstance, AxiosRequestConfig } from 'axios'
6
+ import { Connection, CLIResult, ServiceInfo, NamespaceInfo, DeployOptions } from '../config/types'
7
+ import { OutputFormatter } from '../output/formatter'
8
+
9
+ export class APIClient {
10
+ private client: AxiosInstance
11
+
12
+ constructor(connection: Connection) {
13
+ this.client = axios.create({
14
+ baseURL: connection.endpoint,
15
+ headers: {
16
+ 'Authorization': `Bearer ${connection.token}`,
17
+ 'Content-Type': 'application/json'
18
+ },
19
+ timeout: 30000
20
+ })
21
+ }
22
+
23
+ private async request<T>(method: string, url: string, data?: any, config?: AxiosRequestConfig): Promise<CLIResult<T>> {
24
+ const startTime = Date.now()
25
+ try {
26
+ const response = await this.client.request({
27
+ method,
28
+ url,
29
+ data,
30
+ ...config
31
+ })
32
+
33
+ return {
34
+ success: true,
35
+ data: response.data,
36
+ meta: {
37
+ timestamp: new Date().toISOString(),
38
+ duration: Date.now() - startTime
39
+ }
40
+ }
41
+ } catch (error: any) {
42
+ const duration = Date.now() - startTime
43
+ const message = error.response?.data?.message || error.message
44
+
45
+ return {
46
+ success: false,
47
+ error: {
48
+ code: error.response?.status?.toString() || 'ERROR',
49
+ message,
50
+ details: error.response?.data
51
+ },
52
+ meta: {
53
+ timestamp: new Date().toISOString(),
54
+ duration
55
+ }
56
+ }
57
+ }
58
+ }
59
+
60
+ // ===================
61
+ // Namespace (环境)
62
+ // ===================
63
+
64
+ async listNamespaces(): Promise<CLIResult<NamespaceInfo[]>> {
65
+ return this.request<NamespaceInfo[]>('GET', '/api/deploy-spaces')
66
+ }
67
+
68
+ async createNamespace(name: string): Promise<CLIResult<any>> {
69
+ return this.request('POST', '/api/deploy-spaces', {
70
+ name,
71
+ identifier: name,
72
+ namespace: name,
73
+ environment: 'development'
74
+ })
75
+ }
76
+
77
+ async deleteNamespace(id: string): Promise<CLIResult<void>> {
78
+ return this.request('DELETE', `/api/deploy-spaces/${id}`)
79
+ }
80
+
81
+ async getNamespaceInfo(identifier: string): Promise<CLIResult<any>> {
82
+ return this.request('GET', `/api/deploy-spaces?identifier=${identifier}`)
83
+ }
84
+
85
+ async getProjects(): Promise<CLIResult<any[]>> {
86
+ return this.request<any[]>('GET', '/api/projects')
87
+ }
88
+
89
+ async createNamespaceWithProject(name: string, projectId: string, environment: string = 'development'): Promise<CLIResult<any>> {
90
+ return this.request('POST', '/api/deploy-spaces', {
91
+ name,
92
+ identifier: name,
93
+ namespace: name,
94
+ environment,
95
+ project_id: projectId
96
+ })
97
+ }
98
+
99
+ // ===================
100
+ // Service
101
+ // ===================
102
+
103
+ async listServices(namespace: string): Promise<CLIResult<ServiceInfo[]>> {
104
+ return this.request<any[]>('GET', `/api/k8s?kind=deployments&namespace=${namespace}`)
105
+ }
106
+
107
+ async getServiceStatus(namespace: string, name: string): Promise<CLIResult<any>> {
108
+ return this.request('GET', `/api/k8s/deployments/${name}?namespace=${namespace}`)
109
+ }
110
+
111
+ async deleteService(namespace: string, name: string): Promise<CLIResult<void>> {
112
+ return this.request('DELETE', `/api/services/by-ns/${namespace}/${name}`)
113
+ }
114
+
115
+ // ===================
116
+ // Deploy
117
+ // ===================
118
+
119
+ async deploy(options: DeployOptions): Promise<CLIResult<any>> {
120
+ const { namespace, serviceName, type, ...rest } = options
121
+
122
+ // 获取 namespace 对应的 project_id
123
+ let projectId: string | undefined
124
+
125
+ // 尝试通过 identifier 查询
126
+ const envResult = await this.getNamespaceInfo(namespace)
127
+ if (envResult.success && envResult.data) {
128
+ const envData = Array.isArray(envResult.data) ? envResult.data[0] : envResult.data
129
+ projectId = envData.project?.id || envData.projectId
130
+ }
131
+
132
+ // 如果没找到,尝试从列表中查找
133
+ if (!projectId) {
134
+ const listResult = await this.listNamespaces()
135
+ if (listResult.success && listResult.data) {
136
+ const env = (listResult.data as any[]).find((e) => e.namespace === namespace || e.identifier === namespace)
137
+ if (env) {
138
+ projectId = env.project?.id || env.projectId
139
+ }
140
+ }
141
+ }
142
+
143
+ // 如果还是没有,使用第一个项目的 ID
144
+ if (!projectId) {
145
+ const projectsResult = await this.getProjects()
146
+ if (projectsResult.success && projectsResult.data && projectsResult.data.length > 0) {
147
+ projectId = projectsResult.data[0].id
148
+ }
149
+ }
150
+
151
+ if (!projectId) {
152
+ return {
153
+ success: false,
154
+ error: { code: 'NOT_FOUND', message: `Cannot find project for namespace "${namespace}"` }
155
+ }
156
+ }
157
+
158
+ const payload: any = {
159
+ project_id: projectId,
160
+ name: serviceName,
161
+ type
162
+ }
163
+
164
+ // 根据类型添加配置
165
+ if (type === 'application') {
166
+ payload.git_provider = 'gitlab'
167
+ payload.git_repository = rest.git
168
+ payload.git_branch = rest.gitBranch || 'main'
169
+ payload.build_type = rest.buildType || 'template'
170
+ payload.port = rest.port || 8080
171
+ } else if (type === 'database') {
172
+ payload.database_type = rest.dbType
173
+ payload.version = rest.dbVersion || '8.0'
174
+ payload.root_password = rest.rootPassword || 'root'
175
+ payload.port = rest.port || this.getDefaultPort(rest.dbType!)
176
+ } else if (type === 'image') {
177
+ payload.image = rest.image
178
+ payload.port = rest.port || 80
179
+ }
180
+
181
+ // 添加通用配置
182
+ payload.replicas = rest.replicas || 1
183
+ payload.port = rest.port || 8080
184
+
185
+ // 添加资源限制
186
+ if (rest.cpu || rest.memory) {
187
+ payload.resource_limits = {
188
+ cpu: rest.cpu || '500m',
189
+ memory: rest.memory || '512Mi'
190
+ }
191
+ }
192
+
193
+ // 构建网络配置
194
+ payload.network_config = {
195
+ service_type: 'ClusterIP',
196
+ ports: [{
197
+ container_port: payload.port,
198
+ protocol: 'TCP'
199
+ }]
200
+ }
201
+
202
+ // 添加域名
203
+ if (rest.domain) {
204
+ payload.network_config.ports[0].domain = {
205
+ enabled: true,
206
+ prefix: rest.domain,
207
+ host: `${rest.domain}.${namespace}.dev.aimstek.cn`
208
+ }
209
+ }
210
+
211
+ // 环境变量
212
+ if (rest.envVars) {
213
+ payload.env_vars = rest.envVars
214
+ }
215
+
216
+ return this.request('POST', '/api/services', payload)
217
+ }
218
+
219
+ private getDefaultPort(dbType: string): number {
220
+ const ports: Record<string, number> = {
221
+ mysql: 3306,
222
+ redis: 6379,
223
+ postgres: 5432,
224
+ elasticsearch: 9200
225
+ }
226
+ return ports[dbType] || 3306
227
+ }
228
+
229
+ // ===================
230
+ // Build
231
+ // ===================
232
+
233
+ async build(namespace: string, serviceName: string): Promise<CLIResult<any>> {
234
+ // 先获取服务 ID
235
+ const serviceResult = await this.getServiceByName(namespace, serviceName)
236
+ if (!serviceResult.success || !serviceResult.data) {
237
+ return {
238
+ success: false,
239
+ error: { code: 'NOT_FOUND', message: `Service ${serviceName} not found` }
240
+ }
241
+ }
242
+
243
+ const service = serviceResult.data as any
244
+ if (!service.application_id) {
245
+ return {
246
+ success: false,
247
+ error: { code: 'NO_APPLICATION', message: 'Service is not an application type' }
248
+ }
249
+ }
250
+
251
+ return this.request('POST', `/api/applications/${service.application_id}/build`)
252
+ }
253
+
254
+ async getBuildStatus(namespace: string, serviceName: string): Promise<CLIResult<any>> {
255
+ const serviceResult = await this.getServiceByName(namespace, serviceName)
256
+ if (!serviceResult.success || !serviceResult.data) {
257
+ return { success: false, error: { code: 'NOT_FOUND', message: 'Service not found' } }
258
+ }
259
+
260
+ const service = serviceResult.data as any
261
+ if (!service.application_id) {
262
+ return { success: false, error: { code: 'NO_APPLICATION', message: 'Not an application' } }
263
+ }
264
+
265
+ return this.request('GET', `/api/applications/${service.application_id}/builds`)
266
+ }
267
+
268
+ private async getServiceByName(namespace: string, name: string): Promise<CLIResult<any>> {
269
+ return this.request('GET', `/api/services?project_id=${namespace}&name=${name}`)
270
+ }
271
+
272
+ // ===================
273
+ // Scale
274
+ // ===================
275
+
276
+ async scale(namespace: string, serviceName: string, replicas: number): Promise<CLIResult<any>> {
277
+ return this.request('POST', `/api/k8s?namespace=${namespace}&action=scale`, {
278
+ name: serviceName,
279
+ replicas
280
+ })
281
+ }
282
+
283
+ // ===================
284
+ // Logs
285
+ // ===================
286
+
287
+ async getLogs(namespace: string, serviceName: string, lines: number = 100, follow: boolean = false): Promise<CLIResult<any>> {
288
+ return this.request('GET', `/api/k8s/logs?namespace=${namespace}&serviceName=${serviceName}&lines=${lines}&follow=${follow}`)
289
+ }
290
+
291
+ async streamLogs(namespace: string, serviceName: string): Promise<void> {
292
+ const url = `${this.client.defaults.baseURL}/api/k8s/logs/stream?namespace=${namespace}&serviceName=${serviceName}`
293
+
294
+ try {
295
+ const response = await fetch(url, {
296
+ headers: {
297
+ 'Authorization': `Bearer ${this.client.defaults.headers['Authorization']}`
298
+ }
299
+ })
300
+
301
+ if (!response.ok) {
302
+ const error = await response.text()
303
+ OutputFormatter.error(`Failed to stream logs: ${error}`)
304
+ return
305
+ }
306
+
307
+ const reader = response.body?.getReader()
308
+ if (!reader) {
309
+ OutputFormatter.error('Failed to read stream')
310
+ return
311
+ }
312
+
313
+ const decoder = new TextDecoder()
314
+ while (true) {
315
+ const { done, value } = await reader.read()
316
+ if (done) break
317
+ const text = decoder.decode(value)
318
+ // 解析 SSE 格式
319
+ const lines = text.split('\n')
320
+ for (const line of lines) {
321
+ if (line.startsWith('data: ')) {
322
+ console.log(line.slice(6))
323
+ }
324
+ }
325
+ }
326
+ } catch (error: any) {
327
+ OutputFormatter.error(`Stream error: ${error.message}`)
328
+ }
329
+ }
330
+
331
+ // ===================
332
+ // Pods
333
+ // ===================
334
+
335
+ async listPods(namespace: string, serviceName: string): Promise<CLIResult<any>> {
336
+ return this.request('GET', `/api/k8s?kind=pods&namespace=${namespace}&labelSelector=app=${serviceName}`)
337
+ }
338
+
339
+ // ===================
340
+ // Metrics
341
+ // ===================
342
+
343
+ async getMetrics(namespace: string, serviceName: string, podName?: string): Promise<CLIResult<any>> {
344
+ const url = podName
345
+ ? `/api/k8s/metrics?namespace=${namespace}&podName=${podName}`
346
+ : `/api/k8s/metrics?namespace=${namespace}&serviceName=${serviceName}`
347
+ return this.request('GET', url)
348
+ }
349
+
350
+ // ===================
351
+ // Exec
352
+ // ===================
353
+
354
+ async exec(namespace: string, serviceName: string, command: string, podName?: string): Promise<CLIResult<any>> {
355
+ let targetPod = podName
356
+
357
+ // 如果没有指定 podName,自动获取第一个 pod
358
+ if (!targetPod) {
359
+ const podsResult = await this.listPods(namespace, serviceName)
360
+ if (!podsResult.success || !podsResult.data || podsResult.data.length === 0) {
361
+ return { success: false, error: { code: 'NOT_FOUND', message: 'No pods found' } }
362
+ }
363
+ const pods = Array.isArray(podsResult.data) ? podsResult.data : podsResult.data.items || []
364
+ if (pods.length === 0) {
365
+ return { success: false, error: { code: 'NOT_FOUND', message: 'No pods found' } }
366
+ }
367
+ targetPod = pods[0].metadata?.name
368
+ }
369
+
370
+ return this.request('POST', '/api/debug/pod-exec', {
371
+ namespace,
372
+ podName: targetPod,
373
+ command
374
+ })
375
+ }
376
+ }
377
+
378
+ export function createClient(connection: Connection): APIClient {
379
+ return new APIClient(connection)
380
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 构建命令
3
+ */
4
+
5
+ import { Command } from 'commander'
6
+ import { configStore } from '../config/store'
7
+ import { createClient } from '../api/client'
8
+ import { OutputFormatter } from '../output/formatter'
9
+
10
+ export function makeBuildCommand(): Command {
11
+ const cmd = new Command('build')
12
+ .description('Build applications')
13
+
14
+ cmd
15
+ .command('<namespace> <service-name>')
16
+ .description('Build a service')
17
+ .action(async (namespace, serviceName) => {
18
+ const conn = configStore.getDefaultConnection()
19
+ if (!conn) {
20
+ OutputFormatter.error('No connection configured')
21
+ return
22
+ }
23
+
24
+ const client = createClient(conn)
25
+ OutputFormatter.info('Starting build...')
26
+
27
+ const result = await client.build(namespace, serviceName)
28
+
29
+ if (!result.success) {
30
+ OutputFormatter.error(result.error!.message)
31
+ return
32
+ }
33
+
34
+ OutputFormatter.success('Build started')
35
+ })
36
+
37
+ cmd
38
+ .command('status <namespace> <service-name>')
39
+ .description('Get build status')
40
+ .action(async (namespace, serviceName) => {
41
+ const conn = configStore.getDefaultConnection()
42
+ if (!conn) {
43
+ OutputFormatter.error('No connection configured')
44
+ return
45
+ }
46
+
47
+ const client = createClient(conn)
48
+ const result = await client.getBuildStatus(namespace, serviceName)
49
+
50
+ if (!result.success) {
51
+ OutputFormatter.error(result.error!.message)
52
+ return
53
+ }
54
+
55
+ const builds = result.data || []
56
+ if (builds.length === 0) {
57
+ OutputFormatter.info('No builds found')
58
+ return
59
+ }
60
+
61
+ const latest = builds[0]
62
+ OutputFormatter.info(`Status: ${latest.status}`)
63
+ OutputFormatter.info(`Created: ${latest.created_at}`)
64
+ })
65
+
66
+ return cmd
67
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * 连接管理命令
3
+ */
4
+
5
+ import { Command } from 'commander'
6
+ import { configStore } from '../config/store'
7
+ import { OutputFormatter } from '../output/formatter'
8
+ import { createClient } from '../api/client'
9
+
10
+ export function makeConnectCommand(): Command {
11
+ const cmd = new Command('connect')
12
+ .description('Manage connections to xuanwu platform')
13
+
14
+ cmd
15
+ .command('add <name>')
16
+ .description('Add a new connection')
17
+ .requiredOption('-e, --endpoint <url>', 'Platform API endpoint')
18
+ .requiredOption('-t, --token <token>', 'API token')
19
+ .action(async (name, options) => {
20
+ try {
21
+ configStore.addConnection({
22
+ name,
23
+ endpoint: options.endpoint,
24
+ token: options.token,
25
+ isDefault: configStore.getConnections().length === 0
26
+ })
27
+ OutputFormatter.success(`Connection "${name}" added`)
28
+ } catch (error: any) {
29
+ OutputFormatter.error(error.message)
30
+ }
31
+ })
32
+
33
+ cmd
34
+ .command('ls')
35
+ .description('List all connections')
36
+ .action(() => {
37
+ const connections = configStore.getConnections()
38
+ if (connections.length === 0) {
39
+ OutputFormatter.info('No connections configured')
40
+ return
41
+ }
42
+
43
+ OutputFormatter.table(
44
+ ['Name', 'Endpoint', 'Default'],
45
+ connections.map(c => [
46
+ c.name,
47
+ c.endpoint,
48
+ c.isDefault ? '✓' : ''
49
+ ])
50
+ )
51
+ })
52
+
53
+ cmd
54
+ .command('use <name>')
55
+ .description('Set default connection')
56
+ .action((name) => {
57
+ const conn = configStore.getConnection(name)
58
+ if (!conn) {
59
+ OutputFormatter.error(`Connection "${name}" not found`)
60
+ return
61
+ }
62
+ configStore.setDefaultConnection(name)
63
+ OutputFormatter.success(`Using connection "${name}"`)
64
+ })
65
+
66
+ cmd
67
+ .command('rm <name>')
68
+ .description('Remove a connection')
69
+ .action((name) => {
70
+ configStore.removeConnection(name)
71
+ OutputFormatter.success(`Connection "${name}" removed`)
72
+ })
73
+
74
+ return cmd
75
+ }