xuanwu-cli 2.2.0 → 2.3.3

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 (41) hide show
  1. package/.env.test.example +14 -0
  2. package/__tests__/E2E_TEST_REPORT.md +206 -0
  3. package/__tests__/README.md +322 -0
  4. package/__tests__/TEST_SUMMARY.md +215 -0
  5. package/__tests__/global-setup.ts +13 -0
  6. package/__tests__/global-teardown.ts +3 -0
  7. package/__tests__/helpers/test-utils.ts +70 -0
  8. package/__tests__/integration/app.integration.test.ts +363 -0
  9. package/__tests__/integration/auth.integration.test.ts +243 -0
  10. package/__tests__/integration/build.integration.test.ts +215 -0
  11. package/__tests__/integration/e2e.test.ts +267 -0
  12. package/__tests__/integration/service.integration.test.ts +267 -0
  13. package/__tests__/integration/webhook.integration.test.ts +246 -0
  14. package/__tests__/run-e2e.js +360 -0
  15. package/__tests__/setup.ts +9 -0
  16. package/bin/xuanwu +0 -0
  17. package/dist/api/client.d.ts +29 -4
  18. package/dist/api/client.js +113 -29
  19. package/dist/commands/app.js +44 -0
  20. package/dist/commands/auth/login.js +5 -4
  21. package/dist/commands/deploy.js +77 -49
  22. package/dist/commands/env.js +31 -48
  23. package/dist/commands/project.d.ts +5 -0
  24. package/dist/commands/project.js +134 -0
  25. package/dist/commands/svc.js +36 -0
  26. package/dist/config/types.d.ts +1 -0
  27. package/dist/index.js +2 -0
  28. package/jest.config.js +18 -0
  29. package/package.json +10 -2
  30. package/src/api/client.ts +142 -33
  31. package/src/commands/app.ts +53 -0
  32. package/src/commands/auth/login.ts +6 -4
  33. package/src/commands/deploy.ts +93 -48
  34. package/src/commands/env.ts +35 -52
  35. package/src/commands/project.ts +153 -0
  36. package/src/commands/svc.ts +40 -0
  37. package/src/config/types.ts +1 -0
  38. package/src/index.ts +2 -0
  39. package/test/cli-integration.sh +245 -0
  40. package/test/integration.js +3 -3
  41. package/test/integration.sh +252 -0
@@ -6,27 +6,85 @@ import { Command } from 'commander'
6
6
  import { configStore } from '../config/store'
7
7
  import { createClient } from '../api/client'
8
8
  import { OutputFormatter } from '../output/formatter'
