@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,109 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { jsonResponse, stubFetch } from "../test-support.js"
|
|
3
|
+
import { AmbiguousWorkspaceError, isWorkspaceUuid, resolveWorkspace, WorkspaceNotFoundError } from "./resolver.js"
|
|
4
|
+
|
|
5
|
+
const UUID = "3d3dc322-dd05-4d0c-adab-beb2a0819e69"
|
|
6
|
+
|
|
7
|
+
function wsJson(alias: string, over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
8
|
+
return {
|
|
9
|
+
id: UUID,
|
|
10
|
+
description: "",
|
|
11
|
+
status: "ACTIVE",
|
|
12
|
+
createTime: "2026-01-01T00:00:00Z",
|
|
13
|
+
uri: `${alias}.remote.kimchi.dev`,
|
|
14
|
+
spec: { resources: { cpu: "1", memory: "2Gi", pvcSize: "10Gi" } },
|
|
15
|
+
...over,
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function apiFetch(routes: { method: string; match: RegExp; respond: () => Response }[]): typeof globalThis.fetch {
|
|
20
|
+
return stubFetch(async (url, init) => {
|
|
21
|
+
if ((init?.method ?? "GET") === "POST" && url.endsWith("workspace-tokens:verifyKey")) {
|
|
22
|
+
return jsonResponse({ organizationId: "org-1" })
|
|
23
|
+
}
|
|
24
|
+
for (const route of routes) {
|
|
25
|
+
if (route.method === (init?.method ?? "GET") && route.match.test(url)) return route.respond()
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`unexpected request: ${init?.method ?? "GET"} ${url}`)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("isWorkspaceUuid", () => {
|
|
32
|
+
it("recognizes UUID shapes only", () => {
|
|
33
|
+
expect(isWorkspaceUuid(UUID)).toBe(true)
|
|
34
|
+
expect(isWorkspaceUuid(UUID.toUpperCase())).toBe(true)
|
|
35
|
+
expect(isWorkspaceUuid("pensive-brainy-kimchi")).toBe(false)
|
|
36
|
+
expect(isWorkspaceUuid("3d3dc322-dd05-4d0c-adab-beb2a0819e6")).toBe(false)
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
describe("resolveWorkspace", () => {
|
|
41
|
+
it("goes straight to GET for a UUID ref (no list call)", async () => {
|
|
42
|
+
const fetch = apiFetch([
|
|
43
|
+
{ method: "GET", match: new RegExp(`/${UUID}$`), respond: () => jsonResponse(wsJson("pensive-brainy")) },
|
|
44
|
+
])
|
|
45
|
+
|
|
46
|
+
const ws = await resolveWorkspace("key-1", UUID, { fetch })
|
|
47
|
+
|
|
48
|
+
expect(ws.id).toBe(UUID)
|
|
49
|
+
expect(ws.alias).toBe("pensive-brainy")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("maps a 404 on the UUID path to WorkspaceNotFoundError", async () => {
|
|
53
|
+
const fetch = apiFetch([
|
|
54
|
+
{ method: "GET", match: new RegExp(`/${UUID}$`), respond: () => new Response("gone", { status: 404 }) },
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
await expect(resolveWorkspace("key-1", UUID, { fetch })).rejects.toThrow(WorkspaceNotFoundError)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("resolves an exact alias via the workspace list", async () => {
|
|
61
|
+
const fetch = apiFetch([
|
|
62
|
+
{
|
|
63
|
+
method: "GET",
|
|
64
|
+
match: /\?page\.limit=/,
|
|
65
|
+
respond: () => jsonResponse({ items: [wsJson("bright-oak-otter"), wsJson("pensive-brainy")] }),
|
|
66
|
+
},
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
const ws = await resolveWorkspace("key-1", "pensive-brainy", { fetch })
|
|
70
|
+
|
|
71
|
+
expect(ws.alias).toBe("pensive-brainy")
|
|
72
|
+
expect(ws.id).toBe(UUID)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("resolves a case-insensitive alias prefix", async () => {
|
|
76
|
+
const fetch = apiFetch([
|
|
77
|
+
{
|
|
78
|
+
method: "GET",
|
|
79
|
+
match: /\?page\.limit=/,
|
|
80
|
+
respond: () => jsonResponse({ items: [wsJson("bright-oak-otter"), wsJson("pensive-brainy")] }),
|
|
81
|
+
},
|
|
82
|
+
])
|
|
83
|
+
|
|
84
|
+
const ws = await resolveWorkspace("key-1", "Pensive", { fetch })
|
|
85
|
+
|
|
86
|
+
expect(ws.alias).toBe("pensive-brainy")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("lists candidates when a prefix is ambiguous", async () => {
|
|
90
|
+
const fetch = apiFetch([
|
|
91
|
+
{
|
|
92
|
+
method: "GET",
|
|
93
|
+
match: /\?page\.limit=/,
|
|
94
|
+
respond: () => jsonResponse({ items: [wsJson("bright-oak-otter"), wsJson("bright-oak-owl")] }),
|
|
95
|
+
},
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
await expect(resolveWorkspace("key-1", "bright-oak", { fetch })).rejects.toThrow(AmbiguousWorkspaceError)
|
|
99
|
+
await expect(resolveWorkspace("key-1", "bright-oak", { fetch })).rejects.toThrow(/bright-oak-otter, bright-oak-owl/)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it("throws WorkspaceNotFoundError when nothing matches", async () => {
|
|
103
|
+
const fetch = apiFetch([
|
|
104
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson("bright-oak-otter")] }) },
|
|
105
|
+
])
|
|
106
|
+
|
|
107
|
+
await expect(resolveWorkspace("key-1", "ghost", { fetch })).rejects.toThrow(/No workspace matches "ghost"/)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { ApiOptions } from "./types.js"
|
|
2
|
+
import { RemoteAuthError } from "./types.js"
|
|
3
|
+
import { getWorkspace, listWorkspaces, type Workspace, type WorkspaceCommandOptions } from "./workspaces.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Workspace reference resolution — a port of kap's internal/resolver.
|
|
7
|
+
*
|
|
8
|
+
* Users type the alias (the first DNS label of the workspace URI) or a
|
|
9
|
+
* prefix of it; the control plane addresses workspaces by UUID. Resolution:
|
|
10
|
+
* 1. UUID-shaped refs go straight to GET (404 → not found).
|
|
11
|
+
* 2. Anything else lists workspaces and matches, in order:
|
|
12
|
+
* exact id, exact alias, case-insensitive alias prefix.
|
|
13
|
+
* 3. No match → WorkspaceNotFoundError; multiple matches →
|
|
14
|
+
* AmbiguousWorkspaceError listing the candidates.
|
|
15
|
+
*
|
|
16
|
+
* kap caches the list for 30s — pointless in a one-shot CLI process, so
|
|
17
|
+
* this port is uncached.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export class WorkspaceNotFoundError extends Error {
|
|
21
|
+
constructor(ref: string) {
|
|
22
|
+
super(`No workspace matches "${ref}" — list workspaces with: kimchictl workspace list`)
|
|
23
|
+
this.name = "WorkspaceNotFoundError"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class AmbiguousWorkspaceError extends Error {
|
|
28
|
+
constructor(ref: string, candidates: readonly string[]) {
|
|
29
|
+
super(`Multiple workspaces match "${ref}": ${candidates.join(", ")} — use the full alias`)
|
|
30
|
+
this.name = "AmbiguousWorkspaceError"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
35
|
+
|
|
36
|
+
export function isWorkspaceUuid(ref: string): boolean {
|
|
37
|
+
return UUID_RE.test(ref)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function matchesRef(ws: Workspace, ref: string): boolean {
|
|
41
|
+
if (ws.id === ref) return true
|
|
42
|
+
if (ws.alias === ref) return true
|
|
43
|
+
return ws.alias.toLowerCase().startsWith(ref.toLowerCase())
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Resolve a workspace reference (UUID, alias, or alias prefix) to the workspace. */
|
|
47
|
+
export async function resolveWorkspace(
|
|
48
|
+
apiKey: string,
|
|
49
|
+
ref: string,
|
|
50
|
+
options?: WorkspaceCommandOptions & ApiOptions,
|
|
51
|
+
): Promise<Workspace> {
|
|
52
|
+
if (isWorkspaceUuid(ref)) {
|
|
53
|
+
try {
|
|
54
|
+
return await getWorkspace(apiKey, ref, options)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
if (err instanceof RemoteAuthError && err.statusCode === 404) {
|
|
57
|
+
throw new WorkspaceNotFoundError(ref)
|
|
58
|
+
}
|
|
59
|
+
throw err
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const workspaces = await listWorkspaces(apiKey, options)
|
|
64
|
+
const matched = workspaces.filter((ws) => matchesRef(ws, ref))
|
|
65
|
+
const [unique] = matched
|
|
66
|
+
if (matched.length === 1 && unique) return unique
|
|
67
|
+
if (matched.length === 0) throw new WorkspaceNotFoundError(ref)
|
|
68
|
+
throw new AmbiguousWorkspaceError(
|
|
69
|
+
ref,
|
|
70
|
+
matched.map((ws) => ws.alias),
|
|
71
|
+
)
|
|
72
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { byteQuantityToBytes, cpuQuantityToMillicores, formatAge, formatBytes, formatMillicores } from "./resources.js"
|
|
3
|
+
|
|
4
|
+
describe("quantity parsing", () => {
|
|
5
|
+
it.each([
|
|
6
|
+
["200m", 200],
|
|
7
|
+
["1.5", 1500],
|
|
8
|
+
["2", 2000],
|
|
9
|
+
["1e1", 10000],
|
|
10
|
+
])("cpuQuantityToMillicores(%s) → %d", (input, expected) => {
|
|
11
|
+
expect(cpuQuantityToMillicores(input)).toBe(expected)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it.each([
|
|
15
|
+
["512Mi", 536870912],
|
|
16
|
+
["1G", 1000000000],
|
|
17
|
+
["1.5Gi", 1610612736],
|
|
18
|
+
["20Gi", 21474836480],
|
|
19
|
+
])("byteQuantityToBytes(%s) → %d", (input, expected) => {
|
|
20
|
+
expect(byteQuantityToBytes(input)).toBe(expected)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it.each([["not-a-quantity"], ["-5"], ["1e3m"], [""]])("rejects %s", (input) => {
|
|
24
|
+
expect(cpuQuantityToMillicores(input)).toBeUndefined()
|
|
25
|
+
expect(byteQuantityToBytes(input)).toBeUndefined()
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("display formatting", () => {
|
|
30
|
+
it("formats millicores", () => {
|
|
31
|
+
expect(formatMillicores(undefined)).toBe("-")
|
|
32
|
+
expect(formatMillicores(250)).toBe("250m")
|
|
33
|
+
expect(formatMillicores(1000)).toBe("1")
|
|
34
|
+
expect(formatMillicores(1500)).toBe("1.5")
|
|
35
|
+
expect(formatMillicores(2000)).toBe("2")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("formats bytes", () => {
|
|
39
|
+
expect(formatBytes(undefined)).toBe("-")
|
|
40
|
+
expect(formatBytes(536870912)).toBe("512Mi")
|
|
41
|
+
expect(formatBytes(2147483648)).toBe("2Gi")
|
|
42
|
+
expect(formatBytes(1610612736)).toBe("1.5Gi")
|
|
43
|
+
expect(formatBytes(2048)).toBe("2Ki")
|
|
44
|
+
expect(formatBytes(100)).toBe("100B")
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("formats ages", () => {
|
|
48
|
+
const now = new Date("2026-01-02T12:00:00Z")
|
|
49
|
+
expect(formatAge(new Date("2026-01-02T11:59:30Z"), now)).toBe("30s")
|
|
50
|
+
expect(formatAge(new Date("2026-01-02T11:45:00Z"), now)).toBe("15m")
|
|
51
|
+
expect(formatAge(new Date("2026-01-02T09:00:00Z"), now)).toBe("3h")
|
|
52
|
+
expect(formatAge(new Date("2025-12-28T12:00:00Z"), now)).toBe("5d")
|
|
53
|
+
// Clock skew must never produce a negative age.
|
|
54
|
+
expect(formatAge(new Date("2027-01-01T00:00:00Z"), now)).toBe("0s")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Kubernetes quantity parsing (port of kimchi-dev:src/sandbox/cloud/resources.ts,
|
|
3
|
+
* trimmed to what the workspace list/detail rendering needs) plus display
|
|
4
|
+
* formatters for human tables.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Kubernetes quantity: mantissa, then EITHER an exponent OR a decimal/binary
|
|
9
|
+
* SI suffix — never both.
|
|
10
|
+
*/
|
|
11
|
+
const QUANTITY_RE = /^([0-9]+(?:\.[0-9]+)?|\.[0-9]+)([eE][+-]?[0-9]+|n|u|m|k|M|G|T|P|E|Ki|Mi|Gi|Ti|Pi|Ei)?$/
|
|
12
|
+
|
|
13
|
+
const DECIMAL_SUFFIX: Record<string, number> = {
|
|
14
|
+
n: 1e-9,
|
|
15
|
+
u: 1e-6,
|
|
16
|
+
m: 1e-3,
|
|
17
|
+
k: 1e3,
|
|
18
|
+
M: 1e6,
|
|
19
|
+
G: 1e9,
|
|
20
|
+
T: 1e12,
|
|
21
|
+
P: 1e15,
|
|
22
|
+
E: 1e18,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const BINARY_SUFFIX: Record<string, number> = {
|
|
26
|
+
Ki: 1024,
|
|
27
|
+
Mi: 1024 ** 2,
|
|
28
|
+
Gi: 1024 ** 3,
|
|
29
|
+
Ti: 1024 ** 4,
|
|
30
|
+
Pi: 1024 ** 5,
|
|
31
|
+
Ei: 1024 ** 6,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseQuantity(raw: unknown): number | undefined {
|
|
35
|
+
if (typeof raw !== "string") return undefined
|
|
36
|
+
const match = QUANTITY_RE.exec(raw.trim())
|
|
37
|
+
if (!match) return undefined
|
|
38
|
+
const mantissa = Number.parseFloat(match[1] ?? "")
|
|
39
|
+
if (!Number.isFinite(mantissa)) return undefined
|
|
40
|
+
const suffix = match[2]
|
|
41
|
+
if (suffix === undefined) return mantissa
|
|
42
|
+
if (/^[eE][+-]?[0-9]+$/.test(suffix)) {
|
|
43
|
+
return mantissa * 10 ** Number.parseInt(suffix.slice(1), 10)
|
|
44
|
+
}
|
|
45
|
+
return mantissa * (BINARY_SUFFIX[suffix] ?? DECIMAL_SUFFIX[suffix] ?? 1)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** "200m" → 200, "1.5" → 1500. Undefined for absent/invalid. */
|
|
49
|
+
export function cpuQuantityToMillicores(raw: unknown): number | undefined {
|
|
50
|
+
const cores = parseQuantity(raw)
|
|
51
|
+
return cores === undefined ? undefined : Math.round(cores * 1000)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** "512Mi" → 536870912, "1G" → 1000000000. Undefined for absent/invalid. */
|
|
55
|
+
export function byteQuantityToBytes(raw: unknown): number | undefined {
|
|
56
|
+
const bytes = parseQuantity(raw)
|
|
57
|
+
return bytes === undefined ? undefined : Math.round(bytes)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 1000 → "1", 1500 → "1.5", 250 → "250m", undefined → "-". */
|
|
61
|
+
export function formatMillicores(millicores: number | undefined): string {
|
|
62
|
+
if (millicores === undefined) return "-"
|
|
63
|
+
if (millicores < 1000) return `${millicores}m`
|
|
64
|
+
const cores = millicores / 1000
|
|
65
|
+
return Number.isInteger(cores) ? String(cores) : cores.toFixed(2).replace(/0+$/, "").replace(/\.$/, "")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 2147483648 → "2Gi", 536870912 → "512Mi", undefined → "-". */
|
|
69
|
+
export function formatBytes(bytes: number | undefined): string {
|
|
70
|
+
if (bytes === undefined) return "-"
|
|
71
|
+
const GI = 1024 ** 3
|
|
72
|
+
const MI = 1024 ** 2
|
|
73
|
+
if (bytes >= GI) return formatUnit(bytes / GI, "Gi")
|
|
74
|
+
if (bytes >= MI) return formatUnit(bytes / MI, "Mi")
|
|
75
|
+
if (bytes >= 1024) return formatUnit(bytes / 1024, "Ki")
|
|
76
|
+
return `${bytes}B`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatUnit(value: number, suffix: string): string {
|
|
80
|
+
return `${Number.isInteger(value) ? value : value.toFixed(1)}${suffix}`
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Relative age for table columns: "45s", "12m", "3h", "5d". */
|
|
84
|
+
export function formatAge(since: Date, now: Date = new Date()): string {
|
|
85
|
+
const seconds = Math.max(0, Math.floor((now.getTime() - since.getTime()) / 1000))
|
|
86
|
+
if (seconds < 60) return `${seconds}s`
|
|
87
|
+
const minutes = Math.floor(seconds / 60)
|
|
88
|
+
if (minutes < 60) return `${minutes}m`
|
|
89
|
+
const hours = Math.floor(minutes / 60)
|
|
90
|
+
if (hours < 48) return `${hours}h`
|
|
91
|
+
return `${Math.floor(hours / 24)}d`
|
|
92
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side workspace templates — PLACEHOLDER.
|
|
3
|
+
*
|
|
4
|
+
* Templates are created and stored in the API (they are not local files).
|
|
5
|
+
* The API contract is being finalised server-side; until it lands,
|
|
6
|
+
* `workspace create --template <name>` is accepted at the CLI and fails here
|
|
7
|
+
* with a graceful, actionable error instead of a 4xx surprise.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class TemplatesUnavailableError extends Error {
|
|
11
|
+
constructor(templateName: string) {
|
|
12
|
+
super(
|
|
13
|
+
`Workspace templates are not available yet (requested "${templateName}") — ` +
|
|
14
|
+
`the templates API is being finalised. Omit --template for now.`,
|
|
15
|
+
)
|
|
16
|
+
this.name = "TemplatesUnavailableError"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve a template name to its create-time parameters.
|
|
22
|
+
*
|
|
23
|
+
* TODO(templates-api): replace with the real call once the contract lands —
|
|
24
|
+
* expected to return resources/dependencies applied at create time.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveTemplate(templateName: string): never {
|
|
27
|
+
throw new TemplatesUnavailableError(templateName)
|
|
28
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { checkResponse, fetchWithTimeout, resolveEndpoint } from "./http.js"
|
|
2
|
+
import type { ApiOptions } from "./types.js"
|
|
3
|
+
import { RemoteNetworkError } from "./types.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Workspace token exchange + lifecycle RPCs (ports of the corresponding
|
|
7
|
+
* kimchi-dev:src/sandbox/cloud/auth.ts functions, without the harness's
|
|
8
|
+
* upsert semantics — kimchictl only resumes, never upserts).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Exchange the API key for a short-lived workspace JWT (WebSocket auth). */
|
|
12
|
+
export async function exchangeWorkspaceToken(
|
|
13
|
+
apiKey: string,
|
|
14
|
+
workspaceId: string,
|
|
15
|
+
options?: ApiOptions & { orgId?: string },
|
|
16
|
+
): Promise<{ token: string; expireTime: string }> {
|
|
17
|
+
const endpoint = resolveEndpoint(options)
|
|
18
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch
|
|
19
|
+
|
|
20
|
+
const url = `${endpoint}/ai-optimizer/v1beta/workspace-tokens:exchange`
|
|
21
|
+
const resp = await fetchWithTimeout(
|
|
22
|
+
url,
|
|
23
|
+
{
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
26
|
+
body: JSON.stringify({ workspaceId }),
|
|
27
|
+
},
|
|
28
|
+
fetchImpl,
|
|
29
|
+
30_000,
|
|
30
|
+
options?.signal,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
await checkResponse(resp, url)
|
|
34
|
+
|
|
35
|
+
const data = (await resp.json().catch(() => undefined)) as { token?: unknown; expireTime?: unknown } | undefined
|
|
36
|
+
if (typeof data?.token !== "string") {
|
|
37
|
+
throw new RemoteNetworkError(`Missing token in exchange response from ${endpoint}`)
|
|
38
|
+
}
|
|
39
|
+
return { token: data.token, expireTime: typeof data.expireTime === "string" ? data.expireTime : "" }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resume a suspended (hibernated) workspace: POST …/workspaces/{id}:resume.
|
|
44
|
+
*
|
|
45
|
+
* Success and "workspace is not suspended" (the control plane's FailedPrecondition
|
|
46
|
+
* 400) both mean the workspace is running. Every other failure propagates.
|
|
47
|
+
*/
|
|
48
|
+
export async function resumeWorkspace(
|
|
49
|
+
apiKey: string,
|
|
50
|
+
workspaceId: string,
|
|
51
|
+
options: ApiOptions & { orgId: string },
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const endpoint = resolveEndpoint(options)
|
|
54
|
+
const fetchImpl = options?.fetch ?? globalThis.fetch
|
|
55
|
+
|
|
56
|
+
const url = `${endpoint}/ai-optimizer/v1beta/organizations/${encodeURIComponent(options.orgId)}/workspaces/${encodeURIComponent(workspaceId)}:resume`
|
|
57
|
+
const resp = await fetchWithTimeout(
|
|
58
|
+
url,
|
|
59
|
+
{
|
|
60
|
+
method: "POST",
|
|
61
|
+
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
|
62
|
+
body: JSON.stringify({}),
|
|
63
|
+
},
|
|
64
|
+
fetchImpl,
|
|
65
|
+
30_000,
|
|
66
|
+
options?.signal,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if (resp.ok) return
|
|
70
|
+
|
|
71
|
+
// Clone BEFORE consuming the body: checkResponse re-reads resp.text() and a
|
|
72
|
+
// second read of the same body fails in undici.
|
|
73
|
+
const rest = resp.clone()
|
|
74
|
+
const body = await resp.text().catch(() => "")
|
|
75
|
+
if (resp.status >= 400 && resp.status < 500 && body.includes("not suspended")) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await checkResponse(rest, url)
|
|
80
|
+
}
|
package/src/api/types.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Control-plane client types — mirror of the harness's
|
|
3
|
+
* kimchi-dev:src/sandbox/cloud types (trimmed to what kimchictl v1 needs).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class RemoteAuthError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
public readonly statusCode: number,
|
|
10
|
+
) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = "RemoteAuthError"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class RemoteNetworkError extends Error {
|
|
17
|
+
constructor(message: string) {
|
|
18
|
+
super(message)
|
|
19
|
+
this.name = "RemoteNetworkError"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Thrown when the control plane rejects a request because the user's
|
|
25
|
+
* resource quota is exhausted (HTTP 429 with a quota body). The message is
|
|
26
|
+
* already user-facing — surface verbatim.
|
|
27
|
+
*/
|
|
28
|
+
export class RemoteQuotaError extends RemoteNetworkError {
|
|
29
|
+
constructor(
|
|
30
|
+
message: string,
|
|
31
|
+
public readonly statusCode: number,
|
|
32
|
+
) {
|
|
33
|
+
super(message)
|
|
34
|
+
this.name = "RemoteQuotaError"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ApiOptions {
|
|
39
|
+
/**
|
|
40
|
+
* Override the cloud API endpoint. Resolution order:
|
|
41
|
+
* 1. this option
|
|
42
|
+
* 2. KIMCHI_REMOTE_ENDPOINT env var
|
|
43
|
+
* 3. production default https://app.kimchi.dev/api
|
|
44
|
+
*/
|
|
45
|
+
endpoint?: string
|
|
46
|
+
/** Override global fetch (tests). */
|
|
47
|
+
fetch?: typeof globalThis.fetch
|
|
48
|
+
/** External abort signal. */
|
|
49
|
+
signal?: AbortSignal
|
|
50
|
+
}
|
package/src/api/wait.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ApiOptions } from "./types.js"
|
|
2
|
+
import { RemoteAuthError } from "./types.js"
|
|
3
|
+
import { getWorkspace, type Workspace } from "./workspaces.js"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Poll getWorkspace until the workspace is ACTIVE or the deadline passes.
|
|
7
|
+
* Shared by `workspace create` (post-create wait) and `ssh` (post-resume
|
|
8
|
+
* wait) — kap's 2s cadence, deadline-based, progress via onTick.
|
|
9
|
+
*
|
|
10
|
+
* Transient get failures (the workspace is still registering with the
|
|
11
|
+
* control plane) are tolerated until the deadline; auth failures abort.
|
|
12
|
+
*/
|
|
13
|
+
export async function waitForWorkspaceActive(
|
|
14
|
+
apiKey: string,
|
|
15
|
+
id: string,
|
|
16
|
+
options: ApiOptions & {
|
|
17
|
+
timeoutMs: number
|
|
18
|
+
pollMs?: number
|
|
19
|
+
sleep?: (ms: number) => Promise<void>
|
|
20
|
+
onTick?: (elapsedSeconds: number) => void
|
|
21
|
+
},
|
|
22
|
+
): Promise<Workspace> {
|
|
23
|
+
const pollMs = options.pollMs ?? 2000
|
|
24
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
25
|
+
|
|
26
|
+
const start = Date.now()
|
|
27
|
+
const deadline = start + options.timeoutMs
|
|
28
|
+
let current: Workspace | undefined
|
|
29
|
+
while (Date.now() < deadline) {
|
|
30
|
+
await sleep(pollMs)
|
|
31
|
+
options.onTick?.(Math.round((Date.now() - start) / 1000))
|
|
32
|
+
try {
|
|
33
|
+
current = await getWorkspace(apiKey, id, options)
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof RemoteAuthError) throw err
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
if (current.status === "active") break
|
|
39
|
+
}
|
|
40
|
+
if (!current) {
|
|
41
|
+
// Every poll failed; fetch once without tolerance so the real error surfaces.
|
|
42
|
+
current = await getWorkspace(apiKey, id, options)
|
|
43
|
+
}
|
|
44
|
+
return current
|
|
45
|
+
}
|