@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,247 @@
1
+ /**
2
+ * Browser login — ported from the kimchi harness (kimchi-dev:src/cli-auth/):
3
+ * transient 127.0.0.1 callback server → open `webApp/cli-auth?callback=…&state=…`
4
+ * → web app delivers the API key back to the callback. Same URL grammar and
5
+ * state-CSRF validation so both tools can drive the same web flow.
6
+ */
7
+
8
+ import { randomBytes } from "node:crypto"
9
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
10
+ import open from "open"
11
+
12
+ const CALLBACK_PATH = "/callback"
13
+ const CALLBACK_TIMEOUT_MS = 300_000 // 5 minutes, mirrors the harness
14
+ const SUCCESS_MESSAGE = "Your CLI is now connected. You can close this window and return to kimchictl."
15
+
16
+ const DEFAULT_WEB_APP_URL = "https://app.kimchi.dev"
17
+
18
+ /** Web app base URL: KIMCHI_WEB_APP_URL override, default https://app.kimchi.dev. */
19
+ export function resolveWebAppUrl(options?: { webAppUrl?: string }): string {
20
+ return options?.webAppUrl ?? process.env.KIMCHI_WEB_APP_URL ?? DEFAULT_WEB_APP_URL
21
+ }
22
+
23
+ function escapeHtml(text: string): string {
24
+ return text
25
+ .replaceAll("&", "&")
26
+ .replaceAll("<", "&lt;")
27
+ .replaceAll(">", "&gt;")
28
+ .replaceAll('"', "&quot;")
29
+ .replaceAll("'", "&#39;")
30
+ }
31
+
32
+ function pageHtml(title: string, heading: string, message: string, ok: boolean): string {
33
+ const color = ok ? "#16a34a" : "#dc2626"
34
+ return `<!DOCTYPE html>
35
+ <html><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
36
+ <style>body{font-family:system-ui,sans-serif;display:flex;min-height:100vh;margin:0;align-items:center;justify-content:center;background:#fafafa}
37
+ main{max-width:28rem;padding:2rem;text-align:center}h1{color:${color};font-size:1.25rem}p{color:#444}</style></head>
38
+ <body><main><h1>${escapeHtml(heading)}</h1><p>${escapeHtml(message)}</p></main></body></html>`
39
+ }
40
+
41
+ const successHtml = (message: string) => pageHtml("Login successful", "Login successful", message, true)
42
+ const errorHtml = (title: string, message: string) => pageHtml(title, title, message, false)
43
+
44
+ export interface CallbackResult {
45
+ token?: string
46
+ error?: string
47
+ }
48
+
49
+ export interface CallbackServer {
50
+ port: number
51
+ url: string
52
+ result: Promise<CallbackResult>
53
+ close: () => void
54
+ }
55
+
56
+ export function generateState(): string {
57
+ return randomBytes(32).toString("hex")
58
+ }
59
+
60
+ /**
61
+ * Start a temporary HTTP server on 127.0.0.1 to receive the token callback.
62
+ * Validates the state parameter against CSRF; times out after 5 minutes.
63
+ */
64
+ export function startCallbackServer(expectedState: string): Promise<CallbackServer> {
65
+ return new Promise<CallbackServer>((resolveStart, rejectStart) => {
66
+ let server: Server | undefined
67
+ let resolved = false
68
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined
69
+ let resolveResult: ((r: CallbackResult) => void) | undefined
70
+
71
+ function finish(result: CallbackResult) {
72
+ if (resolved) return
73
+ resolved = true
74
+ if (timeoutTimer) clearTimeout(timeoutTimer)
75
+ resolveResult?.(result)
76
+ // Defer socket destruction so the HTTP response has time to flush
77
+ setTimeout(closeServer, 100)
78
+ }
79
+
80
+ function closeServer() {
81
+ if (!server) return
82
+ try {
83
+ server.closeAllConnections?.()
84
+ server.close()
85
+ } catch {
86
+ // Already closing or closed — safe to ignore
87
+ }
88
+ server = undefined
89
+ }
90
+
91
+ function onRequest(req: IncomingMessage, res: ServerResponse) {
92
+ try {
93
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`)
94
+
95
+ const remote = req.socket.remoteAddress ?? ""
96
+ if (!(remote.startsWith("127.") || remote === "::1" || remote === "::ffff:127.0.0.1")) {
97
+ res.writeHead(403, { "Content-Type": "text/html", Connection: "close" })
98
+ res.end(errorHtml("Forbidden", "Only localhost connections are allowed."))
99
+ return
100
+ }
101
+
102
+ if (url.pathname !== CALLBACK_PATH) {
103
+ res.writeHead(404, { "Content-Type": "text/plain", Connection: "close" })
104
+ res.end("Not found")
105
+ return
106
+ }
107
+
108
+ const state = url.searchParams.get("state")
109
+ if (!state || state !== expectedState) {
110
+ const errorMsg = "This request isn't valid. Please try logging in again from your terminal."
111
+ res.writeHead(400, { "Content-Type": "text/html", Connection: "close" })
112
+ res.end(errorHtml("Login error", errorMsg))
113
+ finish({ error: errorMsg })
114
+ return
115
+ }
116
+
117
+ const error = url.searchParams.get("error")
118
+ if (error) {
119
+ const errorMsg = url.searchParams.get("error_description") || error
120
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" })
121
+ res.end(errorHtml("Authentication failed", errorMsg))
122
+ finish({ error: errorMsg })
123
+ return
124
+ }
125
+
126
+ const token = url.searchParams.get("token")
127
+ if (!token) {
128
+ const errorMsg = "No token was returned by the authentication server"
129
+ res.writeHead(400, { "Content-Type": "text/html", Connection: "close" })
130
+ res.end(errorHtml("Missing token", errorMsg))
131
+ finish({ error: errorMsg })
132
+ return
133
+ }
134
+
135
+ res.writeHead(200, { "Content-Type": "text/html", Connection: "close" })
136
+ res.end(successHtml(SUCCESS_MESSAGE))
137
+ finish({ token })
138
+ } catch (err) {
139
+ const message = err instanceof Error ? err.message : String(err)
140
+ res.writeHead(500, { "Content-Type": "text/plain", Connection: "close" })
141
+ res.end("Internal server error")
142
+ finish({ error: `Unexpected server error: ${message}` })
143
+ req.socket.destroy()
144
+ }
145
+ }
146
+
147
+ server = createServer(onRequest)
148
+
149
+ server.listen(0, "127.0.0.1", () => {
150
+ const addr = server?.address()
151
+ if (!addr || typeof addr === "string") {
152
+ closeServer()
153
+ rejectStart(new Error("Could not determine callback server port"))
154
+ return
155
+ }
156
+
157
+ const port = addr.port
158
+
159
+ timeoutTimer = setTimeout(() => {
160
+ finish({ error: "Browser login timed out — please try again" })
161
+ }, CALLBACK_TIMEOUT_MS)
162
+
163
+ const resultPromise = new Promise<CallbackResult>((resolve) => {
164
+ resolveResult = resolve
165
+ })
166
+
167
+ resolveStart({
168
+ port,
169
+ url: `http://127.0.0.1:${port}${CALLBACK_PATH}`,
170
+ result: resultPromise,
171
+ close: () => {
172
+ finish({ error: "Login cancelled" })
173
+ },
174
+ })
175
+ })
176
+
177
+ server.on("error", (err) => {
178
+ closeServer()
179
+ rejectStart(err)
180
+ })
181
+ })
182
+ }
183
+
184
+ export interface BrowserAuthOptions {
185
+ /** Base URL of the Kimchi web app (default: https://app.kimchi.dev; env: KIMCHI_WEB_APP_URL) */
186
+ webAppUrl?: string
187
+ /** Abort the flow (tears down the callback server early). */
188
+ signal?: AbortSignal
189
+ /** Invoked with the browser URL before the browser opens. */
190
+ onBrowserUrl?: (url: string) => void
191
+ /** Injected browser opener (tests). */
192
+ open?: (url: string) => Promise<unknown>
193
+ }
194
+
195
+ export interface BrowserAuthResult {
196
+ token: string
197
+ }
198
+
199
+ /**
200
+ * Authenticate via browser: redirect the user to the Kimchi web app; the
201
+ * resulting API key is sent back to the transient localhost callback server.
202
+ */
203
+ export async function authenticateViaBrowser(options: BrowserAuthOptions = {}): Promise<BrowserAuthResult> {
204
+ const webAppUrl = resolveWebAppUrl(options)
205
+ const state = generateState()
206
+ const callbackServer = await startCallbackServer(state)
207
+
208
+ const onAbort = () => callbackServer.close()
209
+
210
+ try {
211
+ if (options.signal?.aborted) {
212
+ callbackServer.close()
213
+ throw new Error("Login cancelled")
214
+ }
215
+ options.signal?.addEventListener("abort", onAbort, { once: true })
216
+
217
+ const callbackUrl = encodeURIComponent(callbackServer.url)
218
+ const browserUrl = `${webAppUrl}/cli-auth?callback=${callbackUrl}&state=${encodeURIComponent(state)}`
219
+
220
+ options.onBrowserUrl?.(browserUrl)
221
+
222
+ console.log("Opening your browser to complete login…")
223
+ const opener = options.open ?? open
224
+ try {
225
+ await opener(browserUrl)
226
+ } catch {
227
+ console.log("Couldn't open your browser automatically. Please visit the URL above manually.")
228
+ }
229
+
230
+ const result = await callbackServer.result
231
+
232
+ if (result.error) {
233
+ throw new Error(result.error)
234
+ }
235
+ if (!result.token) {
236
+ throw new Error("Browser login completed but no token was received")
237
+ }
238
+
239
+ callbackServer.close()
240
+ return { token: result.token }
241
+ } catch (err) {
242
+ callbackServer.close()
243
+ throw err
244
+ } finally {
245
+ options.signal?.removeEventListener("abort", onAbort)
246
+ }
247
+ }
@@ -0,0 +1,68 @@
1
+ import { readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { afterEach, describe, expect, it } from "vitest"
4
+ import { cleanupTempDirs, makeTempDir } from "../test-support.js"
5
+ import { readApiKeyFromConfig, writeApiKeyToConfig } from "./config-file.js"
6
+
7
+ afterEach(() => {
8
+ cleanupTempDirs()
9
+ })
10
+
11
+ describe("writeApiKeyToConfig", () => {
12
+ it("creates config.json with the key, mode 0600", () => {
13
+ const configPath = join(makeTempDir(), "config.json")
14
+
15
+ writeApiKeyToConfig(configPath, "new-key")
16
+
17
+ expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({ apiKey: "new-key" })
18
+ expect(statSync(configPath).mode & 0o777).toBe(0o600)
19
+ })
20
+
21
+ it("preserves unrelated fields when setting and clearing the key", () => {
22
+ const configPath = join(makeTempDir(), "config.json")
23
+ writeFileSync(configPath, JSON.stringify({ telemetry: { enabled: false }, apiKey: "old", other: 1 }))
24
+
25
+ writeApiKeyToConfig(configPath, "new-key")
26
+ expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({
27
+ telemetry: { enabled: false },
28
+ apiKey: "new-key",
29
+ other: 1,
30
+ })
31
+
32
+ writeApiKeyToConfig(configPath, undefined)
33
+ expect(JSON.parse(readFileSync(configPath, "utf-8"))).toEqual({
34
+ telemetry: { enabled: false },
35
+ other: 1,
36
+ })
37
+ })
38
+
39
+ it("is a no-op clearing a key that is not there (including a missing file)", () => {
40
+ const configPath = join(makeTempDir(), "config.json")
41
+ writeApiKeyToConfig(configPath, undefined)
42
+ expect(() => statSync(configPath)).toThrow()
43
+ })
44
+ })
45
+
46
+ describe("readApiKeyFromConfig", () => {
47
+ it("reads camelCase and legacy snake_case spellings", () => {
48
+ const dir = makeTempDir()
49
+ const camel = join(dir, "c1.json")
50
+ writeFileSync(camel, JSON.stringify({ apiKey: "camel-key" }))
51
+ expect(readApiKeyFromConfig(camel)).toBe("camel-key")
52
+
53
+ const snake = join(dir, "c2.json")
54
+ writeFileSync(snake, JSON.stringify({ api_key: "snake-key" }))
55
+ expect(readApiKeyFromConfig(snake)).toBe("snake-key")
56
+ })
57
+
58
+ it("returns undefined for missing/corrupt/empty configs", () => {
59
+ const dir = makeTempDir()
60
+ expect(readApiKeyFromConfig(join(dir, "nope.json"))).toBeUndefined()
61
+ const empty = join(dir, "empty.json")
62
+ writeFileSync(empty, JSON.stringify({ apiKey: "" }))
63
+ expect(readApiKeyFromConfig(empty)).toBeUndefined()
64
+ const corrupt = join(dir, "corrupt.json")
65
+ writeFileSync(corrupt, "{ nope")
66
+ expect(readApiKeyFromConfig(corrupt)).toBeUndefined()
67
+ })
68
+ })
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { dirname } from "node:path"
3
+
4
+ /**
5
+ * Minimal config.json access — the harness's other credential slot.
6
+ *
7
+ * The harness browser login writes the API key to BOTH auth.json and
8
+ * config.json (`writeApiKey` in kimchi-dev:src/config.ts), and its startup
9
+ * sync repairs auth.json from config.json. kimchictl mirrors that so a
10
+ * kimchictl-first login activates the harness without a second login, and a
11
+ * `kimchictl logout` doesn't leave a live key behind in config.json.
12
+ *
13
+ * Only the `apiKey` field is ever touched; all other fields are preserved.
14
+ */
15
+
16
+ function readConfigRaw(configPath: string): Record<string, unknown> {
17
+ if (!existsSync(configPath)) return {}
18
+ try {
19
+ const parsed = JSON.parse(readFileSync(configPath, "utf-8") || "{}")
20
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}
21
+ } catch {
22
+ // A corrupt config.json must not wedge kimchictl; leave the harness to re-write it.
23
+ return {}
24
+ }
25
+ }
26
+
27
+ export function readApiKeyFromConfig(configPath: string): string | undefined {
28
+ const raw = readConfigRaw(configPath)
29
+ // The harness accepts camelCase and snake_case historical spellings.
30
+ for (const field of ["apiKey", "api_key"] as const) {
31
+ const value = raw[field]
32
+ if (typeof value === "string" && value.length > 0) return value
33
+ }
34
+ return undefined
35
+ }
36
+
37
+ /** Set the apiKey field (pass undefined to remove it). Preserves all other fields. */
38
+ export function writeApiKeyToConfig(configPath: string, apiKey: string | undefined): void {
39
+ const raw = readConfigRaw(configPath)
40
+ if (apiKey === undefined) {
41
+ if (raw.apiKey === undefined) return
42
+ // undefined-assignment instead of delete: the file is immediately
43
+ // JSON.stringify'd, which drops undefined properties the same way.
44
+ raw.apiKey = undefined
45
+ } else {
46
+ raw.apiKey = apiKey
47
+ }
48
+ mkdirSync(dirname(configPath), { recursive: true, mode: 0o700 })
49
+ writeFileSync(configPath, `${JSON.stringify(raw, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 })
50
+ }
@@ -0,0 +1,63 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest"
2
+ import { jsonResponse, stubFetch } from "../test-support.js"
3
+ import { fetchKimchiProviderIds, ModelsFetchError } from "./models-providers.js"
4
+
5
+ afterEach(() => {
6
+ vi.unstubAllEnvs()
7
+ })
8
+
9
+ describe("fetchKimchiProviderIds", () => {
10
+ it("derives kimchi-dev plus one id per non-ai-enabler upstream provider", async () => {
11
+ const requested: string[] = []
12
+ const fetch = stubFetch(async (url) => {
13
+ requested.push(url)
14
+ return jsonResponse({
15
+ models: [
16
+ { slug: "m1", provider: "ai-enabler" },
17
+ { slug: "m2", provider: "anthropic" },
18
+ { slug: "m3", provider: "anthropic" },
19
+ { slug: "m4", provider: "openai" },
20
+ ],
21
+ })
22
+ })
23
+
24
+ const ids = await fetchKimchiProviderIds("key", { fetch })
25
+
26
+ expect(new Set(ids)).toEqual(new Set(["kimchi-dev", "kimchi-dev/anthropic", "kimchi-dev/openai"]))
27
+ expect(requested).toEqual(["https://llm.kimchi.dev/v1/models/metadata?include_in_cli=true"])
28
+ })
29
+
30
+ it("yields only kimchi-dev when every model is ai-enabler", async () => {
31
+ const fetch = stubFetch(async () => jsonResponse({ models: [{ slug: "m1", provider: "ai-enabler" }] }))
32
+ expect(await fetchKimchiProviderIds("key", { fetch })).toEqual(["kimchi-dev"])
33
+ })
34
+
35
+ it("throws ModelsFetchError with the HTTP status (401 = invalid key)", async () => {
36
+ const fetch = stubFetch(async () => new Response("unauthorized", { status: 401, statusText: "Unauthorized" }))
37
+ try {
38
+ await fetchKimchiProviderIds("bad-key", { fetch })
39
+ expect.unreachable("should have thrown")
40
+ } catch (err) {
41
+ expect(err).toBeInstanceOf(ModelsFetchError)
42
+ expect((err as ModelsFetchError).status).toBe(401)
43
+ }
44
+ })
45
+
46
+ it("normalizes scheme-less endpoints and duplicated slashes", async () => {
47
+ vi.stubEnv("KIMCHI_LLM_ENDPOINT", "llm.internal.example/")
48
+ const requested: string[] = []
49
+ const fetch = stubFetch(async (url) => {
50
+ requested.push(url)
51
+ return jsonResponse({ models: [] })
52
+ })
53
+
54
+ await fetchKimchiProviderIds("key", { fetch })
55
+
56
+ expect(requested).toEqual(["https://llm.internal.example/v1/models/metadata?include_in_cli=true"])
57
+ })
58
+
59
+ it("rejects malformed response bodies", async () => {
60
+ const fetch = stubFetch(async () => jsonResponse({ notModels: true }))
61
+ await expect(fetchKimchiProviderIds("key", { fetch })).rejects.toThrow(/Unexpected response shape/)
62
+ })
63
+ })
@@ -0,0 +1,76 @@
1
+ import { KIMCHI_PROVIDER_ID } from "./provider-ids.js"
2
+
3
+ /**
4
+ * Minimal models-metadata fetch used to (a) validate an API key at login and
5
+ * (b) derive the full set of Kimchi provider ids for auth.json
6
+ * ("kimchi-dev" plus one "kimchi-dev/<upstream>" per upstream provider),
7
+ * matching what the harness writes from models.json.
8
+ *
9
+ * Endpoint shape and derivation rules mirror kimchi-dev:src/models.ts
10
+ * (buildModelsConfig). Only the id set matters here — models.json itself
11
+ * remains the harness's file to write and is never created by kimchictl.
12
+ */
13
+
14
+ const DEFAULT_LLM_ENDPOINT = "https://llm.kimchi.dev"
15
+ const FETCH_TIMEOUT_MS = 20_000
16
+
17
+ export class ModelsFetchError extends Error {
18
+ readonly status?: number
19
+ constructor(message: string, options: { status?: number } = {}) {
20
+ super(message)
21
+ this.name = "ModelsFetchError"
22
+ this.status = options.status
23
+ }
24
+ }
25
+
26
+ function normalizeLlmEndpoint(endpoint?: string): string {
27
+ const trimmed = endpoint?.trim()
28
+ if (!trimmed) return DEFAULT_LLM_ENDPOINT
29
+ // Mirror the harness: scheme-less values get https:// so `dev.example.com` works.
30
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`
31
+ return withScheme.replace(/\/+$/, "")
32
+ }
33
+
34
+ export interface FetchProviderIdsOptions {
35
+ /** LLM service base URL; default https://llm.kimchi.dev. Env override: KIMCHI_LLM_ENDPOINT. */
36
+ endpoint?: string
37
+ fetch?: typeof globalThis.fetch
38
+ }
39
+
40
+ function resolveLlmEndpoint(options?: FetchProviderIdsOptions): string {
41
+ return options?.endpoint ?? process.env.KIMCHI_LLM_ENDPOINT ?? DEFAULT_LLM_ENDPOINT
42
+ }
43
+
44
+ /**
45
+ * Fetch the active model list and derive Kimchi provider ids from it.
46
+ * Throws ModelsFetchError with .status on HTTP failure (401 = invalid key).
47
+ */
48
+ export async function fetchKimchiProviderIds(apiKey: string, options?: FetchProviderIdsOptions): Promise<string[]> {
49
+ const fetchImpl = options?.fetch ?? globalThis.fetch
50
+ const url = `${normalizeLlmEndpoint(resolveLlmEndpoint(options))}/v1/models/metadata?include_in_cli=true`
51
+
52
+ const resp = await fetchImpl(url, {
53
+ headers: { Authorization: `Bearer ${apiKey}` },
54
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
55
+ })
56
+ if (!resp.ok) {
57
+ throw new ModelsFetchError(`Failed to fetch models: ${resp.status} ${resp.statusText}`, { status: resp.status })
58
+ }
59
+
60
+ const body = (await resp.json().catch(() => undefined)) as { models?: unknown } | undefined
61
+ if (!body || !Array.isArray(body.models)) {
62
+ throw new ModelsFetchError("Unexpected response shape from models API")
63
+ }
64
+
65
+ const ids = new Set<string>([KIMCHI_PROVIDER_ID])
66
+ for (const model of body.models) {
67
+ if (model && typeof model === "object" && "provider" in model) {
68
+ const provider = (model as { provider: unknown }).provider
69
+ // "ai-enabler" entries live under the base "kimchi-dev" provider (mirrors buildModelsConfig).
70
+ if (typeof provider === "string" && provider !== "ai-enabler") {
71
+ ids.add(`${KIMCHI_PROVIDER_ID}/${provider}`)
72
+ }
73
+ }
74
+ }
75
+ return [...ids]
76
+ }
@@ -0,0 +1,28 @@
1
+ import { homedir } from "node:os"
2
+ import { resolve } from "node:path"
3
+
4
+ /**
5
+ * Shared-credential paths, mirroring kimchi-dev exactly:
6
+ *
7
+ * ~/.config/kimchi/harness/ agent dir — auth.json, models.json
8
+ * (overridable via KIMCHI_CODING_AGENT_DIR,
9
+ * same fallback as kimchi-dev:src/cli.ts)
10
+ * ~/.config/kimchi/config.json shared wizard config — the `apiKey` slot
11
+ * (fixed path, no env override — same as
12
+ * kimchi-dev:src/config.ts KIMCHI_CONFIG_PATH)
13
+ */
14
+ export function resolveKimchiAgentDir(env: NodeJS.ProcessEnv = process.env): string {
15
+ return env.KIMCHI_CODING_AGENT_DIR ?? resolve(env.HOME ?? homedir(), ".config", "kimchi", "harness")
16
+ }
17
+
18
+ export function resolveAuthJsonPath(env: NodeJS.ProcessEnv = process.env): string {
19
+ return resolve(resolveKimchiAgentDir(env), "auth.json")
20
+ }
21
+
22
+ export function resolveModelsJsonPath(env: NodeJS.ProcessEnv = process.env): string {
23
+ return resolve(resolveKimchiAgentDir(env), "models.json")
24
+ }
25
+
26
+ export function resolveConfigJsonPath(env: NodeJS.ProcessEnv = process.env): string {
27
+ return resolve(env.HOME ?? homedir(), ".config", "kimchi", "config.json")
28
+ }
@@ -0,0 +1,15 @@
1
+ // Provider-id predicates. Dependency-free mirror of kimchi-dev's
2
+ // src/kimchi-provider.ts — the auth.json contract is shared with the harness,
3
+ // so the id grammar must stay in lockstep.
4
+
5
+ export const KIMCHI_PROVIDER_ID = "kimchi-dev"
6
+ const KIMCHI_EXPERIMENTAL_PROVIDER_ID = "kimchi-experimental"
7
+
8
+ /** True for every Kimchi-managed provider, including experimental models. */
9
+ export function isKimchiProvider(provider: string): boolean {
10
+ return (
11
+ provider === KIMCHI_PROVIDER_ID ||
12
+ provider.startsWith(`${KIMCHI_PROVIDER_ID}/`) ||
13
+ provider === KIMCHI_EXPERIMENTAL_PROVIDER_ID
14
+ )
15
+ }
@@ -0,0 +1,72 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+ import { cleanupTempDirs, makeTempDir, stubAgentDirEnv, writeSharedConfigJson } from "../test-support.js"
5
+ import { requireApiKey, resolveApiKey } from "./resolve.js"
6
+
7
+ afterEach(() => {
8
+ cleanupTempDirs()
9
+ vi.unstubAllEnvs()
10
+ })
11
+
12
+ describe("resolveApiKey precedence", () => {
13
+ it("prefers KIMCHI_API_KEY over both files", () => {
14
+ const dir = makeTempDir()
15
+ const { home } = stubAgentDirEnv(dir)
16
+ vi.stubEnv("KIMCHI_API_KEY", "env-key")
17
+ writeSharedConfigJson(home, { apiKey: "config-key" })
18
+ writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "auth-key" } }))
19
+
20
+ expect(resolveApiKey()).toEqual({ key: "env-key", source: "environment" })
21
+ })
22
+
23
+ it("falls back to config.json, then auth.json", () => {
24
+ const dir = makeTempDir()
25
+ const { home } = stubAgentDirEnv(dir)
26
+ writeSharedConfigJson(home, { apiKey: "config-key" })
27
+ writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "auth-key" } }))
28
+
29
+ expect(resolveApiKey()).toEqual({ key: "config-key", source: "config" })
30
+ })
31
+
32
+ it("uses auth.json when no config key exists", () => {
33
+ const dir = makeTempDir()
34
+ stubAgentDirEnv(dir)
35
+ writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "auth-key" } }))
36
+
37
+ expect(resolveApiKey()).toEqual({ key: "auth-key", source: "auth-json" })
38
+ })
39
+
40
+ it("returns undefined when nothing is stored", () => {
41
+ stubAgentDirEnv(makeTempDir())
42
+ expect(resolveApiKey()).toBeUndefined()
43
+ })
44
+ })
45
+
46
+ describe("requireApiKey", () => {
47
+ it("throws the standard not-logged-in error", () => {
48
+ stubAgentDirEnv(makeTempDir())
49
+ expect(() => requireApiKey()).toThrowError(/Not logged in/)
50
+ })
51
+ })
52
+
53
+ describe("default layout (no env overrides) — regression: the real harness paths", () => {
54
+ // These pin the fix for "harness is logged in but kimchictl says Not logged
55
+ // in": with no env vars at all, credentials must be found at the harness's
56
+ // real locations, not a guessed directory.
57
+ it("resolves auth.json from ~/.config/kimchi/harness", () => {
58
+ const home = makeTempDir()
59
+ const agentDir = join(home, ".config", "kimchi", "harness")
60
+ mkdirSync(agentDir, { recursive: true })
61
+ writeFileSync(join(agentDir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "auth-key" } }))
62
+
63
+ expect(resolveApiKey({ HOME: home })).toEqual({ key: "auth-key", source: "auth-json" })
64
+ })
65
+
66
+ it("resolves the apiKey from ~/.config/kimchi/config.json (sibling of the agent dir)", () => {
67
+ const home = makeTempDir()
68
+ writeSharedConfigJson(home, { apiKey: "config-key" })
69
+
70
+ expect(resolveApiKey({ HOME: home })).toEqual({ key: "config-key", source: "config" })
71
+ })
72
+ })
@@ -0,0 +1,46 @@
1
+ import { readSavedApiKey } from "./auth-file.js"
2
+ import { readApiKeyFromConfig } from "./config-file.js"
3
+ import { resolveAuthJsonPath, resolveConfigJsonPath } from "./paths.js"
4
+
5
+ export type ApiKeySource = "environment" | "config" | "auth-json"
6
+
7
+ export interface ResolvedApiKey {
8
+ key: string
9
+ source: ApiKeySource
10
+ }
11
+
12
+ /**
13
+ * Credential resolution, mirroring the harness precedence
14
+ * (kimchi-dev:src/config.ts — env key beats the saved login):
15
+ * 1. KIMCHI_API_KEY environment variable
16
+ * 2. config.json `apiKey` (the harness's saved key)
17
+ * 3. auth.json `kimchi-dev` entry
18
+ */
19
+ export function resolveApiKey(env: NodeJS.ProcessEnv = process.env): ResolvedApiKey | undefined {
20
+ const envKey = env.KIMCHI_API_KEY?.trim()
21
+ if (envKey) return { key: envKey, source: "environment" }
22
+
23
+ const configKey = readApiKeyFromConfig(resolveConfigJsonPath(env))
24
+ if (configKey) return { key: configKey, source: "config" }
25
+
26
+ const authKey = readSavedApiKey(resolveAuthJsonPath(env))
27
+ if (authKey) return { key: authKey, source: "auth-json" }
28
+
29
+ return undefined
30
+ }
31
+
32
+ /** Standard error for commands that need a credential that isn't there. */
33
+ export function requireApiKey(env: NodeJS.ProcessEnv = process.env): ResolvedApiKey {
34
+ const resolved = resolveApiKey(env)
35
+ if (!resolved) {
36
+ throw new NotLoggedInError()
37
+ }
38
+ return resolved
39
+ }
40
+
41
+ export class NotLoggedInError extends Error {
42
+ constructor() {
43
+ super("Not logged in. Run `kimchictl login` first (or set KIMCHI_API_KEY).")
44
+ this.name = "NotLoggedInError"
45
+ }
46
+ }