sealos-cli 0.1.0 → 1.1.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.
@@ -1,224 +1,717 @@
1
1
  import { Command } from 'commander'
2
- import { getCurrentContext } from '../../lib/config.ts'
3
- import { success, spinner, outputTable, formatOutput, info } from '../../lib/output.ts'
4
- import { handleError, AuthError } from '../../lib/errors.ts'
2
+ import chalk from 'chalk'
3
+ import { createDevboxClient } from '../../lib/api-client.ts'
4
+ import { type ApiErrorBody, mapApiError } from '../../lib/errors.ts'
5
+ import { outputJson, outputTable } from '../../lib/output.ts'
6
+ import { withAuth } from '../../lib/with-auth.ts'
7
+
8
+ const PORT_PROTOCOLS = ['http', 'grpc', 'ws'] as const
9
+
10
+ type PortProtocol = typeof PORT_PROTOCOLS[number]
11
+
12
+ interface DevboxPortOptions {
13
+ portName?: string
14
+ number?: number
15
+ protocol?: PortProtocol
16
+ isPublic?: boolean
17
+ customDomain?: string
18
+ }
19
+
20
+ interface DevboxCreateOptions {
21
+ name: string
22
+ runtime?: string
23
+ template?: string
24
+ cpu: string
25
+ memory: string
26
+ port: string[]
27
+ env: string[]
28
+ secretEnv: string[]
29
+ autostart?: boolean
30
+ }
31
+
32
+ interface DevboxUpdateOptions {
33
+ cpu?: string
34
+ memory?: string
35
+ port: string[]
36
+ }
37
+
38
+ interface DevboxReleaseOptions {
39
+ tag: string
40
+ description?: string
41
+ execCommand?: string
42
+ noStart?: boolean
43
+ }
44
+
45
+ function formatValue (value: unknown): string {
46
+ if (value === undefined || value === null || value === '') return '-'
47
+ return String(value)
48
+ }
49
+
50
+ function collectOption (value: string, previous: string[]): string[] {
51
+ return [...previous, value]
52
+ }
53
+
54
+ export function parseNumericValue (value: string, field: string): number {
55
+ const normalized = value.trim().toLowerCase()
56
+ let raw = normalized
57
+
58
+ if (field === 'cpu' && normalized.endsWith('c')) {
59
+ raw = normalized.slice(0, -1)
60
+ }
61
+ if (field === 'memory' && /gi?$|gb$|g$/i.test(normalized)) {
62
+ raw = normalized.replace(/gi?$|gb$|g$/i, '')
63
+ }
64
+
65
+ const parsed = Number(raw)
66
+ if (!Number.isFinite(parsed)) {
67
+ throw new Error(`Invalid ${field} value "${value}"`)
68
+ }
69
+ if (parsed < 0.1 || parsed > 32) {
70
+ throw new Error(`${field} must be between 0.1 and 32`)
71
+ }
72
+ return parsed
73
+ }
74
+
75
+ function parseBooleanValue (value: string, field: string): boolean {
76
+ const normalized = value.trim().toLowerCase()
77
+ if (['true', 'yes', '1', 'public'].includes(normalized)) return true
78
+ if (['false', 'no', '0', 'private'].includes(normalized)) return false
79
+ throw new Error(`Invalid ${field} boolean value "${value}"`)
80
+ }
81
+
82
+ function normalizeProtocol (value?: string): PortProtocol | undefined {
83
+ if (!value) return undefined
84
+ const normalized = value.trim().toLowerCase()
85
+ if (!PORT_PROTOCOLS.includes(normalized as PortProtocol)) {
86
+ throw new Error(`Invalid port protocol "${value}". Use one of: ${PORT_PROTOCOLS.join(', ')}`)
87
+ }
88
+ return normalized as PortProtocol
89
+ }
90
+
91
+ export function parsePortSpec (spec: string): DevboxPortOptions {
92
+ if (!spec) {
93
+ throw new Error('Port spec is required.')
94
+ }
95
+
96
+ if (spec.includes('=')) {
97
+ const port: DevboxPortOptions = {}
98
+ for (const pair of spec.split(',')) {
99
+ const index = pair.indexOf('=')
100
+ if (index === -1) {
101
+ throw new Error(`Invalid port spec "${spec}". Expected comma-separated KEY=VALUE pairs.`)
102
+ }
103
+ const key = pair.slice(0, index).trim()
104
+ const value = pair.slice(index + 1).trim()
105
+ switch (key) {
106
+ case 'portName':
107
+ port.portName = value
108
+ break
109
+ case 'number':
110
+ port.number = parsePortNumber(value)
111
+ break
112
+ case 'protocol':
113
+ port.protocol = normalizeProtocol(value)
114
+ break
115
+ case 'isPublic':
116
+ port.isPublic = parseBooleanValue(value, 'isPublic')
117
+ break
118
+ case 'customDomain':
119
+ port.customDomain = value
120
+ break
121
+ default:
122
+ throw new Error(`Unsupported port field "${key}"`)
123
+ }
124
+ }
125
+ return port
126
+ }
127
+
128
+ const [rawNumber, rawProtocol, rawVisibility, customDomain] = spec.split(':')
129
+ const port: DevboxPortOptions = {
130
+ number: parsePortNumber(rawNumber || '')
131
+ }
132
+ const protocol = normalizeProtocol(rawProtocol)
133
+ if (protocol) port.protocol = protocol
134
+ if (rawVisibility) port.isPublic = parseBooleanValue(rawVisibility, 'visibility')
135
+ if (customDomain) port.customDomain = customDomain
136
+ return port
137
+ }
138
+
139
+ function parsePortNumber (value: string): number {
140
+ const parsed = Number(value)
141
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
142
+ throw new Error(`Invalid port number "${value}"`)
143
+ }
144
+ return parsed
145
+ }
146
+
147
+ function parseEnvArgs (envs: string[]): Array<{ name: string; value: string }> {
148
+ return envs.map(env => {
149
+ const index = env.indexOf('=')
150
+ if (index === -1) {
151
+ throw new Error(`Invalid --env format: "${env}". Expected NAME=VALUE`)
152
+ }
153
+ const name = env.slice(0, index)
154
+ if (!name) {
155
+ throw new Error(`Invalid --env format: "${env}". Environment name is required.`)
156
+ }
157
+ return {
158
+ name,
159
+ value: env.slice(index + 1)
160
+ }
161
+ })
162
+ }
163
+
164
+ function parseSecretEnvArgs (envs: string[]): Array<{ name: string; valueFrom: { secretKeyRef: { name: string; key: string } } }> {
165
+ return envs.map(env => {
166
+ const index = env.indexOf('=')
167
+ if (index === -1) {
168
+ throw new Error(`Invalid --secret-env format: "${env}". Expected NAME=SECRET:KEY`)
169
+ }
170
+ const name = env.slice(0, index)
171
+ const [secretName, secretKey] = env.slice(index + 1).split(':')
172
+ if (!name || !secretName || !secretKey) {
173
+ throw new Error(`Invalid --secret-env format: "${env}". Expected NAME=SECRET:KEY`)
174
+ }
175
+ return {
176
+ name,
177
+ valueFrom: {
178
+ secretKeyRef: {
179
+ name: secretName,
180
+ key: secretKey
181
+ }
182
+ }
183
+ }
184
+ })
185
+ }
186
+
187
+ function requireRuntime (options: Pick<DevboxCreateOptions, 'runtime' | 'template'>): string {
188
+ const runtime = options.runtime || options.template
189
+ if (!runtime) {
190
+ throw new Error('Devbox runtime is required. Use --runtime <runtime>.')
191
+ }
192
+ return runtime
193
+ }
194
+
195
+ export function buildCreateDevboxBody (options: DevboxCreateOptions) {
196
+ return {
197
+ name: options.name,
198
+ runtime: requireRuntime(options),
199
+ quota: {
200
+ cpu: parseNumericValue(options.cpu, 'cpu'),
201
+ memory: parseNumericValue(options.memory, 'memory')
202
+ },
203
+ ports: options.port.map(parsePortSpec),
204
+ env: [
205
+ ...parseEnvArgs(options.env),
206
+ ...parseSecretEnvArgs(options.secretEnv)
207
+ ],
208
+ autostart: options.autostart ?? false
209
+ }
210
+ }
211
+
212
+ export function buildUpdateDevboxBody (options: DevboxUpdateOptions) {
213
+ const body: {
214
+ quota?: { cpu?: number; memory?: number }
215
+ ports?: DevboxPortOptions[]
216
+ } = {}
217
+
218
+ if (options.cpu || options.memory) {
219
+ body.quota = {}
220
+ if (options.cpu) body.quota.cpu = parseNumericValue(options.cpu, 'cpu')
221
+ if (options.memory) body.quota.memory = parseNumericValue(options.memory, 'memory')
222
+ }
223
+
224
+ if (options.port.length > 0) {
225
+ body.ports = options.port.map(parsePortSpec)
226
+ }
227
+
228
+ if (!body.quota && !body.ports) {
229
+ throw new Error('Provide at least one of --cpu, --memory, or --port.')
230
+ }
231
+
232
+ return body
233
+ }
234
+
235
+ export function buildReleaseBody (options: DevboxReleaseOptions) {
236
+ const body: {
237
+ tag: string
238
+ releaseDescription?: string
239
+ execCommand?: string
240
+ startDevboxAfterRelease?: boolean
241
+ } = {
242
+ tag: options.tag
243
+ }
244
+ if (options.description !== undefined) body.releaseDescription = options.description
245
+ if (options.execCommand !== undefined) body.execCommand = options.execCommand
246
+ if (options.noStart) body.startDevboxAfterRelease = false
247
+ return body
248
+ }
249
+
250
+ function printDevboxList (items: any[]): void {
251
+ if (items.length === 0) {
252
+ console.log('No devboxes found.')
253
+ return
254
+ }
255
+ outputTable([
256
+ [chalk.bold('Name'), chalk.bold('Runtime'), chalk.bold('Status'), chalk.bold('CPU'), chalk.bold('Memory'), chalk.bold('UID')],
257
+ ...items.map(item => [
258
+ formatValue(item.name),
259
+ formatValue(item.runtime),
260
+ formatValue(item.status),
261
+ item.quota?.cpu != null ? `${item.quota.cpu} vCPU` : '-',
262
+ item.quota?.memory != null ? `${item.quota.memory} GB` : '-',
263
+ formatValue(item.uid)
264
+ ])
265
+ ])
266
+ }
267
+
268
+ function printDevboxDetail (devbox: any): void {
269
+ outputTable([
270
+ [chalk.bold('Field'), chalk.bold('Value')],
271
+ ['Name', formatValue(devbox.name)],
272
+ ['Runtime', formatValue(devbox.runtime)],
273
+ ['Status', formatValue(devbox.status)],
274
+ ['CPU', devbox.quota?.cpu != null ? `${devbox.quota.cpu} vCPU` : '-'],
275
+ ['Memory', devbox.quota?.memory != null ? `${devbox.quota.memory} GB` : '-'],
276
+ ['Working Dir', formatValue(devbox.workingDir)],
277
+ ['SSH User', formatValue(devbox.userName)],
278
+ ['SSH Port', formatValue(devbox.sshPort)],
279
+ ['Domain', formatValue(devbox.domain)]
280
+ ])
281
+
282
+ if (devbox.ports?.length > 0) {
283
+ console.log(chalk.dim('\n Ports:'))
284
+ outputTable([
285
+ [chalk.bold('Name'), chalk.bold('Number'), chalk.bold('Protocol'), chalk.bold('Public'), chalk.bold('Public Domain'), chalk.bold('Private Address')],
286
+ ...devbox.ports.map((port: any) => [
287
+ formatValue(port.portName),
288
+ formatValue(port.number),
289
+ formatValue(port.protocol),
290
+ formatValue(port.isPublic),
291
+ formatValue(port.publicDomain),
292
+ formatValue(port.privateAddress)
293
+ ])
294
+ ])
295
+ }
296
+
297
+ if (devbox.pods?.length > 0) {
298
+ console.log(chalk.dim('\n Pods:'))
299
+ outputTable([
300
+ [chalk.bold('Name'), chalk.bold('Status')],
301
+ ...devbox.pods.map((pod: any) => [formatValue(pod.name), formatValue(pod.status)])
302
+ ])
303
+ }
304
+ }
305
+
306
+ function printCreateResult (devbox: any): void {
307
+ console.log(chalk.dim(` Name: ${formatValue(devbox.name)}`))
308
+ console.log(chalk.dim(` SSH: ${formatValue(devbox.userName)}@${formatValue(devbox.domain)}:${formatValue(devbox.sshPort)}`))
309
+ console.log(chalk.dim(` Working Dir: ${formatValue(devbox.workingDir)}`))
310
+ if (devbox.autostarted !== undefined) {
311
+ console.log(chalk.dim(` Autostarted: ${formatValue(devbox.autostarted)}`))
312
+ }
313
+ if (devbox.ports?.length > 0) {
314
+ console.log(chalk.dim('\n Ports:'))
315
+ outputTable([
316
+ [chalk.bold('Number'), chalk.bold('Protocol'), chalk.bold('Public'), chalk.bold('Public Domain'), chalk.bold('Private Address')],
317
+ ...devbox.ports.map((port: any) => [
318
+ formatValue(port.number),
319
+ formatValue(port.protocol),
320
+ formatValue(port.isPublic),
321
+ formatValue(port.publicDomain),
322
+ formatValue(port.privateAddress)
323
+ ])
324
+ ])
325
+ }
326
+ }
327
+
328
+ function printReleases (releases: any[]): void {
329
+ if (releases.length === 0) {
330
+ console.log('No releases found.')
331
+ return
332
+ }
333
+ outputTable([
334
+ [chalk.bold('Tag'), chalk.bold('Name'), chalk.bold('Created'), chalk.bold('Image'), chalk.bold('Description')],
335
+ ...releases.map(release => [
336
+ formatValue(release.tag),
337
+ formatValue(release.name),
338
+ formatValue(release.createdAt),
339
+ formatValue(release.image),
340
+ formatValue(release.description)
341
+ ])
342
+ ])
343
+ }
344
+
345
+ function printDeployments (deployments: any[]): void {
346
+ if (deployments.length === 0) {
347
+ console.log('No deployments found.')
348
+ return
349
+ }
350
+ outputTable([
351
+ [chalk.bold('Name'), chalk.bold('Type'), chalk.bold('Tag')],
352
+ ...deployments.map(deployment => [
353
+ formatValue(deployment.name),
354
+ formatValue(deployment.resourceType),
355
+ formatValue(deployment.tag)
356
+ ])
357
+ ])
358
+ }
359
+
360
+ function printMonitor (samples: any[]): void {
361
+ if (samples.length === 0) {
362
+ console.log('No monitor samples found.')
363
+ return
364
+ }
365
+ outputTable([
366
+ [chalk.bold('Time'), chalk.bold('CPU %'), chalk.bold('Memory %'), chalk.bold('Timestamp')],
367
+ ...samples.map(sample => [
368
+ formatValue(sample.readableTime),
369
+ formatValue(sample.cpu),
370
+ formatValue(sample.memory),
371
+ formatValue(sample.timestamp)
372
+ ])
373
+ ])
374
+ }
375
+
376
+ function printTemplates (templates: any[]): void {
377
+ if (templates.length === 0) {
378
+ console.log('No devbox templates found.')
379
+ return
380
+ }
381
+ outputTable([
382
+ [chalk.bold('Runtime'), chalk.bold('User'), chalk.bold('Working Dir'), chalk.bold('App Ports'), chalk.bold('Ports')],
383
+ ...templates.map(template => [
384
+ formatValue(template.runtime),
385
+ formatValue(template.config?.user),
386
+ formatValue(template.config?.workingDir),
387
+ Array.isArray(template.config?.appPorts) ? String(template.config.appPorts.length) : '-',
388
+ Array.isArray(template.config?.ports) ? String(template.config.ports.length) : '-'
389
+ ])
390
+ ])
391
+ }
5
392
 
