@kimchi-dev/kimchictl 0.0.2
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 +203 -0
- package/README.md +105 -0
- package/bin/kimchictl.mjs +17 -0
- package/dist/api/http.d.ts +7 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +60 -0
- package/dist/api/http.js.map +1 -0
- package/dist/api/keys.d.ts +7 -0
- package/dist/api/keys.d.ts.map +1 -0
- package/dist/api/keys.js +28 -0
- package/dist/api/keys.js.map +1 -0
- package/dist/api/me.d.ts +12 -0
- package/dist/api/me.d.ts.map +1 -0
- package/dist/api/me.js +28 -0
- package/dist/api/me.js.map +1 -0
- package/dist/api/resolver.d.ts +26 -0
- package/dist/api/resolver.d.ts.map +1 -0
- package/dist/api/resolver.js +62 -0
- package/dist/api/resolver.js.map +1 -0
- package/dist/api/resources.d.ts +16 -0
- package/dist/api/resources.d.ts.map +1 -0
- package/dist/api/resources.js +96 -0
- package/dist/api/resources.js.map +1 -0
- package/dist/api/templates.d.ts +19 -0
- package/dist/api/templates.d.ts.map +1 -0
- package/dist/api/templates.js +25 -0
- package/dist/api/templates.js.map +1 -0
- package/dist/api/tokens.d.ts +23 -0
- package/dist/api/tokens.d.ts.map +1 -0
- package/dist/api/tokens.js +51 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/api/types.d.ts +34 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +32 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/wait.d.ts +17 -0
- package/dist/api/wait.d.ts.map +1 -0
- package/dist/api/wait.js +37 -0
- package/dist/api/wait.js.map +1 -0
- package/dist/api/workspaces.d.ts +33 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +159 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/auth/auth-file.d.ts +37 -0
- package/dist/auth/auth-file.d.ts.map +1 -0
- package/dist/auth/auth-file.js +100 -0
- package/dist/auth/auth-file.js.map +1 -0
- package/dist/auth/browser-login.d.ts +45 -0
- package/dist/auth/browser-login.d.ts.map +1 -0
- package/dist/auth/browser-login.js +195 -0
- package/dist/auth/browser-login.js.map +1 -0
- package/dist/auth/config-file.d.ts +4 -0
- package/dist/auth/config-file.d.ts.map +1 -0
- package/dist/auth/config-file.js +52 -0
- package/dist/auth/config-file.js.map +1 -0
- package/dist/auth/models-providers.d.ts +17 -0
- package/dist/auth/models-providers.d.ts.map +1 -0
- package/dist/auth/models-providers.js +63 -0
- package/dist/auth/models-providers.js.map +1 -0
- package/dist/auth/paths.d.ts +15 -0
- package/dist/auth/paths.d.ts.map +1 -0
- package/dist/auth/paths.js +25 -0
- package/dist/auth/paths.js.map +1 -0
- package/dist/auth/provider-ids.d.ts +4 -0
- package/dist/auth/provider-ids.d.ts.map +1 -0
- package/dist/auth/provider-ids.js +12 -0
- package/dist/auth/provider-ids.js.map +1 -0
- package/dist/auth/resolve.d.ts +19 -0
- package/dist/auth/resolve.d.ts.map +1 -0
- package/dist/auth/resolve.js +37 -0
- package/dist/auth/resolve.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/completion.d.ts +2 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +71 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/flags.d.ts +16 -0
- package/dist/commands/flags.d.ts.map +1 -0
- package/dist/commands/flags.js +23 -0
- package/dist/commands/flags.js.map +1 -0
- package/dist/commands/guard.d.ts +10 -0
- package/dist/commands/guard.d.ts.map +1 -0
- package/dist/commands/guard.js +41 -0
- package/dist/commands/guard.js.map +1 -0
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +19 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/login.d.ts +18 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +80 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +14 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +41 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/prompt.d.ts +9 -0
- package/dist/commands/prompt.d.ts.map +1 -0
- package/dist/commands/prompt.js +21 -0
- package/dist/commands/prompt.js.map +1 -0
- package/dist/commands/registry.d.ts +16 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +35 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.d.ts.map +1 -0
- package/dist/commands/ssh.js +90 -0
- package/dist/commands/ssh.js.map +1 -0
- package/dist/commands/table.d.ts +3 -0
- package/dist/commands/table.d.ts.map +1 -0
- package/dist/commands/table.js +10 -0
- package/dist/commands/table.js.map +1 -0
- package/dist/commands/update.d.ts +16 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +57 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/version.d.ts +2 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +6 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/commands/workspace.d.ts +15 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +206 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +30 -0
- package/dist/run.js.map +1 -0
- package/dist/ssh/bridge.d.ts +25 -0
- package/dist/ssh/bridge.d.ts.map +1 -0
- package/dist/ssh/bridge.js +95 -0
- package/dist/ssh/bridge.js.map +1 -0
- package/dist/ssh/config.d.ts +65 -0
- package/dist/ssh/config.d.ts.map +1 -0
- package/dist/ssh/config.js +198 -0
- package/dist/ssh/config.js.map +1 -0
- package/dist/ssh/connect.d.ts +27 -0
- package/dist/ssh/connect.d.ts.map +1 -0
- package/dist/ssh/connect.js +67 -0
- package/dist/ssh/connect.js.map +1 -0
- package/dist/ssh/tunnel.d.ts +29 -0
- package/dist/ssh/tunnel.d.ts.map +1 -0
- package/dist/ssh/tunnel.js +36 -0
- package/dist/ssh/tunnel.js.map +1 -0
- package/dist/test-support.d.ts +30 -0
- package/dist/test-support.d.ts.map +1 -0
- package/dist/test-support.js +67 -0
- package/dist/test-support.js.map +1 -0
- package/dist/updates/check.d.ts +18 -0
- package/dist/updates/check.d.ts.map +1 -0
- package/dist/updates/check.js +90 -0
- package/dist/updates/check.js.map +1 -0
- package/dist/updates/self.d.ts +36 -0
- package/dist/updates/self.d.ts.map +1 -0
- package/dist/updates/self.js +103 -0
- package/dist/updates/self.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/api/http.ts +70 -0
- package/src/api/keys.ts +40 -0
- package/src/api/me.ts +48 -0
- package/src/api/resolver.test.ts +109 -0
- package/src/api/resolver.ts +72 -0
- package/src/api/resources.test.ts +56 -0
- package/src/api/resources.ts +92 -0
- package/src/api/templates.ts +28 -0
- package/src/api/tokens.ts +80 -0
- package/src/api/types.ts +50 -0
- package/src/api/wait.ts +45 -0
- package/src/api/workspaces.ts +234 -0
- package/src/auth/auth-file.test.ts +176 -0
- package/src/auth/auth-file.ts +103 -0
- package/src/auth/browser-login.test.ts +93 -0
- package/src/auth/browser-login.ts +247 -0
- package/src/auth/config-file.test.ts +68 -0
- package/src/auth/config-file.ts +50 -0
- package/src/auth/models-providers.test.ts +63 -0
- package/src/auth/models-providers.ts +76 -0
- package/src/auth/paths.ts +28 -0
- package/src/auth/provider-ids.ts +15 -0
- package/src/auth/resolve.test.ts +72 -0
- package/src/auth/resolve.ts +46 -0
- package/src/cli.ts +16 -0
- package/src/commands/completion.test.ts +31 -0
- package/src/commands/completion.ts +77 -0
- package/src/commands/flags.ts +29 -0
- package/src/commands/guard.ts +44 -0
- package/src/commands/help.ts +20 -0
- package/src/commands/login.test.ts +159 -0
- package/src/commands/login.ts +94 -0
- package/src/commands/logout.test.ts +89 -0
- package/src/commands/logout.ts +53 -0
- package/src/commands/prompt.ts +21 -0
- package/src/commands/registry.ts +50 -0
- package/src/commands/ssh.test.ts +101 -0
- package/src/commands/ssh.ts +112 -0
- package/src/commands/table.test.ts +24 -0
- package/src/commands/table.ts +10 -0
- package/src/commands/update.test.ts +91 -0
- package/src/commands/update.ts +78 -0
- package/src/commands/version.ts +6 -0
- package/src/commands/whoami.test.ts +72 -0
- package/src/commands/whoami.ts +36 -0
- package/src/commands/workspace.test.ts +360 -0
- package/src/commands/workspace.ts +250 -0
- package/src/run.test.ts +53 -0
- package/src/run.ts +34 -0
- package/src/ssh/bridge.test.ts +70 -0
- package/src/ssh/bridge.ts +110 -0
- package/src/ssh/config.test.ts +213 -0
- package/src/ssh/config.ts +233 -0
- package/src/ssh/connect.test.ts +123 -0
- package/src/ssh/connect.ts +98 -0
- package/src/ssh/tunnel.test.ts +119 -0
- package/src/ssh/tunnel.ts +64 -0
- package/src/test-support.ts +77 -0
- package/src/updates/check.test.ts +99 -0
- package/src/updates/check.ts +117 -0
- package/src/updates/self.test.ts +117 -0
- package/src/updates/self.ts +131 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { checkResponse, fetchWithTimeout, resolveEndpoint } from "./http.js"
|
|
2
|
+
import { verifyApiKey } from "./keys.js"
|
|
3
|
+
import { byteQuantityToBytes, cpuQuantityToMillicores } from "./resources.js"
|
|
4
|
+
import type { ApiOptions } from "./types.js"
|
|
5
|
+
import { RemoteAuthError, RemoteNetworkError } from "./types.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Workspace client — mirrors the harness's (kimchi-dev:src/sandbox/cloud)
|
|
9
|
+
* calls against the same control-plane API, plus kap's server-named create.
|
|
10
|
+
*
|
|
11
|
+
* The control plane addresses workspaces by UUID id; users type the alias
|
|
12
|
+
* (first DNS label of the URI). api/resolver.ts maps between them.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const HARNESS_CLIENT_TYPE = "harness" // matches kimchi-dev:src/sandbox/constants.ts
|
|
16
|
+
const LIST_WORKSPACES_PAGE_LIMIT = 200
|
|
17
|
+
const LIST_WORKSPACES_PAGE_HARD_CAP = 10
|
|
18
|
+
|
|
19
|
+
export type WorkspaceStatus = "active" | "initializing" | "suspended" | "deleting" | "idle"
|
|
20
|
+
|
|
21
|
+
export interface Workspace {
|
|
22
|
+
/** Server id — also the command-line identifier (`workspace get <id>`, `ssh <id>`). */
|
|
23
|
+
id: string
|
|
24
|
+
/** First DNS label of the URI — the user-facing name (NOT the server id). */
|
|
25
|
+
alias: string
|
|
26
|
+
description: string
|
|
27
|
+
status: WorkspaceStatus
|
|
28
|
+
uri?: string
|
|
29
|
+
host?: string
|
|
30
|
+
createdAt: Date
|
|
31
|
+
cpuMillicores?: number
|
|
32
|
+
ramBytes?: number
|
|
33
|
+
pvcSizeBytes?: number
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkspaceCommandOptions extends ApiOptions {
|
|
37
|
+
/** Pre-resolved organization id — skips the verifyKey round-trip. */
|
|
38
|
+
orgId?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function resolveOrgId(apiKey: string, options?: WorkspaceCommandOptions): Promise<string> {
|
|
42
|
+
return options?.orgId ?? (await verifyApiKey(apiKey, options))
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function listWorkspaces(apiKey: string, options?: WorkspaceCommandOptions): Promise<Workspace[]> {
|
|
46
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch
|
|
47
|
+
const endpoint = resolveEndpoint(options)
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const orgId = await resolveOrgId(apiKey, options)
|
|
51
|
+
|
|
52
|
+
const results: Workspace[] = []
|
|
53
|
+
let cursor = ""
|
|
54
|
+
|
|
55
|
+
for (let page = 0; page < LIST_WORKSPACES_PAGE_HARD_CAP; page++) {
|
|
56
|
+
const params = new URLSearchParams()
|
|
57
|
+
params.set("page.limit", String(LIST_WORKSPACES_PAGE_LIMIT))
|
|
58
|
+
params.set("clientType", HARNESS_CLIENT_TYPE)
|
|
59
|
+
if (cursor) params.set("page.cursor", cursor)
|
|
60
|
+
|
|
61
|
+
const url = `${endpoint}/ai-optimizer/v1beta/organizations/${encodeURIComponent(orgId)}/workspaces?${params.toString()}`
|
|
62
|
+
const resp = await fetchWithTimeout(
|
|
63
|
+
url,
|
|
64
|
+
{ method: "GET", headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" } },
|
|
65
|
+
fetchImpl,
|
|
66
|
+
30_000,
|
|
67
|
+
options?.signal,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
await checkResponse(resp, url)
|
|
71
|
+
|
|
72
|
+
const data: unknown = await resp.json().catch(() => undefined)
|
|
73
|
+
if (typeof data !== "object" || data === null) {
|
|
74
|
+
throw new RemoteNetworkError(`Unexpected response shape from ${endpoint}`)
|
|
75
|
+
}
|
|
76
|
+
const items = (data as { items?: unknown }).items
|
|
77
|
+
if (!Array.isArray(items)) {
|
|
78
|
+
throw new RemoteNetworkError(`Missing items array in list-workspaces response from ${endpoint}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
results.push(mapWorkspace(item, endpoint))
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const nextCursor = (data as { nextPageCursor?: unknown }).nextPageCursor
|
|
86
|
+
if (typeof nextCursor !== "string" || nextCursor.length === 0) {
|
|
87
|
+
return results
|
|
88
|
+
}
|
|
89
|
+
cursor = nextCursor
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return results
|
|
93
|
+
} catch (err) {
|
|
94
|
+
if (err instanceof RemoteAuthError || err instanceof RemoteNetworkError) {
|
|
95
|
+
throw err
|
|
96
|
+
}
|
|
97
|
+
throw new RemoteNetworkError(err instanceof Error ? err.message : String(err))
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getWorkspace(apiKey: string, id: string, options?: WorkspaceCommandOptions): Promise<Workspace> {
|
|
102
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch
|
|
103
|
+
const endpoint = resolveEndpoint(options)
|
|
104
|
+
const orgId = await resolveOrgId(apiKey, options)
|
|
105
|
+
|
|
106
|
+
const url = `${endpoint}/ai-optimizer/v1beta/organizations/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(id)}`
|
|
107
|
+
const resp = await fetchWithTimeout(
|
|
108
|
+
url,
|
|
109
|
+
{ method: "GET", headers: { Authorization: `Bearer ${apiKey}` } },
|
|
110
|
+
fetchImpl,
|
|
111
|
+
30_000,
|
|
112
|
+
options?.signal,
|
|
113
|
+
)
|
|
114
|
+
await checkResponse(resp, url)
|
|
115
|
+
|
|
116
|
+
const data: unknown = await resp.json().catch(() => undefined)
|
|
117
|
+
return mapWorkspace(data, endpoint)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a workspace. Server generates the alias (like kap) — there is no
|
|
122
|
+
* client-side name input. Returns the created workspace (status likely
|
|
123
|
+
* "initializing"; callers may poll getWorkspace until "active").
|
|
124
|
+
*/
|
|
125
|
+
export async function createWorkspace(
|
|
126
|
+
apiKey: string,
|
|
127
|
+
options: WorkspaceCommandOptions & { description?: string },
|
|
128
|
+
): Promise<Workspace> {
|
|
129
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch
|
|
130
|
+
const endpoint = resolveEndpoint(options)
|
|
131
|
+
const orgId = await resolveOrgId(apiKey, options)
|
|
132
|
+
|
|
133
|
+
const url = `${endpoint}/ai-optimizer/v1beta/organizations/${encodeURIComponent(orgId)}/workspaces`
|
|
134
|
+
const resp = await fetchWithTimeout(
|
|
135
|
+
url,
|
|
136
|
+
{
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
139
|
+
body: JSON.stringify({
|
|
140
|
+
...(options.description ? { description: options.description } : {}),
|
|
141
|
+
clientType: HARNESS_CLIENT_TYPE,
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
fetchImpl,
|
|
145
|
+
30_000,
|
|
146
|
+
options?.signal,
|
|
147
|
+
)
|
|
148
|
+
await checkResponse(resp, url)
|
|
149
|
+
|
|
150
|
+
const data: unknown = await resp.json().catch(() => undefined)
|
|
151
|
+
return mapWorkspace(data, endpoint)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function deleteWorkspace(apiKey: string, id: string, options?: WorkspaceCommandOptions): Promise<void> {
|
|
155
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch
|
|
156
|
+
const endpoint = resolveEndpoint(options)
|
|
157
|
+
const orgId = await resolveOrgId(apiKey, options)
|
|
158
|
+
|
|
159
|
+
const url = `${endpoint}/ai-optimizer/v1beta/organizations/${encodeURIComponent(orgId)}/workspaces/${encodeURIComponent(id)}`
|
|
160
|
+
const resp = await fetchWithTimeout(
|
|
161
|
+
url,
|
|
162
|
+
{ method: "DELETE", headers: { Authorization: `Bearer ${apiKey}` } },
|
|
163
|
+
fetchImpl,
|
|
164
|
+
30_000,
|
|
165
|
+
options?.signal,
|
|
166
|
+
)
|
|
167
|
+
await checkResponse(resp, url)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function mapWorkspace(raw: unknown, endpoint: string): Workspace {
|
|
171
|
+
if (typeof raw !== "object" || raw === null) {
|
|
172
|
+
throw new RemoteNetworkError(`Invalid workspace entry in response from ${endpoint}`)
|
|
173
|
+
}
|
|
174
|
+
const r = raw as Record<string, unknown>
|
|
175
|
+
|
|
176
|
+
const id = typeof r.id === "string" && r.id.length > 0 ? r.id : undefined
|
|
177
|
+
if (!id) {
|
|
178
|
+
throw new RemoteNetworkError(`Missing workspace id in response from ${endpoint}`)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const uri = typeof r.uri === "string" && r.uri.length > 0 ? r.uri : undefined
|
|
182
|
+
const alias = uri?.split(".")[0] || id
|
|
183
|
+
|
|
184
|
+
let createdAt: Date | undefined
|
|
185
|
+
if (typeof r.createTime === "string") {
|
|
186
|
+
const parsed = new Date(r.createTime)
|
|
187
|
+
if (!Number.isNaN(parsed.getTime())) createdAt = parsed
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let host: string | undefined
|
|
191
|
+
if (uri) {
|
|
192
|
+
try {
|
|
193
|
+
host = new URL(uri.includes("://") ? uri : `wss://${uri}`).hostname
|
|
194
|
+
} catch {
|
|
195
|
+
host = undefined // tolerate URIs URL can't parse — host is display-only here
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Provisioned sizes arrive nested under spec.resources as Kubernetes
|
|
200
|
+
// quantity strings ("200m", "512Mi", "10Gi").
|
|
201
|
+
const spec = typeof r.spec === "object" && r.spec !== null ? (r.spec as Record<string, unknown>) : undefined
|
|
202
|
+
const res =
|
|
203
|
+
spec && typeof spec.resources === "object" && spec.resources !== null
|
|
204
|
+
? (spec.resources as Record<string, unknown>)
|
|
205
|
+
: undefined
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
id,
|
|
209
|
+
alias,
|
|
210
|
+
description: typeof r.description === "string" ? r.description : "",
|
|
211
|
+
status: mapWorkspaceStatus(r.status),
|
|
212
|
+
uri,
|
|
213
|
+
host,
|
|
214
|
+
createdAt: createdAt ?? new Date(0),
|
|
215
|
+
cpuMillicores: cpuQuantityToMillicores(res?.cpu),
|
|
216
|
+
ramBytes: byteQuantityToBytes(res?.memory),
|
|
217
|
+
pvcSizeBytes: byteQuantityToBytes(res?.pvcSize),
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function mapWorkspaceStatus(raw: unknown): WorkspaceStatus {
|
|
222
|
+
switch (raw) {
|
|
223
|
+
case "ACTIVE":
|
|
224
|
+
return "active"
|
|
225
|
+
case "INITIALIZING":
|
|
226
|
+
return "initializing"
|
|
227
|
+
case "SUSPENDED":
|
|
228
|
+
return "suspended"
|
|
229
|
+
case "DELETING":
|
|
230
|
+
return "deleting"
|
|
231
|
+
default:
|
|
232
|
+
return "idle"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { readFileSync, statSync, utimesSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { cleanupTempDirs, makeTempDir } from "../test-support.js"
|
|
5
|
+
import {
|
|
6
|
+
clearSharedAuth,
|
|
7
|
+
kimchiProviderIdsFromModelsJson,
|
|
8
|
+
readAuthJson,
|
|
9
|
+
readSavedApiKey,
|
|
10
|
+
syncSharedAuth,
|
|
11
|
+
} from "./auth-file.js"
|
|
12
|
+
|
|
13
|
+
// These tests mirror the fixtures in kimchi-dev:src/pi-auth.test.ts — the
|
|
14
|
+
// auth.json byte format is a cross-tool contract: the harness must be able to
|
|
15
|
+
// read what kimchictl writes and vice versa.
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanupTempDirs()
|
|
19
|
+
vi.unstubAllEnvs()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("syncSharedAuth", () => {
|
|
23
|
+
it("creates auth.json on first run with the base provider", async () => {
|
|
24
|
+
const dir = makeTempDir()
|
|
25
|
+
const authPath = join(dir, "auth.json")
|
|
26
|
+
|
|
27
|
+
await syncSharedAuth(authPath, "kimchi-key", [])
|
|
28
|
+
|
|
29
|
+
expect(JSON.parse(readFileSync(authPath, "utf-8"))).toEqual({
|
|
30
|
+
"kimchi-dev": { type: "api_key", key: "kimchi-key" },
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("stores the key for every supplied Kimchi provider id and preserves other providers", async () => {
|
|
35
|
+
const dir = makeTempDir()
|
|
36
|
+
const authPath = join(dir, "auth.json")
|
|
37
|
+
writeFileSync(
|
|
38
|
+
authPath,
|
|
39
|
+
JSON.stringify({
|
|
40
|
+
anthropic: { type: "api_key", key: "keep-me" },
|
|
41
|
+
"kimchi-experimental": { type: "api_key", key: "old-kimchi-key" },
|
|
42
|
+
}),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
await syncSharedAuth(authPath, "kimchi-key", ["kimchi-dev/openai", "kimchi-experimental"])
|
|
46
|
+
|
|
47
|
+
expect(JSON.parse(readFileSync(authPath, "utf-8"))).toEqual({
|
|
48
|
+
anthropic: { type: "api_key", key: "keep-me" },
|
|
49
|
+
"kimchi-dev": { type: "api_key", key: "kimchi-key" },
|
|
50
|
+
"kimchi-dev/openai": { type: "api_key", key: "kimchi-key" },
|
|
51
|
+
"kimchi-experimental": { type: "api_key", key: "kimchi-key" },
|
|
52
|
+
})
|
|
53
|
+
expect(statSync(authPath).mode & 0o777).toBe(0o600)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("ignores non-Kimchi ids in the provider list (defense in depth)", async () => {
|
|
57
|
+
const dir = makeTempDir()
|
|
58
|
+
const authPath = join(dir, "auth.json")
|
|
59
|
+
|
|
60
|
+
await syncSharedAuth(authPath, "kimchi-key", ["ollama"])
|
|
61
|
+
|
|
62
|
+
expect(JSON.parse(readFileSync(authPath, "utf-8"))).toEqual({
|
|
63
|
+
"kimchi-dev": { type: "api_key", key: "kimchi-key" },
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("removes stale Kimchi credentials when the key is cleared", async () => {
|
|
68
|
+
const dir = makeTempDir()
|
|
69
|
+
const authPath = join(dir, "auth.json")
|
|
70
|
+
writeFileSync(
|
|
71
|
+
authPath,
|
|
72
|
+
JSON.stringify({
|
|
73
|
+
"kimchi-dev": { type: "api_key", key: "old" },
|
|
74
|
+
"kimchi-experimental": { type: "api_key", key: "old" },
|
|
75
|
+
ollama: { type: "api_key", key: "keep" },
|
|
76
|
+
}),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
await syncSharedAuth(authPath, "", [])
|
|
80
|
+
|
|
81
|
+
expect(JSON.parse(readFileSync(authPath, "utf-8"))).toEqual({
|
|
82
|
+
ollama: { type: "api_key", key: "keep" },
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("does not rewrite unchanged storage (mtime preserved), only re-chmod", async () => {
|
|
87
|
+
const dir = makeTempDir()
|
|
88
|
+
const authPath = join(dir, "auth.json")
|
|
89
|
+
const originalAuth = JSON.stringify({
|
|
90
|
+
anthropic: { type: "api_key", key: "keep-me" },
|
|
91
|
+
"kimchi-dev": { type: "api_key", key: "kimchi-key" },
|
|
92
|
+
})
|
|
93
|
+
writeFileSync(authPath, originalAuth)
|
|
94
|
+
const fixedTime = new Date("2000-01-01T00:00:00.000Z")
|
|
95
|
+
utimesSync(authPath, fixedTime, fixedTime)
|
|
96
|
+
const originalMtime = statSync(authPath).mtimeMs
|
|
97
|
+
|
|
98
|
+
await syncSharedAuth(authPath, "kimchi-key", [])
|
|
99
|
+
|
|
100
|
+
expect(readFileSync(authPath, "utf-8")).toBe(originalAuth)
|
|
101
|
+
expect(statSync(authPath).mtimeMs).toBe(originalMtime)
|
|
102
|
+
expect(statSync(authPath).mode & 0o777).toBe(0o600)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe("clearSharedAuth", () => {
|
|
107
|
+
it("removes every Kimchi credential, keeps other providers, needs no models.json", async () => {
|
|
108
|
+
const dir = makeTempDir()
|
|
109
|
+
const authPath = join(dir, "auth.json")
|
|
110
|
+
writeFileSync(
|
|
111
|
+
authPath,
|
|
112
|
+
JSON.stringify({
|
|
113
|
+
"kimchi-dev": { type: "api_key", key: "old" },
|
|
114
|
+
"kimchi-dev/anthropic": { type: "api_key", key: "old" },
|
|
115
|
+
"kimchi-experimental": { type: "api_key", key: "old" },
|
|
116
|
+
anthropic: { type: "api_key", key: "keep" },
|
|
117
|
+
}),
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
await clearSharedAuth(authPath)
|
|
121
|
+
|
|
122
|
+
expect(JSON.parse(readFileSync(authPath, "utf-8"))).toEqual({
|
|
123
|
+
anthropic: { type: "api_key", key: "keep" },
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it("is a no-op when auth.json does not exist", async () => {
|
|
128
|
+
const dir = makeTempDir()
|
|
129
|
+
await expect(clearSharedAuth(join(dir, "auth.json"))).resolves.toBeUndefined()
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("readSavedApiKey / readAuthJson", () => {
|
|
134
|
+
it("reads the kimchi-dev key", () => {
|
|
135
|
+
const dir = makeTempDir()
|
|
136
|
+
const authPath = join(dir, "auth.json")
|
|
137
|
+
writeFileSync(authPath, JSON.stringify({ "kimchi-dev": { type: "api_key", key: "abc" } }))
|
|
138
|
+
expect(readSavedApiKey(authPath)).toBe("abc")
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("returns undefined for missing file / missing entry / empty key", () => {
|
|
142
|
+
const dir = makeTempDir()
|
|
143
|
+
expect(readSavedApiKey(join(dir, "nope.json"))).toBeUndefined()
|
|
144
|
+
const authPath = join(dir, "auth.json")
|
|
145
|
+
writeFileSync(authPath, JSON.stringify({ "kimchi-dev": { type: "api_key", key: "" } }))
|
|
146
|
+
expect(readSavedApiKey(authPath)).toBeUndefined()
|
|
147
|
+
expect(readAuthJson(join(dir, "nope.json"))).toEqual({})
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("kimchiProviderIdsFromModelsJson", () => {
|
|
152
|
+
it("returns Kimchi provider ids only", () => {
|
|
153
|
+
const dir = makeTempDir()
|
|
154
|
+
const modelsPath = join(dir, "models.json")
|
|
155
|
+
writeFileSync(
|
|
156
|
+
modelsPath,
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
providers: { "kimchi-dev": {}, "kimchi-dev/openai": {}, "kimchi-experimental": {}, ollama: {} },
|
|
159
|
+
}),
|
|
160
|
+
)
|
|
161
|
+
expect(new Set(kimchiProviderIdsFromModelsJson(modelsPath))).toEqual(
|
|
162
|
+
new Set(["kimchi-dev", "kimchi-dev/openai", "kimchi-experimental"]),
|
|
163
|
+
)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it("tolerates missing/corrupt/unexpected models.json", () => {
|
|
167
|
+
const dir = makeTempDir()
|
|
168
|
+
expect(kimchiProviderIdsFromModelsJson(join(dir, "absent.json"))).toEqual([])
|
|
169
|
+
const corrupt = join(dir, "corrupt.json")
|
|
170
|
+
writeFileSync(corrupt, "{ not json")
|
|
171
|
+
expect(kimchiProviderIdsFromModelsJson(corrupt)).toEqual([])
|
|
172
|
+
const noProviders = join(dir, "empty.json")
|
|
173
|
+
writeFileSync(noProviders, JSON.stringify({ something: 1 }))
|
|
174
|
+
expect(kimchiProviderIdsFromModelsJson(noProviders)).toEqual([])
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { isDeepStrictEqual } from "node:util"
|
|
4
|
+
import { lock } from "proper-lockfile"
|
|
5
|
+
import { isKimchiProvider } from "./provider-ids.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shared auth.json access — a port of the kimchi harness's `syncPiAuth`
|
|
9
|
+
* (kimchi-dev:src/pi-auth.ts). The file format is the harness's pi auth
|
|
10
|
+
* storage:
|
|
11
|
+
*
|
|
12
|
+
* { "<providerId>": { "type": "api_key", "key": "…" }, … }
|
|
13
|
+
*
|
|
14
|
+
* written pretty-printed (2-space indent, trailing newline), mode 0600, under
|
|
15
|
+
* a proper-lockfile lock. kimchictl must stay byte-compatible: the harness
|
|
16
|
+
* reads the entries this writes, and vice versa.
|
|
17
|
+
*
|
|
18
|
+
* Difference vs the harness port: the harness derives provider ids from
|
|
19
|
+
* models.json and throws when it is missing; kimchictl takes the ids as a
|
|
20
|
+
* parameter (the login command derives them from models.json when present and
|
|
21
|
+
* from the models metadata API otherwise) so it can run on machines that
|
|
22
|
+
* have never installed the harness.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Read the raw auth.json map. Returns {} when the file is absent or blank. */
|
|
26
|
+
export function readAuthJson(authPath: string): Record<string, unknown> {
|
|
27
|
+
if (!existsSync(authPath)) return {}
|
|
28
|
+
return JSON.parse(readFileSync(authPath, "utf-8") || "{}") as Record<string, unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Read the saved Kimchi API key, if the shared auth.json has one. */
|
|
32
|
+
export function readSavedApiKey(authPath: string): string | undefined {
|
|
33
|
+
const entry = readAuthJson(authPath)["kimchi-dev"]
|
|
34
|
+
if (entry && typeof entry === "object" && "key" in entry && typeof entry.key === "string" && entry.key.length > 0) {
|
|
35
|
+
return entry.key
|
|
36
|
+
}
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Replace all Kimchi-managed entries in auth.json: delete every existing
|
|
42
|
+
* kimchi provider credential (`kimchi-dev`, `kimchi-dev/*`,
|
|
43
|
+
* `kimchi-experimental`), then, when apiKey is non-empty, write
|
|
44
|
+
* `{ type: "api_key", key }` for each id in providerIds (deduped,
|
|
45
|
+
* "kimchi-dev" always included). Empty apiKey = pure clear.
|
|
46
|
+
*
|
|
47
|
+
* Non-Kimchi providers are preserved untouched. Mirrors the harness: no-op
|
|
48
|
+
* content only re-chmods to 0600 instead of rewriting.
|
|
49
|
+
*/
|
|
50
|
+
export async function syncSharedAuth(authPath: string, apiKey: string, providerIds: readonly string[]): Promise<void> {
|
|
51
|
+
mkdirSync(dirname(authPath), { recursive: true, mode: 0o700 })
|
|
52
|
+
const release = await lock(authPath, { realpath: false, retries: 10 })
|
|
53
|
+
try {
|
|
54
|
+
const authExists = existsSync(authPath)
|
|
55
|
+
if (!apiKey && !authExists) return
|
|
56
|
+
|
|
57
|
+
const credentials = authExists ? readAuthJson(authPath) : {}
|
|
58
|
+
const nextCredentials = { ...credentials }
|
|
59
|
+
for (const providerId of Object.keys(nextCredentials)) {
|
|
60
|
+
if (isKimchiProvider(providerId)) {
|
|
61
|
+
delete nextCredentials[providerId]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (apiKey) {
|
|
66
|
+
const ids = new Set([...providerIds.filter(isKimchiProvider), "kimchi-dev"])
|
|
67
|
+
for (const providerId of ids) {
|
|
68
|
+
nextCredentials[providerId] = { type: "api_key", key: apiKey }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (authExists && isDeepStrictEqual(credentials, nextCredentials)) {
|
|
73
|
+
chmodSync(authPath, 0o600)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
writeFileSync(authPath, `${JSON.stringify(nextCredentials, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 })
|
|
78
|
+
chmodSync(authPath, 0o600)
|
|
79
|
+
} finally {
|
|
80
|
+
await release()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Remove all Kimchi credentials from auth.json (logout path). No-op when absent. */
|
|
85
|
+
export async function clearSharedAuth(authPath: string): Promise<void> {
|
|
86
|
+
await syncSharedAuth(authPath, "", [])
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Kimchi provider ids listed in an existing harness models.json ({} when absent/unreadable). */
|
|
90
|
+
export function kimchiProviderIdsFromModelsJson(modelsPath: string): string[] {
|
|
91
|
+
let parsed: unknown
|
|
92
|
+
try {
|
|
93
|
+
parsed = JSON.parse(readFileSync(modelsPath, "utf-8"))
|
|
94
|
+
} catch {
|
|
95
|
+
// models.json is the harness's file; an absent or corrupt one must not
|
|
96
|
+
// block kimchictl login — the provider-id list falls back to the metadata fetch.
|
|
97
|
+
return []
|
|
98
|
+
}
|
|
99
|
+
if (!parsed || typeof parsed !== "object" || !("providers" in parsed)) return []
|
|
100
|
+
const providers = (parsed as { providers: unknown }).providers
|
|
101
|
+
if (!providers || typeof providers !== "object") return []
|
|
102
|
+
return Object.keys(providers).filter(isKimchiProvider)
|
|
103
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { authenticateViaBrowser, startCallbackServer } from "./browser-login.js"
|
|
3
|
+
|
|
4
|
+
afterEach(() => {
|
|
5
|
+
vi.unstubAllEnvs()
|
|
6
|
+
vi.restoreAllMocks()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe("startCallbackServer", () => {
|
|
10
|
+
it("delivers the token on a valid state callback", async () => {
|
|
11
|
+
const server = await startCallbackServer("state-123")
|
|
12
|
+
const resp = await fetch(`${server.url}?state=state-123&token=tok-xyz`)
|
|
13
|
+
|
|
14
|
+
expect(resp.status).toBe(200)
|
|
15
|
+
expect(await resp.text()).toContain("Your CLI is now connected")
|
|
16
|
+
await expect(server.result).resolves.toEqual({ token: "tok-xyz" })
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("rejects a wrong state (CSRF) and reports the error", async () => {
|
|
20
|
+
const server = await startCallbackServer("expected-state")
|
|
21
|
+
const resp = await fetch(`${server.url}?state=forged&token=abc`)
|
|
22
|
+
|
|
23
|
+
expect(resp.status).toBe(400)
|
|
24
|
+
const result = await server.result
|
|
25
|
+
expect(result.error).toMatch(/request isn't valid/)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("surfaces the web app's error_description", async () => {
|
|
29
|
+
const server = await startCallbackServer("s")
|
|
30
|
+
const resp = await fetch(`${server.url}?state=s&error=access_denied&error_description=User+denied`)
|
|
31
|
+
|
|
32
|
+
expect(resp.status).toBe(200)
|
|
33
|
+
await expect(server.result).resolves.toEqual({ error: "User denied" })
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("404s non-callback paths without resolving the result", async () => {
|
|
37
|
+
const server = await startCallbackServer("s")
|
|
38
|
+
try {
|
|
39
|
+
const resp = await fetch(`${server.url.replace("/callback", "/other")}`)
|
|
40
|
+
expect(resp.status).toBe(404)
|
|
41
|
+
} finally {
|
|
42
|
+
server.close()
|
|
43
|
+
}
|
|
44
|
+
await expect(server.result).resolves.toEqual({ error: "Login cancelled" })
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe("authenticateViaBrowser", () => {
|
|
49
|
+
it("builds the cli-auth URL and completes when the callback delivers a token", async () => {
|
|
50
|
+
vi.stubEnv("KIMCHI_WEB_APP_URL", "https://app.example.test")
|
|
51
|
+
const seenUrls: string[] = []
|
|
52
|
+
|
|
53
|
+
const authPromise = authenticateViaBrowser({
|
|
54
|
+
onBrowserUrl: (url) => seenUrls.push(url),
|
|
55
|
+
open: async (url) => {
|
|
56
|
+
const parsed = new URL(url)
|
|
57
|
+
const callback = new URL(decodeURIComponent(parsed.searchParams.get("callback") ?? ""))
|
|
58
|
+
callback.searchParams.set("state", parsed.searchParams.get("state") ?? "")
|
|
59
|
+
callback.searchParams.set("token", "browser-token")
|
|
60
|
+
await fetch(callback)
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const { token } = await authPromise
|
|
65
|
+
expect(token).toBe("browser-token")
|
|
66
|
+
expect(seenUrls).toHaveLength(1)
|
|
67
|
+
expect(seenUrls[0]).toMatch(
|
|
68
|
+
/^https:\/\/app\.example\.test\/cli-auth\?callback=http%3A%2F%2F127\.0\.0\.1%3A\d+%2Fcallback&state=[0-9a-f]{64}$/,
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("rejects when the browser flow errors", async () => {
|
|
73
|
+
const authPromise = authenticateViaBrowser({
|
|
74
|
+
open: async (url) => {
|
|
75
|
+
const parsed = new URL(url)
|
|
76
|
+
const callback = new URL(decodeURIComponent(parsed.searchParams.get("callback") ?? ""))
|
|
77
|
+
callback.searchParams.set("state", parsed.searchParams.get("state") ?? "")
|
|
78
|
+
callback.searchParams.set("error", "access_denied")
|
|
79
|
+
await fetch(callback)
|
|
80
|
+
},
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
await expect(authPromise).rejects.toThrow(/access_denied/)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("rejects with 'Login cancelled' when aborted before opening", async () => {
|
|
87
|
+
const controller = new AbortController()
|
|
88
|
+
controller.abort()
|
|
89
|
+
await expect(authenticateViaBrowser({ signal: controller.signal, open: async () => {} })).rejects.toThrow(
|
|
90
|
+
/Login cancelled/,
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
})
|