@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,360 @@
1
+ import { writeFileSync } from "node:fs"
2
+ import { join } from "node:path"
3
+ import { afterEach, describe, expect, it, vi } from "vitest"
4
+ import {
5
+ captureConsole,
6
+ cleanupTempDirs,
7
+ jsonResponse,
8
+ makeTempDir,
9
+ stubAgentDirEnv,
10
+ stubFetch,
11
+ } from "../test-support.js"
12
+ import { guardCommand } from "./guard.js"
13
+ import { runWorkspace, type WorkspaceDeps } from "./workspace.js"
14
+
15
+ const guardedWorkspace = guardCommand(runWorkspace)
16
+
17
+ afterEach(() => {
18
+ cleanupTempDirs()
19
+ vi.unstubAllEnvs()
20
+ vi.restoreAllMocks()
21
+ })
22
+
23
+ // ---------------------------------------------------------------- fixtures
24
+
25
+ interface WorkspaceOverrides extends Record<string, unknown> {
26
+ id?: string
27
+ alias?: string
28
+ }
29
+
30
+ function wsJson(over: WorkspaceOverrides = {}): Record<string, unknown> {
31
+ const alias = over.alias ?? "bright-oak-otter"
32
+ return {
33
+ id: over.id ?? "11111111-2222-3333-4444-555555555555",
34
+ description: "api work",
35
+ status: "ACTIVE",
36
+ createTime: "2026-01-01T00:00:00Z",
37
+ uri: `${alias}.remote.kimchi.dev`,
38
+ spec: { resources: { cpu: "2", memory: "4Gi", pvcSize: "20Gi" } },
39
+ ...over,
40
+ }
41
+ }
42
+
43
+ interface Route {
44
+ method: string
45
+ match: RegExp
46
+ respond: (call: number) => Response
47
+ /** Number of times this route must be hit at minimum (0 = optional). */
48
+ minCalls?: number
49
+ }
50
+
51
+ /**
52
+ * Fetch stub routing on method+path. verifyKey is always answered with the
53
+ * fixed org — the commands start every flow by resolving the org id.
54
+ */
55
+ function apiFetch(routes: Route[]): typeof globalThis.fetch {
56
+ const counts = new Map<Route, number>()
57
+ return stubFetch(async (url, init) => {
58
+ const method = init?.method ?? "GET"
59
+ if (method === "POST" && url.endsWith("workspace-tokens:verifyKey")) {
60
+ return jsonResponse({ organizationId: "org-1" })
61
+ }
62
+ for (const route of routes) {
63
+ if (route.method === method && route.match.test(url)) {
64
+ counts.set(route, (counts.get(route) ?? 0) + 1)
65
+ return route.respond(counts.get(route) ?? 1)
66
+ }
67
+ }
68
+ throw new Error(`unexpected request: ${method} ${url}`)
69
+ })
70
+ }
71
+
72
+ async function run(
73
+ args: string[],
74
+ fetch: typeof globalThis.fetch,
75
+ deps: WorkspaceDeps = {},
76
+ ): Promise<{ code: number; lines: string[]; errors: string[] }> {
77
+ const dir = makeTempDir()
78
+ stubAgentDirEnv(dir)
79
+ writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "key" } }))
80
+ const { lines, errors } = captureConsole()
81
+ const code = await guardedWorkspace(args, { fetch, ...deps })
82
+ return { code, lines, errors }
83
+ }
84
+
85
+ // ---------------------------------------------------------------- group dispatch
86
+
87
+ describe("kimchictl workspace (group dispatch)", () => {
88
+ it("prints group usage on help / missing verb", async () => {
89
+ const { code, lines } = await run(["help"], apiFetch([]))
90
+ expect(code).toBe(0)
91
+ expect(lines[0]).toContain("kimchictl workspace <verb>")
92
+ })
93
+
94
+ it("rejects unknown verbs with exit 2", async () => {
95
+ const { code, errors } = await run(["frobnicate"], apiFetch([]))
96
+ expect(code).toBe(2)
97
+ expect(errors[0]).toBe('kimchictl workspace: unknown verb "frobnicate"')
98
+ })
99
+
100
+ it("mounts the hidden ws alias", async () => {
101
+ const dir = makeTempDir()
102
+ stubAgentDirEnv(dir)
103
+ writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "key" } }))
104
+ captureConsole()
105
+ const code = await guardedWorkspace(["help"])
106
+
107
+ expect(code).toBe(0)
108
+ })
109
+ })
110
+
111
+ // ---------------------------------------------------------------- create
112
+
113
+ describe("kimchictl workspace create", () => {
114
+ it("creates, waits for ACTIVE, and prints the alias on stdout", async () => {
115
+ const fetch = apiFetch([
116
+ {
117
+ method: "POST",
118
+ match: /\/organizations\/org-1\/workspaces$/,
119
+ respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })),
120
+ },
121
+ {
122
+ method: "GET",
123
+ match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
124
+ respond: (call) => jsonResponse(wsJson({ status: call >= 2 ? "ACTIVE" : "INITIALIZING" })),
125
+ },
126
+ ])
127
+
128
+ const { code, lines, errors } = await run(["create", "--desc", "api work"], fetch, { sleep: async () => {} })
129
+
130
+ expect(code).toBe(0)
131
+ expect(lines).toEqual(["bright-oak-otter"])
132
+ expect(errors[0]).toBe("✓ created bright-oak-otter (initializing)")
133
+ expect(errors.some((l) => l.includes("uri bright-oak-otter.remote.kimchi.dev"))).toBe(true)
134
+ })
135
+
136
+ it("--no-wait returns immediately after create", async () => {
137
+ const fetch = apiFetch([
138
+ {
139
+ method: "POST",
140
+ match: /\/organizations\/org-1\/workspaces$/,
141
+ respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })),
142
+ },
143
+ ])
144
+
145
+ const { code, lines } = await run(["create", "--no-wait"], fetch)
146
+
147
+ expect(code).toBe(0)
148
+ expect(lines).toEqual(["bright-oak-otter"])
149
+ })
150
+
151
+ it("warns when the workspace is not ACTIVE at the deadline", async () => {
152
+ const fetch = apiFetch([
153
+ { method: "POST", match: /workspaces$/, respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })) },
154
+ {
155
+ method: "GET",
156
+ match: /11111111-2222-3333-4444-555555555555$/,
157
+ respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })),
158
+ },
159
+ ])
160
+
161
+ // deadline 1s, sleep override advances nothing — the loop exits when Date.now() passes the deadline…
162
+ // …so use --timeout 1 with a real timer of 0 polls by making sleep a no-op that burns real time? No:
163
+ // sleep is mocked to resolve instantly, and Date.now() stays under the deadline — the loop would spin
164
+ // forever. Instead, advance the clock via the sleep mock.
165
+ let now = Date.now()
166
+ const realNow = Date.now
167
+ vi.spyOn(Date, "now").mockImplementation(() => now)
168
+
169
+ const { code, errors } = await run(["create", "--timeout", "1"], fetch, {
170
+ sleep: async () => {
171
+ now += 2000
172
+ },
173
+ })
174
+ vi.spyOn(Date, "now").mockImplementation(realNow)
175
+
176
+ expect(code).toBe(0)
177
+ expect(errors.some((l) => l.includes("still initializing"))).toBe(true)
178
+ })
179
+
180
+ it("--template errors gracefully until the templates API lands", async () => {
181
+ const { code, errors } = await run(["create", "--template", "node"], apiFetch([]))
182
+
183
+ expect(code).toBe(1)
184
+ expect(errors[0]).toContain('templates are not available yet (requested "node")')
185
+ })
186
+
187
+ it("rejects positional arguments and bad timeouts as usage errors", async () => {
188
+ for (const args of [
189
+ ["create", "myname"],
190
+ ["create", "--timeout", "abc"],
191
+ ["create", "--timeout", "0"],
192
+ ]) {
193
+ const { code } = await run(args, apiFetch([]))
194
+ expect(code).toBe(2)
195
+ }
196
+ })
197
+ })
198
+
199
+ // ---------------------------------------------------------------- list
200
+
201
+ describe("kimchictl workspace list", () => {
202
+ it("renders the table", async () => {
203
+ const fetch = apiFetch([
204
+ {
205
+ method: "GET",
206
+ match: /\?page\.limit=/,
207
+ respond: () =>
208
+ jsonResponse({
209
+ items: [
210
+ wsJson(),
211
+ wsJson({
212
+ alias: "demo",
213
+ description: "",
214
+ status: "SUSPENDED",
215
+ createTime: "2025-12-30T00:00:00Z",
216
+ spec: undefined,
217
+ }),
218
+ ],
219
+ }),
220
+ },
221
+ ])
222
+
223
+ const { code, lines } = await run(["list"], fetch, { now: () => new Date("2026-01-01T03:00:00Z") })
224
+
225
+ expect(code).toBe(0)
226
+ // formatTable prints the whole table as one console call.
227
+ const table = lines[0]?.split("\n") ?? []
228
+ expect(table[0]).toBe("NAME STATUS CPU MEMORY AGE")
229
+ expect(table[1]).toMatch(/^bright-oak-otter\s+active\s+2\s+4Gi\s+3h$/)
230
+ expect(table[2]).toMatch(/^demo\s+suspended\s+-\s+-\s+2d$/)
231
+ expect(table).toHaveLength(3)
232
+ })
233
+
234
+ it("renders json output and the empty table", async () => {
235
+ const fetch = apiFetch([{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [] }) }])
236
+
237
+ const jsonRun = await run(["list", "--output", "json"], fetch)
238
+ expect(JSON.parse(jsonRun.lines.join(""))).toEqual([])
239
+
240
+ const tableRun = await run(["list"], fetch)
241
+ expect(tableRun.lines).toEqual(["NAME STATUS CPU MEMORY AGE"])
242
+ })
243
+
244
+ it("rejects an unsupported output flavor", async () => {
245
+ const { code } = await run(["list", "--output", "yaml"], apiFetch([]))
246
+ expect(code).toBe(2)
247
+ })
248
+ })
249
+
250
+ // ---------------------------------------------------------------- get
251
+
252
+ describe("kimchictl workspace get", () => {
253
+ it("renders the detail view", async () => {
254
+ const fetch = apiFetch([
255
+ { method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
256
+ ])
257
+
258
+ const { code, lines } = await run(["get", "bright-oak-otter"], fetch)
259
+
260
+ expect(code).toBe(0)
261
+ expect(lines).toEqual([
262
+ "name: bright-oak-otter",
263
+ "description: api work",
264
+ "status: active",
265
+ "uri: bright-oak-otter.remote.kimchi.dev",
266
+ "created: 2026-01-01T00:00:00.000Z",
267
+ "cpu: 2",
268
+ "memory: 4Gi",
269
+ "storage: 20Gi",
270
+ ])
271
+ })
272
+
273
+ it("reports a missing workspace via the guard", async () => {
274
+ const fetch = apiFetch([
275
+ { method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
276
+ ])
277
+
278
+ const { code, errors } = await run(["get", "ghost"], fetch)
279
+
280
+ expect(code).toBe(1)
281
+ expect(errors[0]).toMatch(/No workspace matches "ghost"/)
282
+ })
283
+
284
+ it("resolves a UUID ref without listing", async () => {
285
+ const fetch = apiFetch([
286
+ {
287
+ method: "GET",
288
+ match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
289
+ respond: () => jsonResponse(wsJson()),
290
+ },
291
+ ])
292
+
293
+ const { code, lines } = await run(["get", "11111111-2222-3333-4444-555555555555"], fetch)
294
+
295
+ expect(code).toBe(0)
296
+ expect(lines[0]).toBe("name: bright-oak-otter")
297
+ })
298
+
299
+ it("requires exactly one name", async () => {
300
+ expect((await run(["get"], apiFetch([]))).code).toBe(2)
301
+ expect((await run(["get", "a", "b"], apiFetch([]))).code).toBe(2)
302
+ })
303
+ })
304
+
305
+ // ---------------------------------------------------------------- delete
306
+
307
+ describe("kimchictl workspace delete", () => {
308
+ it("deletes after an affirmative confirmation", async () => {
309
+ const fetch = apiFetch([
310
+ { method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
311
+ {
312
+ method: "DELETE",
313
+ match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
314
+ respond: () => new Response(null, { status: 200 }),
315
+ },
316
+ ])
317
+
318
+ const { code, lines } = await run(["delete", "bright-oak-otter"], fetch, { confirm: async () => true })
319
+
320
+ expect(code).toBe(0)
321
+ expect(lines).toEqual(["✓ deleted bright-oak-otter"])
322
+ })
323
+
324
+ it("--force deletes without prompting", async () => {
325
+ const fetch = apiFetch([
326
+ { method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
327
+ {
328
+ method: "DELETE",
329
+ match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
330
+ respond: () => new Response(null, { status: 200 }),
331
+ },
332
+ ])
333
+
334
+ const { code } = await run(["delete", "--force", "bright-oak-otter"], fetch)
335
+ expect(code).toBe(0)
336
+ })
337
+
338
+ it("declined confirmation deletes nothing (no DELETE request)", async () => {
339
+ // Resolution still runs (it names the target in the prompt) — only the
340
+ // DELETE must not happen: any DELETE route would throw "unexpected request".
341
+ const fetch = apiFetch([
342
+ { method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
343
+ ])
344
+
345
+ const { code, lines } = await run(["delete", "bright-oak-otter"], fetch, { confirm: async () => false })
346
+
347
+ expect(code).toBe(0)
348
+ expect(lines).toEqual(["Aborted."])
349
+ })
350
+
351
+ it("requires --force when non-interactive", async () => {
352
+ const fetch = apiFetch([
353
+ { method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
354
+ ])
355
+ const { code, errors } = await run(["delete", "bright-oak-otter"], fetch, { interactive: false })
356
+
357
+ expect(code).toBe(1)
358
+ expect(errors.some((l) => l.includes("--force"))).toBe(true)
359
+ })
360
+ })
@@ -0,0 +1,250 @@
1
+ import { resolveWorkspace } from "../api/resolver.js"
2
+ import { formatAge, formatBytes, formatMillicores } from "../api/resources.js"
3
+ import { resolveTemplate } from "../api/templates.js"
4
+ import { waitForWorkspaceActive } from "../api/wait.js"
5
+ import { createWorkspace, deleteWorkspace, listWorkspaces, type Workspace } from "../api/workspaces.js"
6
+ import { requireApiKey } from "../auth/resolve.js"
7
+ import { parseFlags, UsageError } from "./flags.js"
8
+ import { confirm, isInteractive } from "./prompt.js"
9
+ import { formatTable } from "./table.js"
10
+
11
+ export interface WorkspaceDeps {
12
+ fetch?: typeof globalThis.fetch
13
+ env?: NodeJS.ProcessEnv
14
+ /** Override TTY interactivity detection (tests). */
15
+ interactive?: boolean
16
+ /** Confirmation override (tests). */
17
+ confirm?: (question: string) => Promise<boolean>
18
+ /** Clock override for deterministic AGE columns (tests). */
19
+ now?: () => Date
20
+ /** Sleep override for the create wait loop (tests). */
21
+ sleep?: (ms: number) => Promise<void>
22
+ }
23
+
24
+ const GROUP_USAGE = `Usage:
25
+ kimchictl workspace <verb> [options]
26
+
27
+ Verbs:
28
+ create Create a workspace (server generates the alias)
29
+ list List workspaces (alias: ls)
30
+ get Show workspace details
31
+ delete Delete a workspace (alias: rm)
32
+
33
+ Options:
34
+ --help Show this help`
35
+
36
+ /** `kimchictl workspace <verb> …` — group command; also mounted as hidden alias `ws`. */
37
+ export async function runWorkspace(args: string[], deps: WorkspaceDeps = {}): Promise<number> {
38
+ const [verb, ...rest] = args
39
+ switch (verb) {
40
+ case "create":
41
+ return runCreate(rest, deps)
42
+ case "list":
43
+ case "ls":
44
+ return runList(rest, deps)
45
+ case "get":
46
+ return runGet(rest, deps)
47
+ case "delete":
48
+ case "rm":
49
+ return runDelete(rest, deps)
50
+ case "help":
51
+ case "--help":
52
+ case "-h":
53
+ case undefined:
54
+ console.log(GROUP_USAGE)
55
+ return verb === undefined ? 2 : 0
56
+ default:
57
+ console.error(`kimchictl workspace: unknown verb "${verb}"`)
58
+ console.error(GROUP_USAGE)
59
+ return 2
60
+ }
61
+ }
62
+
63
+ // ---------------------------------------------------------------- create
64
+
65
+ async function runCreate(args: string[], deps: WorkspaceDeps): Promise<number> {
66
+ const { values, positionals } = parseFlags("workspace create", args, {
67
+ desc: { type: "string" },
68
+ template: { type: "string", short: "t" },
69
+ "no-wait": { type: "boolean" },
70
+ timeout: { type: "string", default: "60" },
71
+ })
72
+ if (positionals.length > 0) {
73
+ throw new UsageError(
74
+ "kimchictl workspace create: unexpected positional argument (workspaces are named by the server)",
75
+ )
76
+ }
77
+
78
+ const template = values.template
79
+ if (typeof template === "string" && template.length > 0) {
80
+ // Placeholder: errors gracefully until the templates API contract lands.
81
+ resolveTemplate(template)
82
+ }
83
+ if (values.template !== undefined && (typeof values.template !== "string" || values.template.length === 0)) {
84
+ throw new UsageError("kimchictl workspace create: --template must not be empty")
85
+ }
86
+
87
+ const timeoutSeconds = parseTimeoutSeconds(values.timeout, "workspace create")
88
+ const desc = typeof values.desc === "string" && values.desc.length > 0 ? values.desc : undefined
89
+
90
+ const { key } = requireApiKey(deps.env)
91
+ const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
92
+
93
+ const created = await createWorkspace(key, { description: desc, fetch: deps.fetch })
94
+ console.error(`✓ created ${created.alias} (${created.status})`)
95
+
96
+ let current = created
97
+ if (!values["no-wait"]) {
98
+ // kap's create poll: 2s cadence, deadline, progress on stderr.
99
+ current = await waitForWorkspaceActive(key, created.id, {
100
+ fetch: deps.fetch,
101
+ timeoutMs: timeoutSeconds * 1000,
102
+ sleep,
103
+ onTick: (elapsedSeconds) => process.stderr.write(`\r waiting… ${elapsedSeconds}s`),
104
+ })
105
+ process.stderr.write("\n")
106
+ if (current.status !== "active") {
107
+ console.error(` ⚠ workspace still initializing — check with: kimchictl workspace get ${created.alias}`)
108
+ }
109
+ }
110
+
111
+ if (current.uri) console.error(` uri ${current.uri}`)
112
+ // The alias goes to stdout last so scripts can capture it: ALIAS=$(kimchictl workspace create)
113
+ console.log(current.alias)
114
+ return 0
115
+ }
116
+
117
+ function parseTimeoutSeconds(raw: unknown, command: string): number {
118
+ const seconds = typeof raw === "string" ? Number(raw) : Number.NaN
119
+ if (!Number.isInteger(seconds) || seconds <= 0) {
120
+ throw new UsageError(`kimchictl ${command}: --timeout must be a positive number of seconds`)
121
+ }
122
+ return seconds
123
+ }
124
+
125
+ // ---------------------------------------------------------------- list
126
+
127
+ async function runList(args: string[], deps: WorkspaceDeps): Promise<number> {
128
+ const { values, positionals } = parseFlags("workspace list", args, {
129
+ output: { type: "string", short: "o", default: "table" },
130
+ })
131
+ if (positionals.length > 0) {
132
+ throw new UsageError("kimchictl workspace list: unexpected positional argument")
133
+ }
134
+ if (values.output !== "json" && values.output !== "table") {
135
+ // Validate before touching the network — usage errors fail fast and offline.
136
+ throw new UsageError(
137
+ `kimchictl workspace list: unsupported --output "${String(values.output)}" (expected table|json)`,
138
+ )
139
+ }
140
+
141
+ const { key } = requireApiKey(deps.env)
142
+ const workspaces = await listWorkspaces(key, { fetch: deps.fetch })
143
+
144
+ if (values.output === "json") {
145
+ console.log(JSON.stringify(workspaces.map(serializeWorkspace), null, 2))
146
+ return 0
147
+ }
148
+
149
+ const now = deps.now?.() ?? new Date()
150
+ const rows = workspaces.map((w) => [
151
+ w.alias,
152
+ w.status,
153
+ formatMillicores(w.cpuMillicores),
154
+ formatBytes(w.ramBytes),
155
+ formatAge(w.createdAt, now),
156
+ ])
157
+ console.log(formatTable(["NAME", "STATUS", "CPU", "MEMORY", "AGE"], rows))
158
+ return 0
159
+ }
160
+
161
+ // ---------------------------------------------------------------- get
162
+
163
+ async function runGet(args: string[], deps: WorkspaceDeps): Promise<number> {
164
+ const { values, positionals } = parseFlags("workspace get", args, {
165
+ output: { type: "string", short: "o", default: "text" },
166
+ })
167
+ if (positionals.length !== 1) {
168
+ throw new UsageError("kimchictl workspace get: expected exactly one workspace name")
169
+ }
170
+ if (values.output !== "json" && values.output !== "text") {
171
+ throw new UsageError(
172
+ `kimchictl workspace get: unsupported --output "${String(values.output)}" (expected text|json)`,
173
+ )
174
+ }
175
+
176
+ const { key } = requireApiKey(deps.env)
177
+ // Accepts a UUID, full alias, or alias prefix (see api/resolver.ts).
178
+ const workspace = await resolveWorkspace(key, positionals[0] ?? "", { fetch: deps.fetch })
179
+
180
+ if (values.output === "json") {
181
+ console.log(JSON.stringify(serializeWorkspace(workspace), null, 2))
182
+ return 0
183
+ }
184
+
185
+ const lines = [
186
+ `name: ${workspace.alias}`,
187
+ workspace.description ? `description: ${workspace.description}` : undefined,
188
+ `status: ${workspace.status}`,
189
+ workspace.uri ? `uri: ${workspace.uri}` : undefined,
190
+ `created: ${workspace.createdAt.toISOString()}`,
191
+ `cpu: ${formatMillicores(workspace.cpuMillicores)}`,
192
+ `memory: ${formatBytes(workspace.ramBytes)}`,
193
+ `storage: ${formatBytes(workspace.pvcSizeBytes)}`,
194
+ ].filter((line): line is string => line !== undefined)
195
+ for (const line of lines) console.log(line)
196
+ return 0
197
+ }
198
+
199
+ // ---------------------------------------------------------------- delete
200
+
201
+ async function runDelete(args: string[], deps: WorkspaceDeps): Promise<number> {
202
+ const { values, positionals } = parseFlags("workspace delete", args, {
203
+ force: { type: "boolean", short: "f" },
204
+ })
205
+ if (positionals.length !== 1) {
206
+ throw new UsageError("kimchictl workspace delete: expected exactly one workspace name")
207
+ }
208
+ const ref = positionals[0] ?? ""
209
+
210
+ const { key } = requireApiKey(deps.env)
211
+ // Resolve before prompting so the confirmation names the real target and
212
+ // typos fail fast instead of after the user answered the prompt.
213
+ const workspace = await resolveWorkspace(key, ref, { fetch: deps.fetch })
214
+
215
+ if (!values.force) {
216
+ const interactive = deps.interactive ?? isInteractive()
217
+ const question = `Delete workspace "${workspace.alias}"? [y/N] `
218
+ const ok = await (deps.confirm ? deps.confirm(question) : interactive ? confirm(question) : false)
219
+ if (!ok) {
220
+ if (!deps.confirm && !interactive) {
221
+ console.error(`kimchictl workspace delete: not interactive — re-run with --force to delete ${workspace.alias}`)
222
+ return 1
223
+ }
224
+ console.log("Aborted.")
225
+ return 0
226
+ }
227
+ }
228
+
229
+ await deleteWorkspace(key, workspace.id, { fetch: deps.fetch })
230
+ console.log(`✓ deleted ${workspace.alias}`)
231
+ return 0
232
+ }
233
+
234
+ // ---------------------------------------------------------------- shared
235
+
236
+ function serializeWorkspace(w: Workspace): Record<string, unknown> {
237
+ return {
238
+ id: w.id,
239
+ alias: w.alias,
240
+ ...(w.description ? { description: w.description } : {}),
241
+ status: w.status,
242
+ ...(w.uri ? { uri: w.uri } : {}),
243
+ createdAt: w.createdAt.getTime() > 0 ? w.createdAt.toISOString() : undefined,
244
+ resources: {
245
+ cpuMillicores: w.cpuMillicores,
246
+ ramBytes: w.ramBytes,
247
+ pvcSizeBytes: w.pvcSizeBytes,
248
+ },
249
+ }
250
+ }
@@ -0,0 +1,53 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
2
+ import { run } from "./run.js"
3
+ import { VERSION } from "./version.js"
4
+
5
+ describe("run dispatcher", () => {
6
+ let logLines: string[]
7
+ let errorLines: string[]
8
+
9
+ beforeEach(() => {
10
+ logLines = []
11
+ errorLines = []
12
+ vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
13
+ logLines.push(args.map(String).join(" "))
14
+ })
15
+ vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
16
+ errorLines.push(args.map(String).join(" "))
17
+ })
18
+ })
19
+
20
+ afterEach(() => {
21
+ vi.restoreAllMocks()
22
+ })
23
+
24
+ it("prints help and exits 0 with no args", async () => {
25
+ expect(await run([])).toBe(0)
26
+ expect(logLines.some((line) => line.includes("Usage:"))).toBe(true)
27
+ })
28
+
29
+ it("prints help for `help`, --help and -h", async () => {
30
+ for (const args of [["help"], ["--help"], ["-h"]]) {
31
+ expect(await run(args)).toBe(0)
32
+ }
33
+ expect(logLines.filter((line) => line.includes("Usage:"))).toHaveLength(3)
34
+ })
35
+
36
+ it("prints the version for the version command and --version / -v", async () => {
37
+ for (const args of [["version"], ["--version"], ["-v"]]) {
38
+ expect(await run(args)).toBe(0)
39
+ }
40
+ expect(logLines).toEqual([`kimchictl ${VERSION}`, `kimchictl ${VERSION}`, `kimchictl ${VERSION}`])
41
+ })
42
+
43
+ it("exits 2 and reports an unknown command, without printing help to stdout", async () => {
44
+ expect(await run(["frobnicate"])).toBe(2)
45
+ expect(errorLines[0]).toBe('kimchictl: unknown command "frobnicate"')
46
+ expect(logLines).toHaveLength(0)
47
+ })
48
+
49
+ it("omits hidden commands from help", async () => {
50
+ await run([])
51
+ expect(logLines.some((line) => line.includes("version"))).toBe(true)
52
+ })
53
+ })
package/src/run.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { printHelp } from "./commands/help.js"
2
+ import { COMMANDS, findCommand } from "./commands/registry.js"
3
+ import { VERSION } from "./version.js"
4
+
5
+ /** Usage errors (unknown command, bad flags) exit with this code. */
6
+ export const EXIT_USAGE = 2
7
+
8
+ /**
9
+ * Shared CLI dispatcher — the single entry behind the standalone `kimchictl`
10
+ * binary, the npm bin shim, and the kimchi harness bridge (`kimchi workspace`).
11
+ * Returns the exit code instead of calling process.exit so hosts can run it
12
+ * in-process.
13
+ */
14
+ export async function run(argv: readonly string[]): Promise<number> {
15
+ const [head, ...rest] = argv
16
+
17
+ if (head === undefined || head === "help" || head === "--help" || head === "-h") {
18
+ printHelp(COMMANDS)
19
+ return 0
20
+ }
21
+ if (head === "--version" || head === "-v") {
22
+ console.log(`kimchictl ${VERSION}`)
23
+ return 0
24
+ }
25
+
26
+ const command = findCommand(head)
27
+ if (!command) {
28
+ console.error(`kimchictl: unknown command "${head}"`)
29
+ printHelp(COMMANDS, (line) => console.error(line))
30
+ return EXIT_USAGE
31
+ }
32
+
33
+ return (await command.run(rest)) ?? 0
34
+ }