6
393
  export function createDevboxCommand (): Command {
7
394
  const devboxCmd = new Command('devbox')
8
395
  .alias('dev')
9
396
  .description('Manage devbox instances')
10
397
 
11
- // devbox create
12
398
  devboxCmd
13
- .command('create')
14
- .description('Create a new devbox')
15
- .argument('[path]', 'Project path', '.')
16
- .option('--name <name>', 'Devbox name')
17
- .option('--template <template>', 'Template to use', 'default')
18
- .option('--cpu <cpu>', 'CPU configuration', '1c')
19
- .option('--memory <memory>', 'Memory configuration', '2g')
20
- .option('--port <port>', 'Port number')
21
- .option('--config <config>', 'Config file path')
22
- .action(async (path, options) => {
23
- try {
24
- const context = getCurrentContext()
25
- if (!context) {
26
- throw new AuthError()
27
- }
399
+ .command('list')
400
+ .description('List all devboxes')
401
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
402
+ .action(withAuth({ spinnerText: 'Loading devboxes...' }, async (ctx, options: { output: string }) => {
403
+ const client = createDevboxClient()
404
+ const { data, error, response } = await client.GET('/devbox', {
405
+ headers: ctx.auth
406
+ })
407
+
408
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
409
+ ctx.spinner.stop()
28
410
 
29
- const spin = spinner('Creating devbox...')
30
-
31
- // TODO: 实现创建 devbox
32
- // 1. 读取配置文件(如果有)
33
- // 2. 调用 API 创建 devbox
34
- // const result = await api.post('/api/v1/devbox', {
35
- // name: options.name,
36
- // template: options.template,
37
- // resources: {
38
- // cpu: options.cpu,
39
- // memory: options.memory
40
- // }
41
- // })
42
-
43
- // 3. 等待创建完成
44
- // 4. 返回访问地址
45
-
46
- spin.succeed('Devbox created successfully')
47
- success('Devbox URL: https://example.sealos.run')
48
- info(`Run "sealos devbox connect ${options.name || 'devbox'}" to connect`)
49
- } catch (error) {
50
- handleError(error)
411
+ if (options.output === 'json') {
412
+ outputJson(data)
413
+ return
51
414
  }
52
- })
415
+ printDevboxList(data)
416
+ }))
53
417
 
54
- // devbox list
55
418
  devboxCmd
56
- .command('list')
57
- .description('List all devboxes')
58
- .option('-o, --output <format>', 'Output format: json, yaml, table', 'table')
59
- .option('--selector <selector>', 'Label selector')
60
- .action(async (options) => {
61
- try {
62
- const context = getCurrentContext()
63
- if (!context) {
64
- throw new AuthError()
65
- }
419
+ .command('create')
420
+ .description('Create a new devbox')
421
+ .requiredOption('--name <name>', 'Devbox name')
422
+ .option('--runtime <runtime>', 'Runtime environment name')
423
+ .option('--template <runtime>', 'Alias for --runtime')
424
+ .option('--cpu <cpu>', 'CPU cores', '1')
425
+ .option('--memory <memory>', 'Memory in GB', '2')
426
+ .option('--port <spec>', 'Port spec, e.g. 8080:http:public or number=8080,protocol=http', collectOption, [] as string[])
427
+ .option('--env <NAME=VALUE>', 'Environment variable', collectOption, [] as string[])
428
+ .option('--secret-env <NAME=SECRET:KEY>', 'Environment variable from secret', collectOption, [] as string[])
429
+ .option('--autostart', 'Auto start devbox after creation')
430
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
431
+ .action(withAuth({ spinnerText: 'Creating devbox...' }, async (ctx, options: DevboxCreateOptions & { output: string }) => {
432
+ const client = createDevboxClient()
433
+ const { data, error, response } = await client.POST('/devbox', {
434
+ headers: ctx.auth,
435
+ body: buildCreateDevboxBody(options) as any
436
+ })
66
437
 
67
- // 示例数据
68
- const data = [
69
- ['NAME', 'STATUS', 'CPU', 'MEMORY', 'CREATED'],
70
- ['my-devbox', 'Running', '2c', '4g', '2h ago'],
71
- ['test-box', 'Stopped', '1c', '2g', '1d ago']
72
- ]
73
-
74
- if (options.output === 'json') {
75
- formatOutput({ items: data.slice(1) }, 'json')
76
- } else if (options.output === 'yaml') {
77
- formatOutput({ items: data.slice(1) }, 'yaml')
78
- } else {
79
- outputTable(data)
80
- }
81
- } catch (error) {
82
- handleError(error)
438
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
439
+
440
+ if (options.output === 'json') {
441
+ ctx.spinner.stop()
442
+ outputJson(data)
443
+ return
83
444
  }
84
- })
445
+ ctx.spinner.succeed(`Devbox "${data.name}" created`)
446
+ printCreateResult(data)
447
+ }))
85
448
 
86
- // devbox get
87
449
  devboxCmd
88
- .command('get')
450
+ .command('get <name>')
89
451
  .description('Get devbox details')
90
- .argument('<name>', 'Devbox name')
91
- .action(async (name) => {
92
- try {
93
- const context = getCurrentContext()
94
- if (!context) {
95
- throw new AuthError()
452
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
453
+ .action(withAuth({ spinnerText: 'Loading devbox...' }, async (ctx, name: string, options: { output: string }) => {
454
+ const client = createDevboxClient()
455
+ const { data, error, response } = await client.GET('/devbox/{name}', {
456
+ headers: ctx.auth,
457
+ params: {
458
+ path: { name }
96
459
  }
460
+ })
461
+
462
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
463
+ ctx.spinner.stop()
97
464
 
98
- const data = [
99
- ['Field', 'Value'],
100
- ['Name', name],
101
- ['Status', 'Running'],
102
- ['CPU', '2c'],
103
- ['Memory', '4g'],
104
- ['URL', 'https://example.sealos.run']
105
- ]
106
-
107
- outputTable(data)
108
- } catch (error) {
109
- handleError(error)
465
+ if (options.output === 'json') {
466
+ outputJson(data)
467
+ return
110
468
  }
111
- })
112
-
113
- // devbox start/stop/restart/delete
114
- const actions = ['start', 'stop', 'restart', 'delete']
115
- actions.forEach(action => {
116
- const cmd = devboxCmd
117
- .command(action)
118
- .description(`${action.charAt(0).toUpperCase() + action.slice(1)} a devbox`)
119
- .argument('<name>', 'Devbox name')
120
-
121
- if (action === 'delete') {
122
- cmd.option('-f, --force', 'Force delete without confirmation')
123
- }
469
+ printDevboxDetail(data)
470
+ }))
471
+
472
+ devboxCmd
473
+ .command('update <name>')
474
+ .description('Update devbox resources or ports')
475
+ .option('--cpu <cpu>', 'CPU cores')
476
+ .option('--memory <memory>', 'Memory in GB')
477
+ .option('--port <spec>', 'Port spec. Existing ports can include portName=...', collectOption, [] as string[])
478
+ .action(withAuth({ spinnerText: 'Updating devbox...' }, async (ctx, name: string, options: DevboxUpdateOptions) => {
479
+ const client = createDevboxClient()
480
+ const { error, response } = await client.PATCH('/devbox/{name}', {
481
+ headers: ctx.auth,
482
+ params: {
483
+ path: { name }
484
+ },
485
+ body: buildUpdateDevboxBody(options) as any
486
+ })
124
487
 
125
- cmd.action(async (name, options) => {
126
- try {
127
- const context = getCurrentContext()
128
- if (!context) {
129
- throw new AuthError()
488
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
489
+ ctx.spinner.succeed(`Devbox "${name}" update requested`)
490
+ }))
491
+
492
+ devboxCmd
493
+ .command('delete <name>')
494
+ .description('Delete a devbox')
495
+ .alias('rm')
496
+ .action(withAuth({ spinnerText: 'Deleting devbox...' }, async (ctx, name: string) => {
497
+ const client = createDevboxClient()
498
+ const { error, response } = await client.DELETE('/devbox/{name}', {
499
+ headers: ctx.auth,
500
+ params: {
501
+ path: { name }
130
502
  }
503
+ })
131
504
 
132
- const spin = spinner(`${action}ing devbox...`)
505
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
506
+ ctx.spinner.succeed(`Devbox "${name}" deleted`)
507
+ }))
133
508
 
134
- // TODO: 实现对应操作
135
- spin.succeed(`Devbox ${action}ed successfully`)
136
- } catch (error) {
137
- handleError(error)
138
- }
139
- })
140
- })
509
+ const lifecycleActions = [
510
+ { name: 'start', endpoint: '/devbox/{name}/start' as const, spinnerText: 'Starting devbox...', done: 'start requested' },
511
+ { name: 'pause', endpoint: '/devbox/{name}/pause' as const, spinnerText: 'Pausing devbox...', done: 'pause requested', alias: 'stop' },
512
+ { name: 'shutdown', endpoint: '/devbox/{name}/shutdown' as const, spinnerText: 'Shutting down devbox...', done: 'shutdown requested' },
513
+ { name: 'restart', endpoint: '/devbox/{name}/restart' as const, spinnerText: 'Restarting devbox...', done: 'restart requested' }
514
+ ]
515
+
516
+ for (const action of lifecycleActions) {
517
+ const command = devboxCmd
518
+ .command(`${action.name} <name>`)
519
+ .description(`${action.name.charAt(0).toUpperCase()}${action.name.slice(1)} a devbox`)
520
+
521
+ if (action.alias) command.alias(action.alias)
522
+
523
+ command.action(withAuth({ spinnerText: action.spinnerText }, async (ctx, name: string) => {
524
+ const client = createDevboxClient()
525
+ const { error, response } = await client.POST(action.endpoint, {
526
+ headers: ctx.auth,
527
+ params: {
528
+ path: { name }
529
+ },
530
+ body: {}
531
+ } as any)
532
+
533
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
534
+ ctx.spinner.succeed(`Devbox "${name}" ${action.done}`)
535
+ }))
536
+ }
141
537
 
142
- // devbox connect
143
538
  devboxCmd
144
- .command('connect')
145
- .description('Connect to a devbox')
146
- .argument('<name>', 'Devbox name')
147
- .option('--ide <ide>', 'IDE type: vscode, cursor', 'vscode')
148
- .action(async (name, options) => {
149
- try {
150
- const context = getCurrentContext()
151
- if (!context) {
152
- throw new AuthError()
539
+ .command('autostart <name>')
540
+ .description('Configure devbox autostart')
541
+ .option('--exec-command <command>', 'Command to execute when the devbox starts')
542
+ .action(withAuth({ spinnerText: 'Configuring autostart...' }, async (ctx, name: string, options: { execCommand?: string }) => {
543
+ const client = createDevboxClient()
544
+ const body = options.execCommand ? { execCommand: options.execCommand } : {}
545
+ const { error, response } = await client.POST('/devbox/{name}/autostart', {
546
+ headers: ctx.auth,
547
+ params: {
548
+ path: { name }
549
+ },
550
+ body
551
+ })
552
+
553
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
554
+ ctx.spinner.succeed(`Autostart configured for "${name}"`)
555
+ }))
556
+
557
+ devboxCmd
558
+ .command('monitor <name>')
559
+ .description('Get devbox monitoring data')
560
+ .option('--start <timestamp>', 'Start timestamp in seconds or milliseconds')
561
+ .option('--end <timestamp>', 'End timestamp in seconds or milliseconds')
562
+ .option('--step <step>', 'Sampling interval, e.g. 2m')
563
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
564
+ .action(withAuth({ spinnerText: 'Loading monitor data...' }, async (
565
+ ctx,
566
+ name: string,
567
+ options: { start?: string; end?: string; step?: string; output: string }
568
+ ) => {
569
+ const client = createDevboxClient()
570
+ const { data, error, response } = await client.GET('/devbox/{name}/monitor', {
571
+ headers: ctx.auth,
572
+ params: {
573
+ path: { name },
574
+ query: {
575
+ ...(options.start ? { start: options.start } : {}),
576
+ ...(options.end ? { end: options.end } : {}),
577
+ ...(options.step ? { step: options.step } : {})
578
+ }
153
579
  }
580
+ })
154
581
 
155
- // TODO: 实现连接 devbox
156
- // 1. 获取 devbox 信息
157
- // 2. 根据 IDE 类型打开对应的连接
582
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
583
+ ctx.spinner.stop()
158
584
 
159
- info(`Opening ${name} in ${options.ide}...`)
160
- success(`Connect URL: ${options.ide}://remote-ssh/devbox-${name}`)
161
- } catch (error) {
162
- handleError(error)
585
+ if (options.output === 'json') {
586
+ outputJson(data)
587
+ return
163
588
  }
164
- })
589
+ printMonitor(data)
590
+ }))
165
591
 
166
- // devbox publish
167
592
  devboxCmd
168
- .command('publish')
169
- .description('Publish a devbox')
170
- .argument('<name>', 'Devbox name')
171
- .action(async (name) => {
172
- try {
173
- const context = getCurrentContext()
174
- if (!context) {
175
- throw new AuthError()
176
- }
593
+ .command('templates')
594
+ .description('List available devbox templates')
595
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
596
+ .action(withAuth({ spinnerText: 'Loading devbox templates...' }, async (ctx, options: { output: string }) => {
597
+ const client = createDevboxClient()
598
+ const { data, error, response } = await client.GET('/devbox/templates', {
599
+ headers: ctx.auth
600
+ })
177
601
 
178
- const spin = spinner('Publishing devbox...')
602
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
603
+ ctx.spinner.stop()
179
604
 
180
- // TODO: 实现发布 devbox
181
- spin.succeed('Devbox published successfully')
182
- } catch (error) {
183
- handleError(error)
605
+ if (options.output === 'json') {
606
+ outputJson(data)
607
+ return
184
608
  }
185
- })
186
-
187
- // devbox template 子命令
188
- const templateCmd = devboxCmd
189
- .command('template')
190
- .description('Manage devbox templates')
191
-
192
- templateCmd
193
- .command('build')
194
- .description('Build a devbox template')
195
- .option('--name <name>', 'Template name')
196
- .action(async (options) => {
197
- try {
198
- // TODO: 实现构建 template
199
- info('Building template...')
200
- } catch (error) {
201
- handleError(error)
609
+ printTemplates(data)
610
+ }))
611
+
612
+ const releasesCommand = devboxCmd
613
+ .command('releases')
614
+ .description('Manage devbox releases')
615
+
616
+ releasesCommand
617
+ .command('list <name>')
618
+ .description('List devbox releases')
619
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
620
+ .action(withAuth({ spinnerText: 'Loading releases...' }, async (ctx, name: string, options: { output: string }) => {
621
+ const client = createDevboxClient()
622
+ const { data, error, response } = await client.GET('/devbox/{name}/releases', {
623
+ headers: ctx.auth,
624
+ params: {
625
+ path: { name }
626
+ }
627
+ })
628
+
629
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
630
+ ctx.spinner.stop()
631
+
632
+ if (options.output === 'json') {
633
+ outputJson(data)
634
+ return
202
635
  }
203
- })
636
+ printReleases(data)
637
+ }))
204
638
 
205
- templateCmd
206
- .command('list')
207
- .description('List devbox templates')
208
- .action(async () => {
209
- try {
210
- // TODO: 列出 devbox templates
211
- const data = [
212
- ['NAME', 'DESCRIPTION', 'VERSION'],
213
- ['nextjs', 'Next.js development environment', '1.0.0'],
214
- ['python', 'Python 3.11 environment', '1.0.0']
215
- ]
216
-
217
- outputTable(data)
218
- } catch (error) {
219
- handleError(error)
639
+ releasesCommand
640
+ .command('create <name>')
641
+ .description('Create a devbox release')
642
+ .requiredOption('--tag <tag>', 'Release tag')
643
+ .option('--description <description>', 'Release description')
644
+ .option('--exec-command <command>', 'Autostart command after release restart')
645
+ .option('--no-start', 'Keep devbox stopped after the release build completes')
646
+ .action(withAuth({ spinnerText: 'Creating release...' }, async (ctx, name: string, options: DevboxReleaseOptions) => {
647
+ const client = createDevboxClient()
648
+ const { data, error, response } = await client.POST('/devbox/{name}/releases', {
649
+ headers: ctx.auth,
650
+ params: {
651
+ path: { name }
652
+ },
653
+ body: buildReleaseBody(options)
654
+ })
655
+
656
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
657
+ ctx.spinner.succeed(`Release "${options.tag}" accepted for "${data.name}" (${data.status})`)
658
+ }))
659
+
660
+ releasesCommand
661
+ .command('delete <name> <tag>')
662
+ .alias('rm')
663
+ .description('Delete a devbox release')
664
+ .action(withAuth({ spinnerText: 'Deleting release...' }, async (ctx, name: string, tag: string) => {
665
+ const client = createDevboxClient()
666
+ const { error, response } = await client.DELETE('/devbox/{name}/releases/{tag}', {
667
+ headers: ctx.auth,
668
+ params: {
669
+ path: { name, tag }
670
+ }
671
+ })
672
+
673
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
674
+ ctx.spinner.succeed(`Release "${tag}" deleted for "${name}"`)
675
+ }))
676
+
677
+ releasesCommand
678
+ .command('deploy <name> <tag>')
679
+ .description('Deploy a release to AppLaunchpad')
680
+ .action(withAuth({ spinnerText: 'Deploying release...' }, async (ctx, name: string, tag: string) => {
681
+ const client = createDevboxClient()
682
+ const { error, response } = await client.POST('/devbox/{name}/releases/{tag}/deploy', {
683
+ headers: ctx.auth,
684
+ params: {
685
+ path: { name, tag }
686
+ }
687
+ })
688
+
689
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
690
+ ctx.spinner.succeed(`Release "${tag}" deployed for "${name}"`)
691
+ }))
692
+
693
+ devboxCmd
694
+ .command('deployments <name>')
695
+ .description('List deployed applications from a devbox')
696
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
697
+ .action(withAuth({ spinnerText: 'Loading deployments...' }, async (ctx, name: string, options: { output: string }) => {
698
+ const client = createDevboxClient()
699
+ const { data, error, response } = await client.GET('/devbox/{name}/deployments', {
700
+ headers: ctx.auth,
701
+ params: {
702
+ path: { name }
703
+ }
704
+ })
705
+
706
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
707
+ ctx.spinner.stop()
708
+
709
+ if (options.output === 'json') {
710
+ outputJson(data)
711
+ return
220
712
  }
221
- })
713
+ printDeployments(data)
714
+ }))
222
715
 
223
716
  return devboxCmd
224
717
  }