sealos-cli 0.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.
@@ -0,0 +1,224 @@
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'
5
+
6
+ export function createDevboxCommand (): Command {
7
+ const devboxCmd = new Command('devbox')
8
+ .alias('dev')
9
+ .description('Manage devbox instances')
10
+
11
+ // devbox create
12
+ 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
+ }
28
+
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)
51
+ }
52
+ })
53
+
54
+ // devbox list
55
+ 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
+ }
66
+
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)
83
+ }
84
+ })
85
+
86
+ // devbox get
87
+ devboxCmd
88
+ .command('get')
89
+ .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()
96
+ }
97
+
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)
110
+ }
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
+ }
124
+
125
+ cmd.action(async (name, options) => {
126
+ try {
127
+ const context = getCurrentContext()
128
+ if (!context) {
129
+ throw new AuthError()
130
+ }
131
+
132
+ const spin = spinner(`${action}ing devbox...`)
133
+
134
+ // TODO: 实现对应操作
135
+ spin.succeed(`Devbox ${action}ed successfully`)
136
+ } catch (error) {
137
+ handleError(error)
138
+ }
139
+ })
140
+ })
141
+
142
+ // devbox connect
143
+ 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()
153
+ }
154
+
155
+ // TODO: 实现连接 devbox
156
+ // 1. 获取 devbox 信息
157
+ // 2. 根据 IDE 类型打开对应的连接
158
+
159
+ info(`Opening ${name} in ${options.ide}...`)
160
+ success(`Connect URL: ${options.ide}://remote-ssh/devbox-${name}`)
161
+ } catch (error) {
162
+ handleError(error)
163
+ }
164
+ })
165
+
166
+ // devbox publish
167
+ 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
+ }
177
+
178
+ const spin = spinner('Publishing devbox...')
179
+
180
+ // TODO: 实现发布 devbox
181
+ spin.succeed('Devbox published successfully')
182
+ } catch (error) {
183
+ handleError(error)
184
+ }
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)
202
+ }
203
+ })
204
+
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)
220
+ }
221
+ })
222
+
223
+ return devboxCmd
224
+ }
@@ -0,0 +1,22 @@
1
+ import { Command } from 'commander'
2
+ import { handleError } from '../../lib/errors.ts'
3
+
4
+ export function createQuotaCommand (): Command {
5
+ const quotaCmd = new Command('quota')
6
+ .description('View resource quotas')
7
+
8
+ // TODO: 实现配额相关命令
9
+
10
+ quotaCmd
11
+ .command('get')
12
+ .description('Get quota information')
13
+ .action(async () => {
14
+ try {
15
+ console.log('TODO: Implement quota get')
16
+ } catch (error) {
17
+ handleError(error)
18
+ }
19
+ })
20
+
21
+ return quotaCmd
22
+ }
@@ -0,0 +1,35 @@
1
+ import { Command } from 'commander'
2
+ import { handleError } from '../../lib/errors.ts'
3
+
4
+ export function createS3Command (): Command {
5
+ const s3Cmd = new Command('s3')
6
+ .description('Manage S3 object storage')
7
+
8
+ // TODO: 实现 S3 相关命令
9
+ // - upload
10
+ // - download
11
+ // - list
12
+ // - delete
13
+ // - sync
14
+ // - bucket (create/list/delete)
15
+
16
+ s3Cmd
17
+ .command('upload')
18
+ .description('Upload files to S3')
19
+ .argument('<source>', 'Source file or directory')
20
+ .argument('[destination]', 'Destination path')
21
+ .option('--bucket <bucket>', 'Bucket name')
22
+ .option('--acl <acl>', 'Access control: private, public-read')
23
+ .action(async (source, destination, options) => {
24
+ try {
25
+ console.log('TODO: Implement s3 upload', { source, destination, options })
26
+ } catch (error) {
27
+ handleError(error)
28
+ }
29
+ })
30
+
31
+ // 其他命令...
32
+ // 为了简洁,这里只实现 upload 作为示例
33
+
34
+ return s3Cmd
35
+ }
@@ -0,0 +1,314 @@
1
+ import { Command } from 'commander'
2
+ import chalk from 'chalk'
3
+ import { readFileSync } from 'node:fs'
4
+ import { createTemplateClient } from '../../lib/api-client.ts'
5
+ import { type ApiErrorBody, mapApiError } from '../../lib/errors.ts'
6
+ import { outputJson, outputTable } from '../../lib/output.ts'
7
+ import { withAuth, withErrorHandling } from '../../lib/with-auth.ts'
8
+
9
+ interface TemplateDeployOptions {
10
+ name?: string
11
+ file?: string
12
+ yaml?: string
13
+ set: string[]
14
+ dryRun?: boolean
15
+ }
16
+
17
+ export type TemplateDeployMode = 'catalog' | 'raw'
18
+
19
+ export function parseSetArgs (sets: string[]): Record<string, string> {
20
+ const args: Record<string, string> = {}
21
+ for (const s of sets) {
22
+ const idx = s.indexOf('=')
23
+ if (idx === -1) {
24
+ throw new Error(`Invalid --set format: "${s}". Expected KEY=VALUE`)
25
+ }
26
+ args[s.slice(0, idx)] = s.slice(idx + 1)
27
+ }
28
+ return args
29
+ }
30
+
31
+ function readStdin (): Promise<string> {
32
+ return new Promise((resolve, reject) => {
33
+ if (process.stdin.isTTY) {
34
+ reject(new Error('No input provided. Use --file, --yaml, or pipe YAML via stdin.'))
35
+ return
36
+ }
37
+ const chunks: Buffer[] = []
38
+ process.stdin.on('data', (chunk) => chunks.push(chunk))
39
+ process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8').trim()))
40
+ process.stdin.on('error', reject)
41
+ })
42
+ }
43
+
44
+ async function resolveYaml (options: { file?: string; yaml?: string }, spinner: { stop: () => void; start: (text?: string) => void }): Promise<string> {
45
+ if (options.file) {
46
+ return readFileSync(options.file, 'utf-8')
47
+ }
48
+ if (options.yaml) {
49
+ return options.yaml
50
+ }
51
+ spinner.stop()
52
+ const content = await readStdin()
53
+ spinner.start('Deploying template...')
54
+ return content
55
+ }
56
+
57
+ export function resolveTemplateDeployMode (
58
+ template: string | undefined,
59
+ options: TemplateDeployOptions,
60
+ stdinIsTTY: boolean = process.stdin.isTTY
61
+ ): TemplateDeployMode {
62
+ const isRaw = !!(options.file || options.yaml || !stdinIsTTY)
63
+
64
+ if (template && isRaw) {
65
+ throw new Error('Cannot specify both a template name and --file/--yaml/stdin. Use one or the other.')
66
+ }
67
+ if (!template && !isRaw) {
68
+ throw new Error('Provide a template name or use --file/--yaml/stdin to supply raw YAML.')
69
+ }
70
+ if (template) {
71
+ if (!options.name) {
72
+ throw new Error('--name is required when deploying from the template catalog.')
73
+ }
74
+ if (options.dryRun) {
75
+ throw new Error('--dry-run is only supported for raw template deploys (--file, --yaml, or stdin).')
76
+ }
77
+ return 'catalog'
78
+ }
79
+
80
+ return 'raw'
81
+ }
82
+
83
+ export function buildCatalogTemplateDeployBody (
84
+ template: string,
85
+ options: Pick<TemplateDeployOptions, 'name' | 'set'>
86
+ ): { name: string; template: string; args?: Record<string, string> } {
87
+ const body: { name: string; template: string; args?: Record<string, string> } = {
88
+ name: options.name!,
89
+ template
90
+ }
91
+ if (options.set.length > 0) {
92
+ body.args = parseSetArgs(options.set)
93
+ }
94
+ return body
95
+ }
96
+
97
+ export function buildRawTemplateDeployBody (
98
+ yaml: string,
99
+ options: Pick<TemplateDeployOptions, 'set' | 'dryRun'>
100
+ ): { yaml: string; args?: Record<string, string>; dryRun?: boolean } {
101
+ const body: { yaml: string; args?: Record<string, string>; dryRun?: boolean } = {
102
+ yaml
103
+ }
104
+ if (options.set.length > 0) {
105
+ body.args = parseSetArgs(options.set)
106
+ }
107
+ if (options.dryRun) {
108
+ body.dryRun = true
109
+ }
110
+ return body
111
+ }
112
+
113
+ export function createTemplateCommand (): Command {
114
+ const tplCmd = new Command('template')
115
+ .alias('tpl')
116
+ .description('Manage templates')
117
+
118
+ const deployTemplate = withAuth({
119
+ spinnerText: 'Deploying template...'
120
+ }, async (
121
+ ctx,
122
+ catalogTemplate: string | undefined,
123
+ deployOptions: TemplateDeployOptions,
124
+ deployMode: TemplateDeployMode
125
+ ) => {
126
+ const client = createTemplateClient()
127
+
128
+ // ── deploy from catalog ──
129
+ if (deployMode === 'catalog') {
130
+ const body = buildCatalogTemplateDeployBody(catalogTemplate!, deployOptions)
131
+ const { data, error, response } = await client.POST('/templates/instances', {
132
+ headers: ctx.auth,
133
+ body
134
+ })
135
+
136
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
137
+
138
+ ctx.spinner.succeed(`Instance "${data.name}" created successfully from catalog template "${catalogTemplate}"`)
139
+ console.log(chalk.dim(` UID: ${data.uid}`))
140
+ console.log(chalk.dim(` Created: ${data.createdAt}`))
141
+
142
+ if (data.resources && data.resources.length > 0) {
143
+ console.log(chalk.dim('\n Resources:'))
144
+ const rows: string[][] = [
145
+ [chalk.bold('Name'), chalk.bold('Type'), chalk.bold('CPU'), chalk.bold('Memory'), chalk.bold('Storage')]
146
+ ]
147
+ for (const r of data.resources) {
148
+ rows.push([
149
+ r.name,
150
+ r.resourceType,
151
+ r.quota?.cpu != null ? `${r.quota.cpu} vCPU` : '-',
152
+ r.quota?.memory != null ? `${r.quota.memory} GiB` : '-',
153
+ r.quota?.storage != null ? `${r.quota.storage} GiB` : '-'
154
+ ])
155
+ }
156
+ outputTable(rows)
157
+ }
158
+ return
159
+ }
160
+
161
+ // ── deploy from raw YAML ──
162
+ const yamlContent = await resolveYaml(deployOptions, ctx.spinner)
163
+ const body = buildRawTemplateDeployBody(yamlContent, deployOptions)
164
+
165
+ const { data, error, response } = await client.POST('/templates/raw', {
166
+ headers: ctx.auth,
167
+ body
168
+ })
169
+
170
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
171
+
172
+ if (deployOptions.dryRun) {
173
+ ctx.spinner.succeed('Raw template validation passed; no resources were created')
174
+ console.log(chalk.dim(` Name: ${data.name}`))
175
+ if (data.resources && data.resources.length > 0) {
176
+ console.log(chalk.dim('\n Resources that would be created:'))
177
+ for (const r of data.resources) {
178
+ console.log(chalk.dim(` - ${r.resourceType}: ${r.name}`))
179
+ }
180
+ }
181
+ return
182
+ }
183
+
184
+ ctx.spinner.succeed(`Raw template deployed as "${data.name}"`)
185
+ if ('uid' in data) {
186
+ console.log(chalk.dim(` UID: ${data.uid}`))
187
+ }
188
+ if ('createdAt' in data) {
189
+ console.log(chalk.dim(` Created: ${data.createdAt}`))
190
+ }
191
+ })
192
+
193
+ // ── list ─────────────────────────────────────────────────────────
194
+ tplCmd
195
+ .command('list')
196
+ .description('List available templates')
197
+ .option('-c, --category <category>', 'Filter by category')
198
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
199
+ .action(withErrorHandling({ spinnerText: 'Loading templates...' }, async (ctx, options: { category?: string; output: string }) => {
200
+ const client = createTemplateClient()
201
+ const { data, error, response } = await client.GET('/templates', {
202
+ params: { query: {} }
203
+ })
204
+
205
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
206
+
207
+ ctx.spinner.stop()
208
+
209
+ let templates = data
210
+ if (options.category) {
211
+ templates = templates.filter((tpl: typeof data[number]) => tpl.category.includes(options.category!))
212
+ }
213
+
214
+ if (options.output === 'json') {
215
+ outputJson(templates)
216
+ return
217
+ }
218
+
219
+ const rows: string[][] = [
220
+ [chalk.bold('Name'), chalk.bold('Description'), chalk.bold('Category'), chalk.bold('Deploys')]
221
+ ]
222
+ for (const tpl of templates) {
223
+ rows.push([
224
+ tpl.name,
225
+ tpl.description.length > 50 ? tpl.description.slice(0, 47) + '...' : tpl.description,
226
+ tpl.category.join(', '),
227
+ String(tpl.deployCount)
228
+ ])
229
+ }
230
+ outputTable(rows)
231
+ }))
232
+
233
+ // ── get ──────────────────────────────────────────────────────────
234
+ tplCmd
235
+ .command('get <name>')
236
+ .alias('describe')
237
+ .description('Get template details')
238
+ .option('-o, --output <format>', 'Output format (json|table)', 'table')
239
+ .action(withErrorHandling({ spinnerText: 'Loading template...' }, async (ctx, name: string, options: { output: string }) => {
240
+ const client = createTemplateClient()
241
+ const { data, error, response } = await client.GET('/templates/{name}', {
242
+ params: {
243
+ path: { name }
244
+ }
245
+ })
246
+
247
+ if (error) throw mapApiError(response.status, error as ApiErrorBody)
248
+
249
+ ctx.spinner.stop()
250
+
251
+ if (options.output === 'json') {
252
+ outputJson(data)
253
+ return
254
+ }
255
+
256
+ console.log(chalk.bold(`\n ${data.name}\n`))
257
+ console.log(` ${chalk.dim('Description:')} ${data.description}`)
258
+ console.log(` ${chalk.dim('Category:')} ${data.category.join(', ')}`)
259
+ console.log(` ${chalk.dim('Git Repo:')} ${data.gitRepo}`)
260
+ console.log(` ${chalk.dim('Deploys:')} ${data.deployCount}`)
261
+
262
+ if (data.quota) {
263
+ console.log(`\n ${chalk.dim('Resources:')}`)
264
+ console.log(` CPU: ${data.quota.cpu} vCPU`)
265
+ console.log(` Memory: ${data.quota.memory} GiB`)
266
+ console.log(` Storage: ${data.quota.storage} GiB`)
267
+ console.log(` NodePort: ${data.quota.nodeport}`)
268
+ }
269
+
270
+ const argEntries = Object.entries(data.args)
271
+ if (argEntries.length > 0) {
272
+ console.log(`\n ${chalk.dim('Arguments:')}`)
273
+ const argRows: string[][] = [
274
+ [chalk.bold('Name'), chalk.bold('Type'), chalk.bold('Required'), chalk.bold('Default'), chalk.bold('Description')]
275
+ ]
276
+ for (const [key, arg] of argEntries) {
277
+ argRows.push([
278
+ key,
279
+ arg.type,
280
+ arg.required ? chalk.red('yes') : 'no',
281
+ arg.default || chalk.dim('-'),
282
+ arg.description
283
+ ])
284
+ }
285
+ outputTable(argRows)
286
+ }
287
+ }))
288
+
289
+ // ── deploy ──────────────────────────────────────────────────────
290
+ tplCmd
291
+ .command('deploy [template]')
292
+ .description('Deploy a template (from catalog or raw YAML)')
293
+ .option('--name <name>', 'Instance name (required when deploying from catalog)')
294
+ .option('--file <path>', 'Path to template YAML file')
295
+ .option('--yaml <yaml>', 'Template YAML string')
296
+ .option('--set <KEY=VALUE...>', 'Set template arguments', (val: string, prev: string[]) => [...prev, val], [] as string[])
297
+ .option('--dry-run', 'Validate raw template YAML without creating resources')
298
+ .addHelpText('after', `
299
+ Examples:
300
+ Catalog:
301
+ sealos template deploy perplexica --name my-app --set OPENAI_API_KEY=xxx
302
+
303
+ Raw:
304
+ sealos template deploy --file ./template.yaml --dry-run
305
+ sealos template deploy --yaml 'apiVersion: app.sealos.io/v1\nkind: Template\n...'
306
+ cat template.yaml | sealos template deploy --dry-run
307
+ `)
308
+ .action(async (template: string | undefined, options: TemplateDeployOptions) => {
309
+ const mode = resolveTemplateDeployMode(template, options)
310
+ await deployTemplate(template, options, mode)
311
+ })
312
+
313
+ return tplCmd
314
+ }
@@ -0,0 +1,84 @@
1
+ import { Command } from 'commander'
2
+ import { getCurrentContext } from '../../lib/config.ts'
3
+ import { success, outputTable, info } from '../../lib/output.ts'
4
+ import { handleError, AuthError } from '../../lib/errors.ts'
5
+
6
+ export function createWorkspaceCommand (): Command {
7
+ const workspaceCmd = new Command('workspace')
8
+ .alias('ws')
9
+ .description('Manage workspaces')
10
+
11
+ // workspace switch
12
+ workspaceCmd
13
+ .command('switch')
14
+ .description('Switch to another workspace')
15
+ .argument('<name>', 'Workspace name')
16
+ .action(async (name) => {
17
+ try {
18
+ // TODO: 调用 API 验证 workspace 是否存在
19
+ // const api = createApiClient()
20
+ // await api.get(`/api/v1/workspaces/${name}`)
21
+
22
+ // 更新配置
23
+ const context = getCurrentContext()
24
+ if (context) {
25
+ context.workspace = name
26
+ // TODO: 更新到配置文件
27
+ }
28
+
29
+ success(`Switched to workspace: ${name}`)
30
+ } catch (error) {
31
+ handleError(error)
32
+ }
33
+ })
34
+
35
+ // workspace list
36
+ workspaceCmd
37
+ .command('list')
38
+ .description('List all workspaces')
39
+ .action(async () => {
40
+ try {
41
+ const context = getCurrentContext()
42
+ if (!context) {
43
+ throw new AuthError()
44
+ }
45
+
46
+ // 示例数据
47
+ const data = [
48
+ ['NAME', 'STATUS', 'CURRENT'],
49
+ ['default', 'Active', context.workspace === 'default' ? '*' : ''],
50
+ ['production', 'Active', context.workspace === 'production' ? '*' : '']
51
+ ]
52
+
53
+ outputTable(data)
54
+ info('API integration needed for real data')
55
+ } catch (error) {
56
+ handleError(error)
57
+ }
58
+ })
59
+
60
+ // workspace current
61
+ workspaceCmd
62
+ .command('current')
63
+ .description('Show current workspace')
64
+ .action(async () => {
65
+ try {
66
+ const context = getCurrentContext()
67
+ if (!context) {
68
+ throw new AuthError()
69
+ }
70
+
71
+ const data = [
72
+ ['Field', 'Value'],
73
+ ['Workspace', context.workspace],
74
+ ['Context', context.name]
75
+ ]
76
+
77
+ outputTable(data)
78
+ } catch (error) {
79
+ handleError(error)
80
+ }
81
+ })
82
+
83
+ return workspaceCmd
84
+ }