9
- import { DeployOptions } from '../config/types'
9
+
10
+ function parseNamespaceName(input: string): { namespace: string; name: string } | null {
11
+ const parts = input.split('/')
12
+ if (parts.length !== 2) {
13
+ return null
14
+ }
15
+ return { namespace: parts[0], name: parts[1] }
16
+ }
17
+
18
+ function getAge(timestamp?: string): string {
19
+ if (!timestamp) return '-'
20
+ const created = new Date(timestamp)
21
+ const now = new Date()
22
+ const diff = Math.floor((now.getTime() - created.getTime()) / 1000)
23
+ if (diff < 60) return `${diff}s`
24
+ if (diff < 3600) return `${Math.floor(diff / 60)}m`
25
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h`
26
+ return `${Math.floor(diff / 86400)}d`
27
+ }
10
28
 
11
29
  export function makeDeployCommand(): Command {
12
30
  const cmd = new Command('deploy')
13
31
  .description('Deploy services to environment')
14
- .argument('<namespace>', 'Target namespace')
15
- .argument('<service-name>', 'Service name')
16
- .option('-t, --type <type>', 'Service type (application|database|image)', 'image')
17
- .option('--git <url>', 'Git repository URL')
18
- .option('--git-branch <branch>', 'Git branch', 'main')
19
- .option('--build-type <type>', 'Build type (template|dockerfile)', 'template')
20
- .option('--language <lang>', 'Language (java-springboot|nodejs|python|golang)')
21
- .option('--dockerfile-path <path>', 'Dockerfile path')
22
- .option('--db-type <type>', 'Database type (mysql|redis|postgres|elasticsearch)')
23
- .option('--db-version <version>', 'Database version')
24
- .option('--root-password <password>', 'Root password')
25
- .option('--password <password>', 'Password')
26
- .option('--user <user>', 'Username')
27
- .option('--database <name>', 'Database name')
28
- .option('--image <image>', 'Container image')
29
- .option('-p, --port <port>', 'Container port')
32
+
33
+ cmd
34
+ .command('history <ns-name>')
35
+ .description('Show deployment history for a service')
36
+ .action(async (nsName) => {
37
+ const conn = configStore.getDefaultConnection()
38
+ if (!conn) {
39
+ OutputFormatter.error('No connection configured')
40
+ return
41
+ }
42
+
43
+ const parsed = parseNamespaceName(nsName)
44
+ if (!parsed) {
45
+ OutputFormatter.error('Invalid format. Use: <namespace>/<service-name>')
46
+ return
47
+ }
48
+
49
+ const { namespace, name: serviceName } = parsed
50
+
51
+ const client = createClient(conn)
52
+ const result = await client.listServiceDeployments(namespace, serviceName)
53
+
54
+ if (!result.success) {
55
+ OutputFormatter.error(result.error!.message)
56
+ return
57
+ }
58
+
59
+ const data = result.data as any
60
+ const deployments = data?.deployments || data || []
61
+
62
+ if (deployments.length === 0) {
63
+ OutputFormatter.info('No deployments found')
64
+ return
65
+ }
66
+
67
+ OutputFormatter.table(
68
+ ['ID', 'Image', 'Status', 'Build', 'Created'],
69
+ deployments.map((d: any) => [
70
+ d.id.substring(0, 8),
71
+ d.image?.substring(0, 40) || '-',
72
+ d.status || '-',
73
+ d.build?.buildNumber ? `#${d.build.buildNumber}` : '-',
74
+ getAge(d.createdAt)
75
+ ])
76
+ )
77
+
78
+ if (data?.pagination) {
79
+ OutputFormatter.info(`Page ${data.pagination.page}/${data.pagination.totalPages}, Total: ${data.pagination.total}`)
80
+ }
81
+ })
82
+
83
+ cmd
84
+ .argument('<ns-name>', 'Target namespace and service name (format: namespace/service-name)')
85
+ .requiredOption('-i, --image <image>', 'Container image (required)')
86
+ .option('-p, --project <code>', 'Project code')
87
+ .option('--port <port>', 'Container port', '80')
30
88
  .option('-r, --replicas <num>', 'Number of replicas', '1')
31
89
  .option('--cpu <value>', 'CPU limit')
32
90
  .option('--memory <value>', 'Memory limit')
@@ -36,47 +94,34 @@ export function makeDeployCommand(): Command {
36
94
  memo[key] = value
37
95
  return memo
38
96
  }, {})
