@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,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("<", "<")
|
|
27
|
+
.replaceAll(">", ">")
|
|
28
|
+
.replaceAll('"', """)
|
|
29
|
+
.replaceAll("'", "'")
|
|
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
|
+
}
|