@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,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
+ }
@@ -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
+ }
@@ -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
+ }