39
- .action(async (namespace, serviceName, options) => {
97
+ .action(async (nsName, options) => {
40
98
  const conn = configStore.getDefaultConnection()
41
99
  if (!conn) {
42
100
  OutputFormatter.error('No connection configured')
43
101
  return
44
102
  }
45
103
 
46
- const type = options.type as 'application' | 'database' | 'image'
47
-
48
- const deployOptions: DeployOptions = {
49
- namespace,
50
- serviceName,
51
- type,
52
- port: options.port ? parseInt(options.port) : undefined,
53
- replicas: options.replicas ? parseInt(options.replicas) : undefined,
54
- cpu: options.cpu,
55
- memory: options.memory,
56
- domain: options.domain,
57
- envVars: options.env
104
+ const parsed = parseNamespaceName(nsName)
105
+ if (!parsed) {
106
+ OutputFormatter.error('Invalid format. Use: <namespace>/<service-name>')
107
+ return
58
108
  }
59
109
 
60
- // 根据类型添加配置
61
- if (type === 'application') {
62
- deployOptions.git = options.git
63
- deployOptions.gitBranch = options.gitBranch
64
- deployOptions.buildType = options.buildType
65
- deployOptions.language = options.language
66
- deployOptions.dockerfilePath = options.dockerfilePath
67
- } else if (type === 'database') {
68
- deployOptions.dbType = options.dbType
69
- deployOptions.dbVersion = options.dbVersion
70
- deployOptions.rootPassword = options.rootPassword
71
- deployOptions.password = options.password
72
- deployOptions.user = options.user
73
- deployOptions.database = options.database
74
- } else if (type === 'image') {
75
- deployOptions.image = options.image
76
- }
110
+ const { namespace, name: serviceName } = parsed
77
111
 
78
112
  const client = createClient(conn)
79
- const result = await client.deploy(deployOptions)
113
+
114
+ const result = await client.deployService(
115
+ namespace,
116
+ serviceName,
117
+ options.image,
118
+ {
119
+ projectCode: options.project,
120
+ replicas: options.replicas ? parseInt(options.replicas) : 1,
121
+ port: options.port ? parseInt(options.port) : 80,
122
+ env: options.env
123
+ }
124
+ )
80
125
 
81
126
  if (!result.success) {
82
127
  OutputFormatter.error(result.error!.message)
@@ -13,16 +13,18 @@ export function makeEnvCommand(): Command {
13
13
 
14
14
  cmd
15
15
  .command('ls')
16
+ .alias('list')
16
17
  .description('List accessible environments')
17
- .action(async () => {
18
+ .option('-p, --project <project-code>', 'Filter by project code')
19
+ .action(async (options) => {
18
20
  const conn = configStore.getDefaultConnection()
19
21
  if (!conn) {
20
- OutputFormatter.error('No connection configured. Run: xuanwu connect add <name> -e <endpoint> -t <token>')
22
+ OutputFormatter.error('No connection configured. Run: xw connect add <name> -e <endpoint> -t <token>')
21
23
  return
22
24
  }
23
25
 
24
26
  const client = createClient(conn)
25
- const result = await client.listNamespaces()
27
+ const result = await client.listEnvironments({ project: options.project })
26
28
 
27
29
  if (!result.success) {
28
30
  OutputFormatter.error(result.error!.message)
@@ -36,22 +38,20 @@ export function makeEnvCommand(): Command {
36
38
  }
37
39
 
38
40
  OutputFormatter.table(
39
- ['Name', 'Identifier', 'Environment', 'Status'],
41
+ ['Name', 'Namespace', 'Project', 'Services'],
40
42
  spaces.map((s: any) => [
41
- s.name || s.identifier,
42
- s.identifier,
43
- s.environment,
44
- s.status || 'active'
43
+ s.name,
44
+ s.namespace,
45
+ s.project?.name || '-',
46
+ s._count?.services || 0
45
47
  ])
46
48
  )
47
49
  })
48
50
 
49
51
  cmd
50
- .command('create <namespace>')
51
- .description('Create a new environment (K8s namespace)')
52
- .option('-p, --project-id <id>', 'Project ID')
53
- .option('-e, --environment <env>', 'Environment type (development|staging|production)', 'development')
54
- .action(async (namespace, options) => {
52
+ .command('get <namespace>')
53
+ .description('Get environment details')
54
+ .action(async (namespace) => {
55
55
  const conn = configStore.getDefaultConnection()
56
56
  if (!conn) {
57
57
  OutputFormatter.error('No connection configured')
@@ -59,34 +59,26 @@ export function makeEnvCommand(): Command {
59
59
  }
60
60
 
61
61
  const client = createClient(conn)
62
-
63
- let projectId = options.projectId
64
-
65
- // 如果没有指定 project-id,尝试获取第一个项目
66
- if (!projectId) {
67
- const projectsResult = await client.getProjects()
68
- if (!projectsResult.success || !projectsResult.data || projectsResult.data.length === 0) {
69
- OutputFormatter.error('No projects found. Please specify --project-id')
70
- return
71
- }
72
- projectId = projectsResult.data[0].id
73
- OutputFormatter.info(`Using project: ${projectsResult.data[0].name}`)
74
- }
75
-
76
- const result = await client.createNamespaceWithProject(namespace, projectId, options.environment)
62
+ const result = await client.getEnvironment(namespace)
77
63
 
78
64
  if (!result.success) {
79
65
  OutputFormatter.error(result.error!.message)
80
66
  return
81
67
  }
82
68
 
83
- OutputFormatter.success(`Environment "${namespace}" created`)
69
+ const env = result.data
70
+ OutputFormatter.info(`Name: ${env.name}`)
71
+ OutputFormatter.info(`Namespace: ${env.namespace}`)
72
+ OutputFormatter.info(`Project: ${env.project?.name || '-'}`)
73
+ OutputFormatter.info(`Services: ${env.services?.length || 0}`)
84
74
  })
85
75
 
86
76
  cmd
87
- .command('rm <namespace>')
88
- .description('Delete an environment')
89
- .action(async (namespace) => {
77
+ .command('create <namespace>')
78
+ .description('Create a new environment (K8s namespace)')
79
+ .option('-n, --name <name>', 'Environment name')
80
+ .option('-p, --project <project-code>', 'Project code')
81
+ .action(async (namespace, options) => {
90
82
  const conn = configStore.getDefaultConnection()
91
83
  if (!conn) {
92
84
  OutputFormatter.error('No connection configured')
@@ -95,28 +87,24 @@ export function makeEnvCommand(): Command {
95
87
 
96
88
  const client = createClient(conn)
97
89
 
98
- // 先获取 namespace ID
99
- const infoResult = await client.getNamespaceInfo(namespace)
100
- if (!infoResult.success || !infoResult.data) {
101
- OutputFormatter.error(`Environment "${namespace}" not found`)
102
- return
103
- }
104
-
105
- const envData = Array.isArray(infoResult.data) ? infoResult.data[0] : infoResult.data
106
- const id = envData.id
107
- const result = await client.deleteNamespace(id)
90
+ const result = await client.createEnvironment(
91
+ options.name || namespace,
92
+ namespace,
93
+ options.project
94
+ )
108
95
 
109
96
  if (!result.success) {
110
97
  OutputFormatter.error(result.error!.message)
111
98
  return
112
99
  }
113
100
 
114
- OutputFormatter.success(`Environment "${namespace}" deleted`)
101
+ OutputFormatter.success(`Environment "${namespace}" created`)
115
102
  })
116
103
 
117
104
  cmd
118
- .command('info <namespace>')
119
- .description('Show environment details')
105
+ .command('rm <namespace>')
106
+ .alias('delete')
107
+ .description('Delete an environment')
120
108
  .action(async (namespace) => {
121
109
  const conn = configStore.getDefaultConnection()
122
110
  if (!conn) {
@@ -125,19 +113,14 @@ export function makeEnvCommand(): Command {
125
113
  }
126
114
 
127
115
  const client = createClient(conn)
128
- const result = await client.getNamespaceInfo(namespace)
116
+ const result = await client.deleteEnvironment(namespace)
129
117
 
130
118
  if (!result.success) {
131
119
  OutputFormatter.error(result.error!.message)
132
120
  return
133
121
  }
134
122
 
135
- const info = result.data
136
- OutputFormatter.info(`Name: ${info.name}`)
137
- OutputFormatter.info(`Identifier: ${info.identifier}`)
138
- OutputFormatter.info(`Namespace: ${info.namespace}`)
139
- OutputFormatter.info(`Environment: ${info.environment}`)
140
- OutputFormatter.info(`Status: ${info.status}`)
123
+ OutputFormatter.success(`Environment "${namespace}" deleted`)
141
124
  })
142
125
 
143
126
  return cmd
@@ -0,0 +1,153 @@
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
+ function getAge(timestamp?: string): string {
11
+ if (!timestamp) return '-'
12
+ const created = new Date(timestamp)
13
+ const now = new Date()
14
+ const diff = Math.floor((now.getTime() - created.getTime()) / 1000)
15
+ if (diff < 60) return `${diff}s`
16
+ if (diff < 3600) return `${Math.floor(diff / 60)}m`
17
+ if (diff < 86400) return `${Math.floor(diff / 3600)}h`
18
+ return `${Math.floor(diff / 86400)}d`
19
+ }
20
+
21
+ export function makeProjectCommand(): Command {
22
+ const cmd = new Command('project')
23
+ .description('Manage projects')
24
+
25
+ cmd
26
+ .command('ls')
27
+ .alias('list')
28
+ .description('List projects')
29
+ .option('-n, --name <name>', 'Filter by name')
30
+ .option('-c, --code <code>', 'Filter by code')
31
+ .action(async (options) => {
32
+ const conn = configStore.getDefaultConnection()
33
+ if (!conn) {
34
+ OutputFormatter.error('No connection configured. Run: xw connect add <name> -e <endpoint> -t <token>')
35
+ return
36
+ }
37
+
38
+ const client = createClient(conn)
39
+ const result = await client.listProjects({ name: options.name, code: options.code })
40
+
41
+ if (!result.success) {
42
+ OutputFormatter.error(result.error!.message)
43
+ return
44
+ }
45
+
46
+ const projects = result.data || []
47
+ if (projects.length === 0) {
48
+ OutputFormatter.info('No projects found')
49
+ return
50
+ }
51
+
52
+ OutputFormatter.table(
53
+ ['Code', 'Name', 'Environments', 'Members', 'Created'],
54
+ projects.map((p: any) => [
55
+ p.code,
56
+ p.name,
57
+ p.environments?.length || 0,
58
+ p.members?.length || 0,
59
+ getAge(p.createdAt)
60
+ ])
61
+ )
62
+ })
63
+
64
+ cmd
65
+ .command('get <code>')
66
+ .alias('describe')
67
+ .description('Get project details')
68
+ .action(async (code) => {
69
+ const conn = configStore.getDefaultConnection()
70
+ if (!conn) {
71
+ OutputFormatter.error('No connection configured')
72
+ return
73
+ }
74
+
75
+ const client = createClient(conn)
76
+ const result = await client.getProject(code)
77
+
78
+ if (!result.success) {
79
+ OutputFormatter.error(result.error!.message)
80
+ return
81
+ }
82
+
83
+ const project = result.data
84
+ OutputFormatter.info(`Code: ${project.code}`)
85
+ OutputFormatter.info(`Name: ${project.name}`)
86
+ OutputFormatter.info(`Description: ${project.description || '-'}`)
87
+ OutputFormatter.info(`Environments: ${project.environments?.length || 0}`)
88
+ OutputFormatter.info(`Members: ${project.members?.length || 0}`)
89
+ OutputFormatter.info(`Created: ${project.createdAt}`)
90
+
91
+ if (project.environments && project.environments.length > 0) {
92
+ OutputFormatter.info('\nEnvironments:')
93
+ project.environments.forEach((env: any) => {
94
+ OutputFormatter.info(` - ${env.name} (${env.namespace})`)
95
+ })
96
+ }
97
+
98
+ if (project.members && project.members.length > 0) {
99
+ OutputFormatter.info('\nMembers:')
100
+ project.members.forEach((m: any) => {
101
+ OutputFormatter.info(` - ${m.user.name || m.user.email} (${m.role})`)
102
+ })
103
+ }
104
+ })
105
+
106
+ cmd
107
+ .command('create')
108
+ .description('Create a new project')
109
+ .requiredOption('-n, --name <name>', 'Project name')
110
+ .requiredOption('-c, --code <code>', 'Project code (unique identifier, cannot be changed after creation)')
111
+ .option('-d, --description <description>', 'Project description')
112
+ .action(async (options) => {
113
+ const conn = configStore.getDefaultConnection()
114
+ if (!conn) {
115
+ OutputFormatter.error('No connection configured')
116
+ return
117
+ }
118
+
119
+ const client = createClient(conn)
120
+ const result = await client.createProject(options.name, options.code, options.description)
121
+
122
+ if (!result.success) {
123
+ OutputFormatter.error(result.error!.message)
124
+ return
125
+ }
126
+
127
+ OutputFormatter.success(`Project "${options.name}" created with code "${options.code}"`)
128
+ })
129
+
130
+ cmd
131
+ .command('rm <code>')
132
+ .alias('delete')
133
+ .description('Delete a project')
134
+ .action(async (code) => {
135
+ const conn = configStore.getDefaultConnection()
136
+ if (!conn) {
137
+ OutputFormatter.error('No connection configured')
138
+ return
139
+ }
140
+
141
+ const client = createClient(conn)
142
+ const result = await client.deleteProject(code)
143
+
144
+ if (!result.success) {
145
+ OutputFormatter.error(result.error!.message)
146
+ return
147
+ }
148
+
149
+ OutputFormatter.success(`Project "${code}" deleted`)
150
+ })
151
+
152
+ return cmd
153
+ }
@@ -323,6 +323,46 @@ export function makeSvcCommand(): Command {
323
323
  )
324
324
  })
325
325
 
326
+ cmd
327
+ .command('update <ns>/<name>')
328
+ .description('Update service configuration')
329
+ .option('-i, --image <image>', 'Container image')
330
+ .option('-r, --replicas <num>', 'Number of replicas')
331
+ .option('--port <port>', 'Container port')
332
+ .action(async (nsName, options) => {
333
+ const parsed = parseNamespaceName(nsName)
334
+ if (!parsed) {
335
+ OutputFormatter.error('Invalid format. Use: <namespace>/<service-name>')
336
+ return
337
+ }
338
+
339
+ const conn = configStore.getDefaultConnection()
340
+ if (!conn) {
341
+ OutputFormatter.error('No connection configured')
342
+ return
343
+ }
344
+
345
+ const updateData: any = {}
346
+ if (options.image) updateData.image = options.image
347
+ if (options.replicas) updateData.replicas = parseInt(options.replicas)
348
+ if (options.port) updateData.port = parseInt(options.port)
349
+
350
+ if (Object.keys(updateData).length === 0) {
351
+ OutputFormatter.error('No update options provided. Use --image, --replicas, or --port')
352
+ return
353
+ }
354
+
355
+ const client = createClient(conn)
356
+ const result = await client.updateK8sService(parsed.namespace, parsed.name, updateData)
357
+
358
+ if (!result.success) {
359
+ OutputFormatter.error(result.error!.message)
360
+ return
361
+ }
362
+
363
+ OutputFormatter.success(`Service "${nsName}" updated`)
364
+ })
365
+
326
366
  cmd
327
367
  .command('delete <ns>/<name>')
328
368
  .alias('rm')
@@ -43,6 +43,7 @@ export interface DeployOptions {
43
43
  namespace: string
44
44
  serviceName: string
45
45
  type: 'application' | 'database' | 'image'
46
+ projectCode?: string
46
47
  port?: number
47
48
  replicas?: number
48
49
  cpu?: string
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  import { Command } from 'commander'
6
6
  import { configStore } from './config/store'
7
7
  import { makeConnectCommand } from './commands/connect'
8
+ import { makeProjectCommand } from './commands/project'
8
9
  import { makeEnvCommand } from './commands/env'
9
10
  import { makeDeployCommand } from './commands/deploy'
10
11
  import { makeAppCommand } from './commands/app'
@@ -27,6 +28,7 @@ program
27
28
  .option('-o, --output <format>', 'Output format (human|json)', 'human')
28
29
 
29
30
  program.addCommand(makeConnectCommand())
31
+ program.addCommand(makeProjectCommand())
30
32
  program.addCommand(makeEnvCommand())
31
33
  program.addCommand(makeAppCommand())
32
34
  program.addCommand(makeSvcCommand())