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.
- package/LICENSE +191 -0
- package/README.md +234 -0
- package/dist/bin/cli.cjs +2066 -0
- package/dist/bin/cli.d.cts +1 -0
- package/dist/bin/cli.d.ts +1 -0
- package/dist/bin/cli.mjs +2044 -0
- package/dist/main.cjs +2079 -0
- package/dist/main.d.cts +7 -0
- package/dist/main.d.ts +7 -0
- package/dist/main.mjs +2045 -0
- package/package.json +112 -0
- package/src/bin/cli.ts +4 -0
- package/src/commands/app/index.ts +22 -0
- package/src/commands/auth/index.ts +124 -0
- package/src/commands/auth/login.ts +35 -0
- package/src/commands/auth/logout.ts +23 -0
- package/src/commands/auth/whoami.ts +38 -0
- package/src/commands/config/index.ts +54 -0
- package/src/commands/database/index.ts +881 -0
- package/src/commands/devbox/index.ts +224 -0
- package/src/commands/quota/index.ts +22 -0
- package/src/commands/s3/index.ts +35 -0
- package/src/commands/template/index.ts +314 -0
- package/src/commands/workspace/index.ts +84 -0
- package/src/docs/database_openapi.json +8297 -0
- package/src/docs/template_openapi.json +1 -0
- package/src/generated/database.ts +3969 -0
- package/src/generated/template.ts +1007 -0
- package/src/lib/api-client.ts +64 -0
- package/src/lib/api.ts +83 -0
- package/src/lib/auth.ts +570 -0
- package/src/lib/config.ts +134 -0
- package/src/lib/constants.ts +1 -0
- package/src/lib/errors.ts +105 -0
- package/src/lib/oauth.ts +197 -0
- package/src/lib/output.ts +93 -0
- package/src/lib/with-auth.ts +56 -0
- package/src/main.ts +51 -0
- package/src/types/index.ts +56 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import createClient from 'openapi-fetch'
|
|
2
|
+
import type { paths as TemplatePaths } from '../generated/template.ts'
|
|
3
|
+
import type { paths as DatabasePaths } from '../generated/database.ts'
|
|
4
|
+
import { DEFAULT_SEALOS_REGION, loadAuth } from './auth.ts'
|
|
5
|
+
import { ConfigError } from './errors.ts'
|
|
6
|
+
|
|
7
|
+
function resolveHost (options?: { baseUrl?: string }): string {
|
|
8
|
+
let authRegion: string | undefined
|
|
9
|
+
try {
|
|
10
|
+
authRegion = loadAuth().region
|
|
11
|
+
} catch {
|
|
12
|
+
authRegion = undefined
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const host = options?.baseUrl || process.env.SEALOS_REGION || authRegion || DEFAULT_SEALOS_REGION
|
|
16
|
+
if (!host) {
|
|
17
|
+
throw new ConfigError('No Sealos Cloud host configured. Run "sealos login <host>" first.')
|
|
18
|
+
}
|
|
19
|
+
return host.replace(/\/+$/, '')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveDbproviderHost (host: string): string {
|
|
23
|
+
return resolvePrefixedHost(host, 'dbprovider')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveTemplateProviderHost (host: string): string {
|
|
27
|
+
return resolvePrefixedHost(host, 'template')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function resolvePrefixedHost (host: string, prefix: string): string {
|
|
31
|
+
const url = new URL(host)
|
|
32
|
+
|
|
33
|
+
if (
|
|
34
|
+
url.hostname === 'localhost' ||
|
|
35
|
+
url.hostname === '127.0.0.1' ||
|
|
36
|
+
url.hostname.startsWith(`${prefix}.`)
|
|
37
|
+
) {
|
|
38
|
+
return url.toString().replace(/\/+$/, '')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
url.hostname = `${prefix}.${url.hostname}`
|
|
42
|
+
return url.toString().replace(/\/+$/, '')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveDatabaseHost (options?: { baseUrl?: string }): string {
|
|
46
|
+
const override = process.env.SEALOS_DATABASE_HOST?.trim()
|
|
47
|
+
if (override) {
|
|
48
|
+
return override.replace(/\/+$/, '')
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return resolveDbproviderHost(resolveHost(options))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveTemplateHost (options?: { baseUrl?: string }): string {
|
|
55
|
+
return resolveTemplateProviderHost(resolveHost(options))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createTemplateClient (options?: { baseUrl?: string }) {
|
|
59
|
+
return createClient<TemplatePaths>({ baseUrl: `${resolveTemplateHost(options)}/api/v2alpha` })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createDatabaseClient (options?: { baseUrl?: string }) {
|
|
63
|
+
return createClient<DatabasePaths>({ baseUrl: `${resolveDatabaseHost(options)}/api/v2alpha` })
|
|
64
|
+
}
|
package/src/lib/api.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
|
2
|
+
import { getCurrentContext } from './config.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* API client base class
|
|
6
|
+
*/
|
|
7
|
+
export class ApiClient {
|
|
8
|
+
private client: AxiosInstance
|
|
9
|
+
|
|
10
|
+
constructor (baseURL?: string) {
|
|
11
|
+
const context = getCurrentContext()
|
|
12
|
+
|
|
13
|
+
this.client = axios.create({
|
|
14
|
+
baseURL: baseURL || context?.host || '',
|
|
15
|
+
timeout: 30000,
|
|
16
|
+
headers: {
|
|
17
|
+
'Content-Type': 'application/json'
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// Request interceptor - add auth token
|
|
22
|
+
this.client.interceptors.request.use(
|
|
23
|
+
(config) => {
|
|
24
|
+
const context = getCurrentContext()
|
|
25
|
+
if (context?.token) {
|
|
26
|
+
config.headers.Authorization = `Bearer ${context.token}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Support KUBECONFIG environment variable
|
|
30
|
+
const kubeconfig = process.env.KUBECONFIG
|
|
31
|
+
if (kubeconfig) {
|
|
32
|
+
config.headers['X-Kubeconfig'] = kubeconfig
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return config
|
|
36
|
+
},
|
|
37
|
+
async (error) => await Promise.reject(error)
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Response interceptor - unified error handling
|
|
41
|
+
this.client.interceptors.response.use(
|
|
42
|
+
(response) => response,
|
|
43
|
+
async (error) => {
|
|
44
|
+
if (error.response?.status === 401) {
|
|
45
|
+
throw new Error('Authentication failed. Please run "sealos login" first.')
|
|
46
|
+
}
|
|
47
|
+
return await Promise.reject(error)
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async get<T = any> (url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
53
|
+
const response = await this.client.get<T>(url, config)
|
|
54
|
+
return response.data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async post<T = any> (url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
58
|
+
const response = await this.client.post<T>(url, data, config)
|
|
59
|
+
return response.data
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async put<T = any> (url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
63
|
+
const response = await this.client.put<T>(url, data, config)
|
|
64
|
+
return response.data
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async delete<T = any> (url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
68
|
+
const response = await this.client.delete<T>(url, config)
|
|
69
|
+
return response.data
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async patch<T = any> (url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
|
73
|
+
const response = await this.client.patch<T>(url, data, config)
|
|
74
|
+
return response.data
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create API client instance
|
|
80
|
+
*/
|
|
81
|
+
export function createApiClient (baseURL?: string): ApiClient {
|
|
82
|
+
return new ApiClient(baseURL)
|
|
83
|
+
}
|
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { homedir, platform } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import type { SealosAuthData, SealosWorkspace } from '../types/index.ts'
|
|
6
|
+
|
|
7
|
+
export const SEALOS_AUTH_CLIENT_ID = 'af993c98-d19d-4bdc-b338-79b80dc4f8bf'
|
|
8
|
+
export const DEFAULT_SEALOS_REGION = 'https://usw-1.sealos.io'
|
|
9
|
+
export const AUTH_METHOD_DEVICE_GRANT = 'oauth2_device_grant'
|
|
10
|
+
export const AUTH_METHOD_TOKEN = 'token'
|
|
11
|
+
|
|
12
|
+
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'
|
|
13
|
+
|
|
14
|
+
export interface SealosAuthPaths {
|
|
15
|
+
sealosDir: string
|
|
16
|
+
authPath: string
|
|
17
|
+
kubeconfigPath: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AuthDependencies {
|
|
21
|
+
fetch?: typeof fetch
|
|
22
|
+
sleep?: (ms: number) => Promise<void>
|
|
23
|
+
openBrowser?: (url: string) => void
|
|
24
|
+
now?: () => Date
|
|
25
|
+
paths?: SealosAuthPaths
|
|
26
|
+
stderr?: Pick<NodeJS.WriteStream, 'write'>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface DeviceAuthorizationResponse {
|
|
30
|
+
device_code: string
|
|
31
|
+
user_code: string
|
|
32
|
+
verification_uri?: string
|
|
33
|
+
verification_uri_complete?: string
|
|
34
|
+
expires_in: number
|
|
35
|
+
interval?: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TokenResponse {
|
|
39
|
+
access_token?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RegionTokenResponse {
|
|
43
|
+
data?: {
|
|
44
|
+
token?: string
|
|
45
|
+
kubeconfig?: string
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface NamespaceListResponse {
|
|
50
|
+
data?: {
|
|
51
|
+
namespaces?: SealosWorkspace[]
|
|
52
|
+
} | SealosWorkspace[]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface SwitchWorkspaceResponse {
|
|
56
|
+
data?: {
|
|
57
|
+
token?: string
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface KubeconfigResponse {
|
|
62
|
+
data?: {
|
|
63
|
+
kubeconfig?: string
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LoginResult {
|
|
68
|
+
kubeconfig_path?: string
|
|
69
|
+
region: string
|
|
70
|
+
workspace: string
|
|
71
|
+
limited?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface AuthStatus {
|
|
75
|
+
authenticated: boolean
|
|
76
|
+
kubeconfig_path?: string
|
|
77
|
+
region?: string
|
|
78
|
+
workspace?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface AuthInfo extends AuthStatus {
|
|
82
|
+
auth_method?: string
|
|
83
|
+
authenticated_at?: string
|
|
84
|
+
current_workspace?: SealosAuthData['current_workspace'] | null
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface WorkspaceListResult {
|
|
88
|
+
current: string | null
|
|
89
|
+
workspaces: SealosWorkspace[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface SwitchWorkspaceResult {
|
|
93
|
+
workspace: {
|
|
94
|
+
uid?: string
|
|
95
|
+
id?: string
|
|
96
|
+
teamName?: string
|
|
97
|
+
}
|
|
98
|
+
kubeconfig_path: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function getAuthPaths (sealosDir = join(homedir(), '.sealos')): SealosAuthPaths {
|
|
102
|
+
return {
|
|
103
|
+
sealosDir,
|
|
104
|
+
authPath: join(sealosDir, 'auth.json'),
|
|
105
|
+
kubeconfigPath: join(sealosDir, 'kubeconfig')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function normalizeRegion (region?: string): string {
|
|
110
|
+
return (region || process.env.SEALOS_REGION || DEFAULT_SEALOS_REGION).replace(/\/+$/, '')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createDefaultAuthDependencies (): Required<AuthDependencies> {
|
|
114
|
+
return {
|
|
115
|
+
fetch,
|
|
116
|
+
sleep: async (ms: number) => await new Promise(resolve => setTimeout(resolve, ms)),
|
|
117
|
+
openBrowser,
|
|
118
|
+
now: () => new Date(),
|
|
119
|
+
paths: getAuthPaths(),
|
|
120
|
+
stderr: process.stderr
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function withDeps (deps: AuthDependencies = {}): Required<AuthDependencies> {
|
|
125
|
+
return {
|
|
126
|
+
...createDefaultAuthDependencies(),
|
|
127
|
+
...deps
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function ensureSealosDir (paths: SealosAuthPaths): void {
|
|
132
|
+
mkdirSync(paths.sealosDir, { recursive: true })
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function saveAuth (auth: SealosAuthData, deps: AuthDependencies = {}): void {
|
|
136
|
+
const { paths } = withDeps(deps)
|
|
137
|
+
ensureSealosDir(paths)
|
|
138
|
+
writeFileSync(paths.authPath, JSON.stringify(auth, null, 2), { mode: 0o600 })
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function loadAuth (deps: AuthDependencies = {}): SealosAuthData {
|
|
142
|
+
const { paths } = withDeps(deps)
|
|
143
|
+
if (!existsSync(paths.authPath)) {
|
|
144
|
+
throw new Error('Not authenticated. Please run: sealos login')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return JSON.parse(readFileSync(paths.authPath, 'utf-8')) as SealosAuthData
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function getToken (deps: AuthDependencies = {}): string | null {
|
|
151
|
+
try {
|
|
152
|
+
return loadAuth(deps).regional_token || null
|
|
153
|
+
} catch {
|
|
154
|
+
return null
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function getAuthHeaders (deps: AuthDependencies = {}): { Authorization: string } | null {
|
|
159
|
+
const token = getToken(deps)
|
|
160
|
+
return token ? { Authorization: token } : null
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function requireAuth (deps: AuthDependencies = {}): { Authorization: string } {
|
|
164
|
+
const headers = getAuthHeaders(deps)
|
|
165
|
+
if (!headers) {
|
|
166
|
+
throw new Error('Authentication required. Please run "sealos login" first.')
|
|
167
|
+
}
|
|
168
|
+
return headers
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function saveKubeconfig (kubeconfig: string, deps: AuthDependencies = {}): void {
|
|
172
|
+
const { paths } = withDeps(deps)
|
|
173
|
+
ensureSealosDir(paths)
|
|
174
|
+
writeFileSync(paths.kubeconfigPath, kubeconfig, { mode: 0o600 })
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function clearAuth (deps: AuthDependencies = {}): void {
|
|
178
|
+
const { paths } = withDeps(deps)
|
|
179
|
+
rmSync(paths.authPath, { force: true })
|
|
180
|
+
rmSync(paths.kubeconfigPath, { force: true })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function checkAuth (deps: AuthDependencies = {}): AuthStatus {
|
|
184
|
+
const { paths } = withDeps(deps)
|
|
185
|
+
if (!existsSync(paths.kubeconfigPath)) {
|
|
186
|
+
return { authenticated: false }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
const kubeconfig = readFileSync(paths.kubeconfigPath, 'utf-8')
|
|
191
|
+
if (!kubeconfig.includes('server:') || (!kubeconfig.includes('token:') && !kubeconfig.includes('client-certificate'))) {
|
|
192
|
+
return { authenticated: false }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const auth: Partial<SealosAuthData> = existsSync(paths.authPath)
|
|
196
|
+
? JSON.parse(readFileSync(paths.authPath, 'utf-8')) as SealosAuthData
|
|
197
|
+
: {}
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
authenticated: true,
|
|
201
|
+
kubeconfig_path: paths.kubeconfigPath,
|
|
202
|
+
region: auth.region || 'unknown',
|
|
203
|
+
workspace: auth.current_workspace?.id || 'unknown'
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
return { authenticated: false }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function getAuthInfo (deps: AuthDependencies = {}): AuthInfo {
|
|
211
|
+
const { paths } = withDeps(deps)
|
|
212
|
+
const status = checkAuth(deps)
|
|
213
|
+
if (!status.authenticated) {
|
|
214
|
+
return { authenticated: false }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const auth: Partial<SealosAuthData> = existsSync(paths.authPath)
|
|
218
|
+
? JSON.parse(readFileSync(paths.authPath, 'utf-8')) as SealosAuthData
|
|
219
|
+
: {}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...status,
|
|
223
|
+
auth_method: auth.auth_method || 'unknown',
|
|
224
|
+
authenticated_at: auth.authenticated_at || 'unknown',
|
|
225
|
+
current_workspace: auth.current_workspace || null
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async function parseResponse<T> (res: Response): Promise<T> {
|
|
230
|
+
return await res.json() as T
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async function readErrorBody (res: Response): Promise<string> {
|
|
234
|
+
return await res.text().catch(() => '')
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export async function requestDeviceAuthorization (region: string, deps: AuthDependencies = {}): Promise<DeviceAuthorizationResponse> {
|
|
238
|
+
const { fetch: fetchImpl } = withDeps(deps)
|
|
239
|
+
const res = await fetchImpl(`${region}/api/auth/oauth2/device`, {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
242
|
+
body: new URLSearchParams({
|
|
243
|
+
client_id: SEALOS_AUTH_CLIENT_ID,
|
|
244
|
+
grant_type: DEVICE_GRANT_TYPE
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
if (!res.ok) {
|
|
249
|
+
const body = await readErrorBody(res)
|
|
250
|
+
throw new Error(`Device authorization request failed (${res.status}): ${body || res.statusText}`)
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return await parseResponse<DeviceAuthorizationResponse>(res)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function pollForToken (
|
|
257
|
+
region: string,
|
|
258
|
+
deviceCode: string,
|
|
259
|
+
interval: number,
|
|
260
|
+
expiresIn: number,
|
|
261
|
+
deps: AuthDependencies = {}
|
|
262
|
+
): Promise<TokenResponse> {
|
|
263
|
+
const { fetch: fetchImpl, sleep, now, stderr } = withDeps(deps)
|
|
264
|
+
const maxWait = Math.min(expiresIn, 600) * 1000
|
|
265
|
+
const deadline = now().getTime() + maxWait
|
|
266
|
+
let pollInterval = interval * 1000
|
|
267
|
+
let lastLoggedMinute = -1
|
|
268
|
+
|
|
269
|
+
while (now().getTime() < deadline) {
|
|
270
|
+
await sleep(pollInterval)
|
|
271
|
+
|
|
272
|
+
const remaining = Math.ceil((deadline - now().getTime()) / 60000)
|
|
273
|
+
if (remaining !== lastLoggedMinute && remaining > 0) {
|
|
274
|
+
lastLoggedMinute = remaining
|
|
275
|
+
stderr.write(` Waiting for authorization... (${remaining} min remaining)\n`)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const res = await fetchImpl(`${region}/api/auth/oauth2/token`, {
|
|
279
|
+
method: 'POST',
|
|
280
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
281
|
+
body: new URLSearchParams({
|
|
282
|
+
client_id: SEALOS_AUTH_CLIENT_ID,
|
|
283
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
284
|
+
device_code: deviceCode
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
if (res.ok) {
|
|
289
|
+
return await parseResponse<TokenResponse>(res)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const body = await res.json().catch(() => ({})) as { error?: string }
|
|
293
|
+
switch (body.error) {
|
|
294
|
+
case 'authorization_pending':
|
|
295
|
+
break
|
|
296
|
+
case 'slow_down':
|
|
297
|
+
pollInterval += 5000
|
|
298
|
+
break
|
|
299
|
+
case 'access_denied':
|
|
300
|
+
throw new Error('Authorization denied by user')
|
|
301
|
+
case 'expired_token':
|
|
302
|
+
throw new Error('Device code expired. Please run login again.')
|
|
303
|
+
default:
|
|
304
|
+
throw new Error(`Token request failed: ${body.error || res.statusText}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
throw new Error('Authorization timed out (10 minutes). Please run login again.')
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function getRegionToken (region: string, globalToken: string, deps: AuthDependencies = {}): Promise<RegionTokenResponse> {
|
|
312
|
+
const { fetch: fetchImpl } = withDeps(deps)
|
|
313
|
+
const res = await fetchImpl(`${region}/api/auth/regionToken`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: {
|
|
316
|
+
Authorization: globalToken,
|
|
317
|
+
'Content-Type': 'application/json'
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
if (!res.ok) {
|
|
322
|
+
const body = await readErrorBody(res)
|
|
323
|
+
throw new Error(`Region token exchange failed (${res.status}): ${body || res.statusText}`)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return await parseResponse<RegionTokenResponse>(res)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function normalizeWorkspaces (data: NamespaceListResponse): SealosWorkspace[] {
|
|
330
|
+
const namespaces = Array.isArray(data.data) ? data.data : data.data?.namespaces
|
|
331
|
+
return Array.isArray(namespaces) ? namespaces : []
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export async function listRemoteWorkspaces (region: string, regionalToken: string, deps: AuthDependencies = {}): Promise<SealosWorkspace[]> {
|
|
335
|
+
const { fetch: fetchImpl } = withDeps(deps)
|
|
336
|
+
const res = await fetchImpl(`${region}/api/auth/namespace/list`, {
|
|
337
|
+
headers: { Authorization: regionalToken }
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
if (!res.ok) {
|
|
341
|
+
const body = await readErrorBody(res)
|
|
342
|
+
throw new Error(`List workspaces failed (${res.status}): ${body || res.statusText}`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return normalizeWorkspaces(await parseResponse<NamespaceListResponse>(res))
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
export async function switchRemoteWorkspace (
|
|
349
|
+
region: string,
|
|
350
|
+
regionalToken: string,
|
|
351
|
+
nsUid: string,
|
|
352
|
+
deps: AuthDependencies = {}
|
|
353
|
+
): Promise<SwitchWorkspaceResponse> {
|
|
354
|
+
const { fetch: fetchImpl } = withDeps(deps)
|
|
355
|
+
const res = await fetchImpl(`${region}/api/auth/namespace/switch`, {
|
|
356
|
+
method: 'POST',
|
|
357
|
+
headers: {
|
|
358
|
+
Authorization: regionalToken,
|
|
359
|
+
'Content-Type': 'application/json'
|
|
360
|
+
},
|
|
361
|
+
body: JSON.stringify({ ns_uid: nsUid })
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
if (!res.ok) {
|
|
365
|
+
const body = await readErrorBody(res)
|
|
366
|
+
throw new Error(`Switch workspace failed (${res.status}): ${body || res.statusText}`)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return await parseResponse<SwitchWorkspaceResponse>(res)
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function getKubeconfig (region: string, regionalToken: string, deps: AuthDependencies = {}): Promise<KubeconfigResponse> {
|
|
373
|
+
const { fetch: fetchImpl } = withDeps(deps)
|
|
374
|
+
const res = await fetchImpl(`${region}/api/auth/getKubeconfig`, {
|
|
375
|
+
headers: { Authorization: regionalToken }
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
if (!res.ok) {
|
|
379
|
+
const body = await readErrorBody(res)
|
|
380
|
+
throw new Error(`Get kubeconfig failed (${res.status}): ${body || res.statusText}`)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return await parseResponse<KubeconfigResponse>(res)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
export function openBrowser (url: string): void {
|
|
387
|
+
const command = platform() === 'darwin' ? 'open' : platform() === 'win32' ? 'cmd' : 'xdg-open'
|
|
388
|
+
const args = platform() === 'win32' ? ['/c', 'start', '', url] : [url]
|
|
389
|
+
execFileSync(command, args, { stdio: 'ignore' })
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function loginWithToken (region: string, token: string, deps: AuthDependencies = {}): Promise<LoginResult> {
|
|
393
|
+
const { now } = withDeps(deps)
|
|
394
|
+
const normalizedRegion = normalizeRegion(region)
|
|
395
|
+
saveAuth({
|
|
396
|
+
region: normalizedRegion,
|
|
397
|
+
regional_token: token,
|
|
398
|
+
authenticated_at: now().toISOString(),
|
|
399
|
+
auth_method: AUTH_METHOD_TOKEN
|
|
400
|
+
}, deps)
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
region: normalizedRegion,
|
|
404
|
+
workspace: 'unknown',
|
|
405
|
+
limited: true
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export async function loginWithDeviceFlow (region?: string, deps: AuthDependencies = {}): Promise<LoginResult> {
|
|
410
|
+
const fullDeps = withDeps(deps)
|
|
411
|
+
const normalizedRegion = normalizeRegion(region)
|
|
412
|
+
const deviceAuth = await requestDeviceAuthorization(normalizedRegion, fullDeps)
|
|
413
|
+
const {
|
|
414
|
+
device_code: deviceCode,
|
|
415
|
+
user_code: userCode,
|
|
416
|
+
verification_uri: verificationUri,
|
|
417
|
+
verification_uri_complete: verificationUriComplete,
|
|
418
|
+
expires_in: expiresIn,
|
|
419
|
+
interval = 5
|
|
420
|
+
} = deviceAuth
|
|
421
|
+
|
|
422
|
+
const url = verificationUriComplete || verificationUri
|
|
423
|
+
fullDeps.stderr.write(`\nPlease open the following URL in your browser to authorize:\n\n ${url}\n\nAuthorization code: ${userCode}\nExpires in: ${Math.floor(expiresIn / 60)} minutes\n\nWaiting for authorization...\n`)
|
|
424
|
+
|
|
425
|
+
if (url) {
|
|
426
|
+
try {
|
|
427
|
+
fullDeps.openBrowser(url)
|
|
428
|
+
fullDeps.stderr.write('Browser opened automatically.\n')
|
|
429
|
+
} catch {
|
|
430
|
+
fullDeps.stderr.write('Could not open browser automatically. Please open the URL manually.\n')
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const tokenResponse = await pollForToken(normalizedRegion, deviceCode, interval, expiresIn, fullDeps)
|
|
435
|
+
const accessToken = tokenResponse.access_token
|
|
436
|
+
if (!accessToken) {
|
|
437
|
+
throw new Error('Token response missing access_token')
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
fullDeps.stderr.write('Authorization received. Exchanging for regional token...\n')
|
|
441
|
+
|
|
442
|
+
const regionData = await getRegionToken(normalizedRegion, accessToken, fullDeps)
|
|
443
|
+
const regionalToken = regionData.data?.token
|
|
444
|
+
const kubeconfig = regionData.data?.kubeconfig
|
|
445
|
+
if (!regionalToken) {
|
|
446
|
+
throw new Error('Region token response missing data.token field')
|
|
447
|
+
}
|
|
448
|
+
if (!kubeconfig) {
|
|
449
|
+
throw new Error('Region token response missing data.kubeconfig field')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let currentWorkspace: SealosWorkspace | undefined
|
|
453
|
+
try {
|
|
454
|
+
const workspaces = await listRemoteWorkspaces(normalizedRegion, regionalToken, fullDeps)
|
|
455
|
+
currentWorkspace = workspaces.find(workspace => workspace.nstype === 'private') || workspaces[0]
|
|
456
|
+
} catch {
|
|
457
|
+
currentWorkspace = undefined
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
saveKubeconfig(kubeconfig, fullDeps)
|
|
461
|
+
saveAuth({
|
|
462
|
+
region: normalizedRegion,
|
|
463
|
+
access_token: accessToken,
|
|
464
|
+
regional_token: regionalToken,
|
|
465
|
+
authenticated_at: fullDeps.now().toISOString(),
|
|
466
|
+
auth_method: AUTH_METHOD_DEVICE_GRANT,
|
|
467
|
+
...(currentWorkspace
|
|
468
|
+
? {
|
|
469
|
+
current_workspace: {
|
|
470
|
+
uid: currentWorkspace.uid,
|
|
471
|
+
id: currentWorkspace.id,
|
|
472
|
+
teamName: currentWorkspace.teamName
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
: {})
|
|
476
|
+
}, fullDeps)
|
|
477
|
+
|
|
478
|
+
fullDeps.stderr.write('Authentication successful!\n')
|
|
479
|
+
|
|
480
|
+
return {
|
|
481
|
+
kubeconfig_path: fullDeps.paths.kubeconfigPath,
|
|
482
|
+
region: normalizedRegion,
|
|
483
|
+
workspace: currentWorkspace?.id || 'default'
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export async function listWorkspaces (deps: AuthDependencies = {}): Promise<WorkspaceListResult> {
|
|
488
|
+
const auth = loadAuth(deps)
|
|
489
|
+
if (!auth.regional_token) {
|
|
490
|
+
throw new Error('No regional_token found. Please run: sealos login')
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const workspaces = await listRemoteWorkspaces(auth.region, auth.regional_token, deps)
|
|
494
|
+
return {
|
|
495
|
+
current: auth.current_workspace?.id || null,
|
|
496
|
+
workspaces: workspaces.map(workspace => ({
|
|
497
|
+
uid: workspace.uid,
|
|
498
|
+
id: workspace.id,
|
|
499
|
+
teamName: workspace.teamName,
|
|
500
|
+
role: workspace.role,
|
|
501
|
+
nstype: workspace.nstype
|
|
502
|
+
}))
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export async function switchWorkspace (target: string, deps: AuthDependencies = {}): Promise<SwitchWorkspaceResult> {
|
|
507
|
+
if (!target) {
|
|
508
|
+
throw new Error('Usage: sealos auth switch <namespace-id-or-uid>')
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const fullDeps = withDeps(deps)
|
|
512
|
+
const auth = loadAuth(fullDeps)
|
|
513
|
+
if (!auth.regional_token) {
|
|
514
|
+
throw new Error('No regional_token found. Please run: sealos login')
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const workspaces = await listRemoteWorkspaces(auth.region, auth.regional_token, fullDeps)
|
|
518
|
+
if (workspaces.length === 0) {
|
|
519
|
+
throw new Error('No workspaces found')
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const targetLower = target.toLowerCase()
|
|
523
|
+
const match = workspaces.find(workspace =>
|
|
524
|
+
workspace.id === target ||
|
|
525
|
+
workspace.uid === target ||
|
|
526
|
+
workspace.id?.toLowerCase().includes(targetLower) ||
|
|
527
|
+
workspace.teamName?.toLowerCase().includes(targetLower)
|
|
528
|
+
)
|
|
529
|
+
|
|
530
|
+
if (!match?.uid) {
|
|
531
|
+
const available = workspaces.map(workspace => ` ${workspace.id || 'unknown'} (${workspace.teamName || 'unknown'})`).join('\n')
|
|
532
|
+
throw new Error(`No workspace matching "${target}". Available:\n${available}`)
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
fullDeps.stderr.write(`Switching to workspace: ${match.id || match.uid} (${match.teamName || 'unknown'})...\n`)
|
|
536
|
+
const switchData = await switchRemoteWorkspace(auth.region, auth.regional_token, match.uid, fullDeps)
|
|
537
|
+
const newToken = switchData.data?.token
|
|
538
|
+
if (!newToken) {
|
|
539
|
+
throw new Error('Switch response missing data.token')
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const kubeconfigData = await getKubeconfig(auth.region, newToken, fullDeps)
|
|
543
|
+
const kubeconfig = kubeconfigData.data?.kubeconfig
|
|
544
|
+
if (!kubeconfig) {
|
|
545
|
+
throw new Error('Kubeconfig response missing data.kubeconfig')
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const nextAuth: SealosAuthData = {
|
|
549
|
+
...auth,
|
|
550
|
+
regional_token: newToken,
|
|
551
|
+
current_workspace: {
|
|
552
|
+
uid: match.uid,
|
|
553
|
+
id: match.id,
|
|
554
|
+
teamName: match.teamName
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
saveAuth(nextAuth, fullDeps)
|
|
558
|
+
saveKubeconfig(kubeconfig, fullDeps)
|
|
559
|
+
|
|
560
|
+
fullDeps.stderr.write(`Switched to workspace: ${match.id || match.uid}\n`)
|
|
561
|
+
|
|
562
|
+
return {
|
|
563
|
+
workspace: {
|
|
564
|
+
uid: match.uid,
|
|
565
|
+
id: match.id,
|
|
566
|
+
teamName: match.teamName
|
|
567
|
+
},
|
|
568
|
+
kubeconfig_path: fullDeps.paths.kubeconfigPath
|
|
569
|
+
}
|
|
570
|
+
}
|