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,134 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs'
4
+ import type { SealosConfig, Context } from '../types/index.ts'
5
+
6
+ const CONFIG_DIR = join(homedir(), '.sealos')
7
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json')
8
+
9
+ /**
10
+ * Ensure config directory exists
11
+ */
12
+ export function ensureConfigDir (): void {
13
+ if (!existsSync(CONFIG_DIR)) {
14
+ mkdirSync(CONFIG_DIR, { recursive: true })
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Read config file
20
+ */
21
+ export function readConfig (): SealosConfig {
22
+ ensureConfigDir()
23
+
24
+ if (!existsSync(CONFIG_FILE)) {
25
+ const defaultConfig: SealosConfig = {
26
+ currentContext: '',
27
+ contexts: []
28
+ }
29
+ return defaultConfig
30
+ }
31
+
32
+ try {
33
+ const content = readFileSync(CONFIG_FILE, 'utf-8')
34
+ return JSON.parse(content) as SealosConfig
35
+ } catch (error) {
36
+ throw new Error(`Failed to read config file: ${error}`)
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Write config file
42
+ */
43
+ export function writeConfig (config: SealosConfig): void {
44
+ ensureConfigDir()
45
+
46
+ try {
47
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8')
48
+ } catch (error) {
49
+ throw new Error(`Failed to write config file: ${error}`)
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Get current context
55
+ */
56
+ export function getCurrentContext (): Context | null {
57
+ const config = readConfig()
58
+ if (!config.currentContext) {
59
+ return null
60
+ }
61
+
62
+ const context = config.contexts.find(ctx => ctx.name === config.currentContext)
63
+ return context || null
64
+ }
65
+
66
+ /**
67
+ * Set current context
68
+ */
69
+ export function setCurrentContext (name: string): void {
70
+ const config = readConfig()
71
+ const context = config.contexts.find(ctx => ctx.name === name)
72
+
73
+ if (!context) {
74
+ throw new Error(`Context "${name}" not found`)
75
+ }
76
+
77
+ config.currentContext = name
78
+ writeConfig(config)
79
+ }
80
+
81
+ /**
82
+ * Add or update context
83
+ */
84
+ export function upsertContext (context: Context): void {
85
+ const config = readConfig()
86
+ const existingIndex = config.contexts.findIndex(ctx => ctx.name === context.name)
87
+
88
+ if (existingIndex >= 0) {
89
+ config.contexts[existingIndex] = context
90
+ } else {
91
+ config.contexts.push(context)
92
+ }
93
+
94
+ // If this is the first context, set it as current automatically
95
+ if (!config.currentContext) {
96
+ config.currentContext = context.name
97
+ }
98
+
99
+ writeConfig(config)
100
+ }
101
+
102
+ /**
103
+ * Remove context
104
+ */
105
+ export function removeContext (name: string): void {
106
+ const config = readConfig()
107
+ config.contexts = config.contexts.filter(ctx => ctx.name !== name)
108
+
109
+ // If removing current context, clear currentContext
110
+ if (config.currentContext === name) {
111
+ config.currentContext = config.contexts[0]?.name || ''
112
+ }
113
+
114
+ writeConfig(config)
115
+ }
116
+
117
+ /**
118
+ * Get config value
119
+ */
120
+ export function getConfigValue (key: string): string | undefined {
121
+ const config = readConfig()
122
+ // TODO: Implement nested key access, e.g. "contexts.0.name"
123
+ return (config as unknown as Record<string, unknown>)[key] as string | undefined
124
+ }
125
+
126
+ /**
127
+ * Set config value
128
+ */
129
+ export function setConfigValue (key: string, value: string): void {
130
+ const config = readConfig()
131
+ // TODO: Implement nested key setting
132
+ ;(config as unknown as Record<string, unknown>)[key] = value
133
+ writeConfig(config)
134
+ }
@@ -0,0 +1 @@
1
+ export const CLIENT_ID = 'af993c98-d19d-4bdc-b338-79b80dc4f8bf'
@@ -0,0 +1,105 @@
1
+ import chalk from 'chalk'
2
+
3
+ /**
4
+ * CLI error base class
5
+ */
6
+ export class CliError extends Error {
7
+ constructor (message: string, public exitCode: number = 1) {
8
+ super(message)
9
+ this.name = 'CliError'
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Authentication error
15
+ */
16
+ export class AuthError extends CliError {
17
+ constructor (message: string = 'Authentication required. Please run "sealos login" first.') {
18
+ super(message, 1)
19
+ this.name = 'AuthError'
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Configuration error
25
+ */
26
+ export class ConfigError extends CliError {
27
+ constructor (message: string) {
28
+ super(message, 1)
29
+ this.name = 'ConfigError'
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Standard API error response body
35
+ */
36
+ export interface ApiErrorBody {
37
+ error?: {
38
+ type?: string
39
+ code?: string
40
+ message?: string
41
+ details?: Array<{ field: string; message: string }> | string
42
+ }
43
+ }
44
+
45
+ /**
46
+ * API error
47
+ */
48
+ export class ApiError extends CliError {
49
+ constructor (
50
+ message: string,
51
+ public statusCode?: number,
52
+ public code?: string,
53
+ public details?: Array<{ field: string; message: string }> | string
54
+ ) {
55
+ super(message, 1)
56
+ this.name = 'ApiError'
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Map an API error response to the appropriate CliError.
62
+ * Supports the unified error format: { error: { type, code, message, details? } }
63
+ */
64
+ export function mapApiError (status: number, body?: ApiErrorBody): CliError {
65
+ const message = body?.error?.message || `API request failed with status ${status}`
66
+ if (status === 401) {
67
+ return new AuthError(message)
68
+ }
69
+ return new ApiError(message, status, body?.error?.code, body?.error?.details)
70
+ }
71
+
72
+ /**
73
+ * Unified error handling
74
+ */
75
+ export function handleError (error: unknown): never {
76
+ if (error instanceof ApiError) {
77
+ console.error(chalk.red('Error:'), error.message)
78
+ if (error.details) {
79
+ if (Array.isArray(error.details)) {
80
+ for (const d of error.details) {
81
+ console.error(chalk.yellow(` ${d.field}:`), d.message)
82
+ }
83
+ } else {
84
+ console.error(chalk.yellow(' Details:'), error.details)
85
+ }
86
+ }
87
+ process.exit(error.exitCode)
88
+ }
89
+
90
+ if (error instanceof CliError) {
91
+ console.error(chalk.red('Error:'), error.message)
92
+ process.exit(error.exitCode)
93
+ }
94
+
95
+ if (error instanceof Error) {
96
+ console.error(chalk.red('Error:'), error.message)
97
+ if (process.env.DEBUG) {
98
+ console.error(error.stack)
99
+ }
100
+ process.exit(1)
101
+ }
102
+
103
+ console.error(chalk.red('Error:'), 'An unknown error occurred')
104
+ process.exit(1)
105
+ }
@@ -0,0 +1,197 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { platform } from 'node:os'
3
+ import type { Ora } from 'ora'
4
+ import chalk from 'chalk'
5
+ import { CLIENT_ID } from './constants.ts'
6
+
7
+ interface DeviceAuthResponse {
8
+ device_code: string
9
+ user_code: string
10
+ verification_uri: string
11
+ verification_uri_complete?: string
12
+ expires_in: number
13
+ interval?: number
14
+ }
15
+
16
+ interface TokenResponse {
17
+ access_token: string
18
+ token_type: string
19
+ }
20
+
21
+ function sleep (ms: number): Promise<void> {
22
+ return new Promise(resolve => setTimeout(resolve, ms))
23
+ }
24
+
25
+ /**
26
+ * POST /api/auth/oauth2/device to start device authorization.
27
+ */
28
+ export async function requestDeviceAuthorization (region: string): Promise<DeviceAuthResponse> {
29
+ const res = await fetch(`${region}/api/auth/oauth2/device`, {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
32
+ body: new URLSearchParams({
33
+ client_id: CLIENT_ID,
34
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
35
+ })
36
+ })
37
+
38
+ if (!res.ok) {
39
+ const contentType = res.headers.get('content-type') || ''
40
+ if (res.status === 404) {
41
+ throw new Error(
42
+ 'OAuth2 device grant not supported on this host.\n' +
43
+ `'${region}' does not have the device authorization endpoint.`
44
+ )
45
+ }
46
+ const body = contentType.includes('text/html')
47
+ ? ''
48
+ : await res.text().catch(() => '')
49
+ throw new Error(`Device authorization request failed (${res.status}): ${body || res.statusText}`)
50
+ }
51
+
52
+ return res.json() as Promise<DeviceAuthResponse>
53
+ }
54
+
55
+ /**
56
+ * Poll POST /api/auth/oauth2/token until the user authorizes.
57
+ * Handles: authorization_pending, slow_down (+5s per RFC 8628 §3.5),
58
+ * access_denied, expired_token. Hard cap at 10 minutes.
59
+ */
60
+ export async function pollForToken (
61
+ region: string,
62
+ deviceCode: string,
63
+ interval: number,
64
+ expiresIn: number
65
+ ): Promise<TokenResponse> {
66
+ const maxWait = Math.min(expiresIn, 600) * 1000
67
+ const deadline = Date.now() + maxWait
68
+ let pollInterval = interval * 1000
69
+
70
+ while (Date.now() < deadline) {
71
+ await sleep(pollInterval)
72
+
73
+ const res = await fetch(`${region}/api/auth/oauth2/token`, {
74
+ method: 'POST',
75
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
76
+ body: new URLSearchParams({
77
+ client_id: CLIENT_ID,
78
+ grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
79
+ device_code: deviceCode
80
+ })
81
+ })
82
+
83
+ if (res.ok) {
84
+ return res.json() as Promise<TokenResponse>
85
+ }
86
+
87
+ const body = await res.json().catch(() => ({})) as { error?: string }
88
+
89
+ switch (body.error) {
90
+ case 'authorization_pending':
91
+ break
92
+
93
+ case 'slow_down':
94
+ pollInterval += 5000
95
+ break
96
+
97
+ case 'access_denied':
98
+ throw new Error('Authorization denied by user')
99
+
100
+ case 'expired_token':
101
+ throw new Error('Device code expired. Please run login again.')
102
+
103
+ default:
104
+ throw new Error(`Token request failed: ${body.error || res.statusText}`)
105
+ }
106
+ }
107
+
108
+ throw new Error('Authorization timed out (10 minutes). Please run login again.')
109
+ }
110
+
111
+ /**
112
+ * Exchange an OAuth access token for a Sealos kubeconfig.
113
+ */
114
+ export async function exchangeForKubeconfig (region: string, accessToken: string): Promise<string> {
115
+ const res = await fetch(`${region}/api/auth/getDefaultKubeconfig`, {
116
+ method: 'POST',
117
+ headers: {
118
+ Authorization: accessToken,
119
+ 'Content-Type': 'application/json'
120
+ }
121
+ })
122
+
123
+ if (!res.ok) {
124
+ const body = await res.text().catch(() => '')
125
+ throw new Error(`Kubeconfig exchange failed (${res.status}): ${body || res.statusText}`)
126
+ }
127
+
128
+ const data = await res.json() as { data?: { kubeconfig?: string } }
129
+ const kubeconfig = data.data?.kubeconfig
130
+
131
+ if (!kubeconfig) {
132
+ throw new Error('API response missing data.kubeconfig field')
133
+ }
134
+
135
+ return kubeconfig
136
+ }
137
+
138
+ /**
139
+ * Open a URL in the user's default browser. Swallows errors silently.
140
+ */
141
+ export function openBrowser (url: string): void {
142
+ try {
143
+ const os = platform()
144
+ const cmd = os === 'darwin' ? 'open' : os === 'win32' ? 'start' : 'xdg-open'
145
+ execSync(`${cmd} "${url}"`, { stdio: 'ignore' })
146
+ } catch {
147
+ // Silently ignore — user can open manually
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Orchestrate the full OAuth2 Device Grant login flow.
153
+ */
154
+ export async function deviceGrantLogin (
155
+ region: string,
156
+ spinner: Ora
157
+ ): Promise<{ kubeconfig: string; region: string }> {
158
+ // Step 1: Request device authorization
159
+ spinner.stop()
160
+ const deviceAuth = await requestDeviceAuthorization(region)
161
+
162
+ const {
163
+ device_code: deviceCode,
164
+ user_code: userCode,
165
+ verification_uri: verificationUri,
166
+ verification_uri_complete: verificationUriComplete,
167
+ expires_in: expiresIn,
168
+ interval = 5
169
+ } = deviceAuth
170
+
171
+ // Step 2: Display verification info
172
+ const url = verificationUriComplete || verificationUri
173
+ console.log()
174
+ console.log(chalk.bold(' Open this URL to authorize:'))
175
+ console.log(` ${chalk.cyan(url)}`)
176
+ console.log()
177
+ console.log(` Code: ${chalk.bold.yellow(userCode)}`)
178
+ console.log()
179
+
180
+ // Step 3: Auto-open browser
181
+ openBrowser(url)
182
+
183
+ // Step 4: Poll for token
184
+ spinner.start('Waiting for authorization...')
185
+ const tokenResponse = await pollForToken(region, deviceCode, interval, expiresIn)
186
+ const accessToken = tokenResponse.access_token
187
+
188
+ if (!accessToken) {
189
+ throw new Error('Token response missing access_token')
190
+ }
191
+
192
+ // Step 5: Exchange for kubeconfig
193
+ spinner.text = 'Exchanging for kubeconfig...'
194
+ const kubeconfig = await exchangeForKubeconfig(region, accessToken)
195
+
196
+ return { kubeconfig, region }
197
+ }
@@ -0,0 +1,93 @@
1
+ import chalk from 'chalk'
2
+ import { table } from 'table'
3
+ import ora, { type Ora } from 'ora'
4
+
5
+ /**
6
+ * Output JSON format
7
+ */
8
+ export function outputJson (data: any): void {
9
+ console.log(JSON.stringify(data, null, 2))
10
+ }
11
+
12
+ /**
13
+ * Output YAML format
14
+ */
15
+ export function outputYaml (data: any): void {
16
+ // TODO: Use yaml library to implement
17
+ console.log('YAML output not implemented yet')
18
+ console.log(data)
19
+ }
20
+
21
+ /**
22
+ * Output table
23
+ */
24
+ export function outputTable (data: string[][]): void {
25
+ console.log(table(data))
26
+ }
27
+
28
+ /**
29
+ * Format output
30
+ */
31
+ export function formatOutput (data: any, format: 'json' | 'yaml' | 'table' = 'table'): void {
32
+ switch (format) {
33
+ case 'json':
34
+ outputJson(data)
35
+ break
36
+ case 'yaml':
37
+ outputYaml(data)
38
+ break
39
+ case 'table':
40
+ if (Array.isArray(data)) {
41
+ outputTable(data)
42
+ } else {
43
+ console.log(data)
44
+ }
45
+ break
46
+ default:
47
+ console.log(data)
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Success message
53
+ */
54
+ export function success (message: string): void {
55
+ console.log(chalk.green('✓'), message)
56
+ }
57
+
58
+ /**
59
+ * Error message
60
+ */
61
+ export function error (message: string): void {
62
+ console.error(chalk.red('✗'), message)
63
+ }
64
+
65
+ /**
66
+ * Warning message
67
+ */
68
+ export function warn (message: string): void {
69
+ console.warn(chalk.yellow('⚠'), message)
70
+ }
71
+
72
+ /**
73
+ * Info message
74
+ */
75
+ export function info (message: string): void {
76
+ console.log(chalk.blue('ℹ'), message)
77
+ }
78
+
79
+ /**
80
+ * Create loading spinner
81
+ */
82
+ export function spinner (text: string): Ora {
83
+ return ora(text).start()
84
+ }
85
+
86
+ /**
87
+ * Confirmation prompt
88
+ */
89
+ export async function confirm (message: string): Promise<boolean> {
90
+ // TODO: Use inquirer or other interactive library
91
+ console.log(chalk.yellow('?'), message)
92
+ return true
93
+ }
@@ -0,0 +1,56 @@
1
+ import type { Ora } from 'ora'
2
+ import { spinner } from './output.ts'
3
+ import { requireAuth } from './auth.ts'
4
+ import { handleError } from './errors.ts'
5
+
6
+ interface AuthContext {
7
+ auth: { Authorization: string }
8
+ spinner: Ora
9
+ }
10
+
11
+ interface ErrorHandlingContext {
12
+ spinner: Ora
13
+ }
14
+
15
+ interface WithAuthOptions {
16
+ spinnerText: string
17
+ }
18
+
19
+ /**
20
+ * Wraps a command handler that requires authentication.
21
+ * Handles: auth check + spinner + try/catch + error handling
22
+ */
23
+ export function withAuth<T extends any[]> (
24
+ options: WithAuthOptions,
25
+ fn: (ctx: AuthContext, ...args: T) => Promise<void>
26
+ ): (...args: T) => Promise<void> {
27
+ return async (...args: T) => {
28
+ const spin = spinner(options.spinnerText)
29
+ try {
30
+ const auth = requireAuth()
31
+ await fn({ auth, spinner: spin }, ...args)
32
+ } catch (error) {
33
+ spin.fail()
34
+ handleError(error)
35
+ }
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Wraps a command handler that does NOT require authentication.
41
+ * Handles: spinner + try/catch + error handling
42
+ */
43
+ export function withErrorHandling<T extends any[]> (
44
+ options: WithAuthOptions,
45
+ fn: (ctx: ErrorHandlingContext, ...args: T) => Promise<void>
46
+ ): (...args: T) => Promise<void> {
47
+ return async (...args: T) => {
48
+ const spin = spinner(options.spinnerText)
49
+ try {
50
+ await fn({ spinner: spin }, ...args)
51
+ } catch (error) {
52
+ spin.fail()
53
+ handleError(error)
54
+ }
55
+ }
56
+ }
package/src/main.ts ADDED
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander'
3
+ import { registerAuthCommands } from './commands/auth/index.ts'
4
+ import { createWorkspaceCommand } from './commands/workspace/index.ts'
5
+ import { createDevboxCommand } from './commands/devbox/index.ts'
6
+ import { createS3Command } from './commands/s3/index.ts'
7
+ import { createDatabaseCommand } from './commands/database/index.ts'
8
+ import { createTemplateCommand } from './commands/template/index.ts'
9
+ import { createQuotaCommand } from './commands/quota/index.ts'
10
+ import { createAppCommand } from './commands/app/index.ts'
11
+ import { createConfigCommand } from './commands/config/index.ts'
12
+ import { handleError } from './lib/errors.ts'
13
+
14
+ export function createProgram (): Command {
15
+ const program = new Command()
16
+
17
+ program
18
+ .name('sealos')
19
+ .description('Official CLI tool for Sealos Cloud - Manage devbox, applications, databases, and object storage')
20
+ .version('0.0.1')
21
+
22
+ // Register all command modules
23
+ registerAuthCommands(program)
24
+ program.addCommand(createWorkspaceCommand())
25
+ program.addCommand(createDevboxCommand())
26
+ program.addCommand(createS3Command())
27
+ program.addCommand(createDatabaseCommand())
28
+ program.addCommand(createTemplateCommand())
29
+ program.addCommand(createQuotaCommand())
30
+ program.addCommand(createAppCommand())
31
+ program.addCommand(createConfigCommand())
32
+
33
+ return program
34
+ }
35
+
36
+ export function runCLI (): void {
37
+ const program = createProgram()
38
+
39
+ // Global error handling
40
+ program.exitOverride()
41
+
42
+ try {
43
+ program.parse(process.argv)
44
+ } catch (error) {
45
+ if (error instanceof Error && 'code' in error &&
46
+ typeof error.code === 'string' && error.code.startsWith('commander.')) {
47
+ process.exit(0)
48
+ }
49
+ handleError(error)
50
+ }
51
+ }
@@ -0,0 +1,56 @@
1
+ // Core type definitions
2
+
3
+ export interface SealosConfig {
4
+ currentContext: string
5
+ contexts: Context[]
6
+ }
7
+
8
+ export interface Context {
9
+ name: string
10
+ host: string
11
+ token: string
12
+ workspace: string
13
+ }
14
+
15
+ export interface DevboxConfig {
16
+ name?: string
17
+ template: string
18
+ resources: {
19
+ cpu: string
20
+ memory: string
21
+ storage?: string
22
+ }
23
+ ports?: number[]
24
+ env?: Record<string, string>
25
+ }
26
+
27
+ export interface OutputOptions {
28
+ format: 'json' | 'yaml' | 'table'
29
+ }
30
+
31
+ export interface ApiResponse<T = any> {
32
+ success: boolean
33
+ data?: T
34
+ error?: string
35
+ }
36
+
37
+ export interface SealosWorkspace {
38
+ uid?: string
39
+ id?: string
40
+ teamName?: string
41
+ role?: string
42
+ nstype?: string
43
+ }
44
+
45
+ export interface SealosAuthData {
46
+ region: string
47
+ access_token?: string
48
+ regional_token?: string
49
+ authenticated_at?: string
50
+ auth_method?: string
51
+ current_workspace?: {
52
+ uid?: string
53
+ id?: string
54
+ teamName?: string
55
+ }
56
+ }