@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.
Files changed (231) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +105 -0
  3. package/bin/kimchictl.mjs +17 -0
  4. package/dist/api/http.d.ts +7 -0
  5. package/dist/api/http.d.ts.map +1 -0
  6. package/dist/api/http.js +60 -0
  7. package/dist/api/http.js.map +1 -0
  8. package/dist/api/keys.d.ts +7 -0
  9. package/dist/api/keys.d.ts.map +1 -0
  10. package/dist/api/keys.js +28 -0
  11. package/dist/api/keys.js.map +1 -0
  12. package/dist/api/me.d.ts +12 -0
  13. package/dist/api/me.d.ts.map +1 -0
  14. package/dist/api/me.js +28 -0
  15. package/dist/api/me.js.map +1 -0
  16. package/dist/api/resolver.d.ts +26 -0
  17. package/dist/api/resolver.d.ts.map +1 -0
  18. package/dist/api/resolver.js +62 -0
  19. package/dist/api/resolver.js.map +1 -0
  20. package/dist/api/resources.d.ts +16 -0
  21. package/dist/api/resources.d.ts.map +1 -0
  22. package/dist/api/resources.js +96 -0
  23. package/dist/api/resources.js.map +1 -0
  24. package/dist/api/templates.d.ts +19 -0
  25. package/dist/api/templates.d.ts.map +1 -0
  26. package/dist/api/templates.js +25 -0
  27. package/dist/api/templates.js.map +1 -0
  28. package/dist/api/tokens.d.ts +23 -0
  29. package/dist/api/tokens.d.ts.map +1 -0
  30. package/dist/api/tokens.js +51 -0
  31. package/dist/api/tokens.js.map +1 -0
  32. package/dist/api/types.d.ts +34 -0
  33. package/dist/api/types.d.ts.map +1 -0
  34. package/dist/api/types.js +32 -0
  35. package/dist/api/types.js.map +1 -0
  36. package/dist/api/wait.d.ts +17 -0
  37. package/dist/api/wait.d.ts.map +1 -0
  38. package/dist/api/wait.js +37 -0
  39. package/dist/api/wait.js.map +1 -0
  40. package/dist/api/workspaces.d.ts +33 -0
  41. package/dist/api/workspaces.d.ts.map +1 -0
  42. package/dist/api/workspaces.js +159 -0
  43. package/dist/api/workspaces.js.map +1 -0
  44. package/dist/auth/auth-file.d.ts +37 -0
  45. package/dist/auth/auth-file.d.ts.map +1 -0
  46. package/dist/auth/auth-file.js +100 -0
  47. package/dist/auth/auth-file.js.map +1 -0
  48. package/dist/auth/browser-login.d.ts +45 -0
  49. package/dist/auth/browser-login.d.ts.map +1 -0
  50. package/dist/auth/browser-login.js +195 -0
  51. package/dist/auth/browser-login.js.map +1 -0
  52. package/dist/auth/config-file.d.ts +4 -0
  53. package/dist/auth/config-file.d.ts.map +1 -0
  54. package/dist/auth/config-file.js +52 -0
  55. package/dist/auth/config-file.js.map +1 -0
  56. package/dist/auth/models-providers.d.ts +17 -0
  57. package/dist/auth/models-providers.d.ts.map +1 -0
  58. package/dist/auth/models-providers.js +63 -0
  59. package/dist/auth/models-providers.js.map +1 -0
  60. package/dist/auth/paths.d.ts +15 -0
  61. package/dist/auth/paths.d.ts.map +1 -0
  62. package/dist/auth/paths.js +25 -0
  63. package/dist/auth/paths.js.map +1 -0
  64. package/dist/auth/provider-ids.d.ts +4 -0
  65. package/dist/auth/provider-ids.d.ts.map +1 -0
  66. package/dist/auth/provider-ids.js +12 -0
  67. package/dist/auth/provider-ids.js.map +1 -0
  68. package/dist/auth/resolve.d.ts +19 -0
  69. package/dist/auth/resolve.d.ts.map +1 -0
  70. package/dist/auth/resolve.js +37 -0
  71. package/dist/auth/resolve.js.map +1 -0
  72. package/dist/cli.d.ts +2 -0
  73. package/dist/cli.d.ts.map +1 -0
  74. package/dist/cli.js +15 -0
  75. package/dist/cli.js.map +1 -0
  76. package/dist/commands/completion.d.ts +2 -0
  77. package/dist/commands/completion.d.ts.map +1 -0
  78. package/dist/commands/completion.js +71 -0
  79. package/dist/commands/completion.js.map +1 -0
  80. package/dist/commands/flags.d.ts +16 -0
  81. package/dist/commands/flags.d.ts.map +1 -0
  82. package/dist/commands/flags.js +23 -0
  83. package/dist/commands/flags.js.map +1 -0
  84. package/dist/commands/guard.d.ts +10 -0
  85. package/dist/commands/guard.d.ts.map +1 -0
  86. package/dist/commands/guard.js +41 -0
  87. package/dist/commands/guard.js.map +1 -0
  88. package/dist/commands/help.d.ts +4 -0
  89. package/dist/commands/help.d.ts.map +1 -0
  90. package/dist/commands/help.js +19 -0
  91. package/dist/commands/help.js.map +1 -0
  92. package/dist/commands/login.d.ts +18 -0
  93. package/dist/commands/login.d.ts.map +1 -0
  94. package/dist/commands/login.js +80 -0
  95. package/dist/commands/login.js.map +1 -0
  96. package/dist/commands/logout.d.ts +14 -0
  97. package/dist/commands/logout.d.ts.map +1 -0
  98. package/dist/commands/logout.js +41 -0
  99. package/dist/commands/logout.js.map +1 -0
  100. package/dist/commands/prompt.d.ts +9 -0
  101. package/dist/commands/prompt.d.ts.map +1 -0
  102. package/dist/commands/prompt.js +21 -0
  103. package/dist/commands/prompt.js.map +1 -0
  104. package/dist/commands/registry.d.ts +16 -0
  105. package/dist/commands/registry.d.ts.map +1 -0
  106. package/dist/commands/registry.js +35 -0
  107. package/dist/commands/registry.js.map +1 -0
  108. package/dist/commands/ssh.d.ts +15 -0
  109. package/dist/commands/ssh.d.ts.map +1 -0
  110. package/dist/commands/ssh.js +90 -0
  111. package/dist/commands/ssh.js.map +1 -0
  112. package/dist/commands/table.d.ts +3 -0
  113. package/dist/commands/table.d.ts.map +1 -0
  114. package/dist/commands/table.js +10 -0
  115. package/dist/commands/table.js.map +1 -0
  116. package/dist/commands/update.d.ts +16 -0
  117. package/dist/commands/update.d.ts.map +1 -0
  118. package/dist/commands/update.js +57 -0
  119. package/dist/commands/update.js.map +1 -0
  120. package/dist/commands/version.d.ts +2 -0
  121. package/dist/commands/version.d.ts.map +1 -0
  122. package/dist/commands/version.js +6 -0
  123. package/dist/commands/version.js.map +1 -0
  124. package/dist/commands/whoami.d.ts +7 -0
  125. package/dist/commands/whoami.d.ts.map +1 -0
  126. package/dist/commands/whoami.js +30 -0
  127. package/dist/commands/whoami.js.map +1 -0
  128. package/dist/commands/workspace.d.ts +15 -0
  129. package/dist/commands/workspace.d.ts.map +1 -0
  130. package/dist/commands/workspace.js +206 -0
  131. package/dist/commands/workspace.js.map +1 -0
  132. package/dist/run.d.ts +10 -0
  133. package/dist/run.d.ts.map +1 -0
  134. package/dist/run.js +30 -0
  135. package/dist/run.js.map +1 -0
  136. package/dist/ssh/bridge.d.ts +25 -0
  137. package/dist/ssh/bridge.d.ts.map +1 -0
  138. package/dist/ssh/bridge.js +95 -0
  139. package/dist/ssh/bridge.js.map +1 -0
  140. package/dist/ssh/config.d.ts +65 -0
  141. package/dist/ssh/config.d.ts.map +1 -0
  142. package/dist/ssh/config.js +198 -0
  143. package/dist/ssh/config.js.map +1 -0
  144. package/dist/ssh/connect.d.ts +27 -0
  145. package/dist/ssh/connect.d.ts.map +1 -0
  146. package/dist/ssh/connect.js +67 -0
  147. package/dist/ssh/connect.js.map +1 -0
  148. package/dist/ssh/tunnel.d.ts +29 -0
  149. package/dist/ssh/tunnel.d.ts.map +1 -0
  150. package/dist/ssh/tunnel.js +36 -0
  151. package/dist/ssh/tunnel.js.map +1 -0
  152. package/dist/test-support.d.ts +30 -0
  153. package/dist/test-support.d.ts.map +1 -0
  154. package/dist/test-support.js +67 -0
  155. package/dist/test-support.js.map +1 -0
  156. package/dist/updates/check.d.ts +18 -0
  157. package/dist/updates/check.d.ts.map +1 -0
  158. package/dist/updates/check.js +90 -0
  159. package/dist/updates/check.js.map +1 -0
  160. package/dist/updates/self.d.ts +36 -0
  161. package/dist/updates/self.d.ts.map +1 -0
  162. package/dist/updates/self.js +103 -0
  163. package/dist/updates/self.js.map +1 -0
  164. package/dist/version.d.ts +7 -0
  165. package/dist/version.d.ts.map +1 -0
  166. package/dist/version.js +7 -0
  167. package/dist/version.js.map +1 -0
  168. package/package.json +77 -0
  169. package/src/api/http.ts +70 -0
  170. package/src/api/keys.ts +40 -0
  171. package/src/api/me.ts +48 -0
  172. package/src/api/resolver.test.ts +109 -0
  173. package/src/api/resolver.ts +72 -0
  174. package/src/api/resources.test.ts +56 -0
  175. package/src/api/resources.ts +92 -0
  176. package/src/api/templates.ts +28 -0
  177. package/src/api/tokens.ts +80 -0
  178. package/src/api/types.ts +50 -0
  179. package/src/api/wait.ts +45 -0
  180. package/src/api/workspaces.ts +234 -0
  181. package/src/auth/auth-file.test.ts +176 -0
  182. package/src/auth/auth-file.ts +103 -0
  183. package/src/auth/browser-login.test.ts +93 -0
  184. package/src/auth/browser-login.ts +247 -0
  185. package/src/auth/config-file.test.ts +68 -0
  186. package/src/auth/config-file.ts +50 -0
  187. package/src/auth/models-providers.test.ts +63 -0
  188. package/src/auth/models-providers.ts +76 -0
  189. package/src/auth/paths.ts +28 -0
  190. package/src/auth/provider-ids.ts +15 -0
  191. package/src/auth/resolve.test.ts +72 -0
  192. package/src/auth/resolve.ts +46 -0
  193. package/src/cli.ts +16 -0
  194. package/src/commands/completion.test.ts +31 -0
  195. package/src/commands/completion.ts +77 -0
  196. package/src/commands/flags.ts +29 -0
  197. package/src/commands/guard.ts +44 -0
  198. package/src/commands/help.ts +20 -0
  199. package/src/commands/login.test.ts +159 -0
  200. package/src/commands/login.ts +94 -0
  201. package/src/commands/logout.test.ts +89 -0
  202. package/src/commands/logout.ts +53 -0
  203. package/src/commands/prompt.ts +21 -0
  204. package/src/commands/registry.ts +50 -0
  205. package/src/commands/ssh.test.ts +101 -0
  206. package/src/commands/ssh.ts +112 -0
  207. package/src/commands/table.test.ts +24 -0
  208. package/src/commands/table.ts +10 -0
  209. package/src/commands/update.test.ts +91 -0
  210. package/src/commands/update.ts +78 -0
  211. package/src/commands/version.ts +6 -0
  212. package/src/commands/whoami.test.ts +72 -0
  213. package/src/commands/whoami.ts +36 -0
  214. package/src/commands/workspace.test.ts +360 -0
  215. package/src/commands/workspace.ts +250 -0
  216. package/src/run.test.ts +53 -0
  217. package/src/run.ts +34 -0
  218. package/src/ssh/bridge.test.ts +70 -0
  219. package/src/ssh/bridge.ts +110 -0
  220. package/src/ssh/config.test.ts +213 -0
  221. package/src/ssh/config.ts +233 -0
  222. package/src/ssh/connect.test.ts +123 -0
  223. package/src/ssh/connect.ts +98 -0
  224. package/src/ssh/tunnel.test.ts +119 -0
  225. package/src/ssh/tunnel.ts +64 -0
  226. package/src/test-support.ts +77 -0
  227. package/src/updates/check.test.ts +99 -0
  228. package/src/updates/check.ts +117 -0
  229. package/src/updates/self.test.ts +117 -0
  230. package/src/updates/self.ts +131 -0
  231. 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
+ })