@kimchi-dev/kimchictl 0.0.6 → 0.0.8

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.
@@ -121,29 +121,89 @@ export async function getWorkspace(apiKey: string, id: string, options?: Workspa
121
121
  return mapWorkspace(data, endpoint)
122
122
  }
123
123
 
124
+ /**
125
+ * Create-time workspace parameters (mirrors the WorkspaceSpec proto).
126
+ * Set either template fields or spec fields — never both.
127
+ */
128
+ export interface CreateWorkspaceSpec {
129
+ resources?: {
130
+ /** CPU request as a k8s quantity string, e.g. "500m", "2". */
131
+ cpu?: string
132
+ /** Memory request as a k8s quantity string, e.g. "1Gi", "512Mi". */
133
+ memory?: string
134
+ /** PVC size as a k8s quantity string, e.g. "20Gi". */
135
+ pvcSize?: string
136
+ }
137
+ /** CLI tools to install at boot, e.g. ["jq", "node@22"]. */
138
+ dependencies?: string[]
139
+ egressPolicy?: {
140
+ denyByDefault?: boolean
141
+ allowed?: string[]
142
+ denied?: string[]
143
+ }
144
+ /** Shell script executed at boot after dependency installation. */
145
+ initScript?: string
146
+ }
147
+
148
+ export interface CreateWorkspaceOptions extends WorkspaceCommandOptions {
149
+ description?: string
150
+ templateName?: string
151
+ templateId?: string
152
+ spec?: CreateWorkspaceSpec
153
+ }
154
+
124
155
  /**
125
156
  * Create a workspace. Server generates the alias (like kap) — there is no
126
- * client-side name input. Returns the created workspace (status likely
127
- * "initializing"; callers may poll getWorkspace until "active").
157
+ * client-side name input. Set either template fields (templateName or
158
+ * templateId) or explicit spec parameters, never both (the API rejects
159
+ * both — proto: "conflicts with spec: set either template fields or
160
+ * explicit parameters, never both"). Returns the created workspace
161
+ * (status likely "initializing"; callers may poll getWorkspace until
162
+ * "active").
128
163
  */
129
- export async function createWorkspace(
130
- apiKey: string,
131
- options: WorkspaceCommandOptions & { description?: string },
132
- ): Promise<Workspace> {
164
+ export async function createWorkspace(apiKey: string, options: CreateWorkspaceOptions): Promise<Workspace> {
133
165
  const fetchImpl = options?.fetch ?? globalThis.fetch
134
166
  const endpoint = resolveEndpoint(options)
135
167
  const orgId = await resolveOrgId(apiKey, options)
136
168
 
169
+ // Build the request body — the Workspace message (gRPC-gateway body: "workspace").
170
+ // gRPC-gateway maps proto field names to camelCase JSON keys.
171
+ const body: Record<string, unknown> = {
172
+ clientType: HARNESS_CLIENT_TYPE,
173
+ }
174
+ if (options.description) body.description = options.description
175
+ if (options.templateName) body.templateName = options.templateName
176
+ if (options.templateId) body.templateId = options.templateId
177
+
178
+ const spec = options.spec
179
+ if (spec) {
180
+ const specBody: Record<string, unknown> = {}
181
+ if (spec.resources) {
182
+ const res: Record<string, string> = {}
183
+ if (spec.resources.cpu) res.cpu = spec.resources.cpu
184
+ if (spec.resources.memory) res.memory = spec.resources.memory
185
+ if (spec.resources.pvcSize) res.pvcSize = spec.resources.pvcSize
186
+ if (Object.keys(res).length > 0) specBody.resources = res
187
+ }
188
+ if (spec.dependencies && spec.dependencies.length > 0) specBody.dependencies = spec.dependencies
189
+ if (spec.egressPolicy) {
190
+ const eg: Record<string, unknown> = {}
191
+ if (spec.egressPolicy.denyByDefault !== undefined) eg.denyByDefault = spec.egressPolicy.denyByDefault
192
+ if (spec.egressPolicy.allowed && spec.egressPolicy.allowed.length > 0) eg.allowed = spec.egressPolicy.allowed
193
+ if (spec.egressPolicy.denied && spec.egressPolicy.denied.length > 0) eg.denied = spec.egressPolicy.denied
194
+ if (Object.keys(eg).length > 0) specBody.egressPolicy = eg
195
+ }
196
+ if (spec.initScript) specBody.initScript = spec.initScript
197
+ if (Object.keys(specBody).length > 0) body.spec = specBody
198
+ }
199
+
137
200
  const url = `${endpoint}/ai-optimizer/v1beta/organizations/${encodeURIComponent(orgId)}/workspaces`
138
201
  const resp = await fetchWithTimeout(
139
202
  url,
140
203
  {
141
204
  method: "POST",
142
205
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
143
- body: JSON.stringify({
144
- ...(options.description ? { description: options.description } : {}),
145
- clientType: HARNESS_CLIENT_TYPE,
146
- }),
206
+ body: JSON.stringify(body),
147
207
  },
148
208
  fetchImpl,
149
209
  30_000,
@@ -8,7 +8,7 @@ export class UsageError extends Error {
8
8
  }
9
9
  }
10
10
 
11
- export type FlagValues = Record<string, string | boolean | undefined>
11
+ export type FlagValues = Record<string, string | boolean | string[] | boolean[] | undefined>
12
12
 
13
13
  /**
14
14
  * Strict flag parsing with a uniform usage-error shape across commands.
@@ -1,5 +1,5 @@
1
1
  import { AmbiguousWorkspaceError, WorkspaceNotFoundError } from "../api/resolver.js"
2
- import { TemplatesUnavailableError } from "../api/templates.js"
2
+ import { AmbiguousTemplateError, TemplateNotFoundError } from "../api/templates.js"
3
3
  import { RemoteAuthError, RemoteNetworkError } from "../api/types.js"
4
4
  import { ModelsFetchError } from "../auth/models-providers.js"
5
5
  import { NotLoggedInError } from "../auth/resolve.js"
@@ -28,9 +28,10 @@ export function guardCommand<TDeps>(
28
28
  if (
29
29
  err instanceof NotLoggedInError ||
30
30
  err instanceof ModelsFetchError ||
31
- err instanceof TemplatesUnavailableError ||
32
31
  err instanceof WorkspaceNotFoundError ||
33
32
  err instanceof AmbiguousWorkspaceError ||
33
+ err instanceof TemplateNotFoundError ||
34
+ err instanceof AmbiguousTemplateError ||
34
35
  err instanceof BridgeError ||
35
36
  err instanceof RemoteAuthError ||
36
37
  err instanceof RemoteNetworkError
@@ -198,11 +198,88 @@ describe("kimchictl workspace create", () => {
198
198
  expect(lines[lines.length - 1]).toBe("bright-oak-otter")
199
199
  })
200
200
 
201
- it("--template errors gracefully until the templates API lands", async () => {
202
- const { code, errors } = await run(["create", "--template", "node"], apiFetch([]))
201
+ it("--template conflicts with spec flags (usage error)", async () => {
202
+ for (const specFlag of [
203
+ ["--cpu", "2"],
204
+ ["--memory", "4Gi"],
205
+ ["--storage", "20Gi"],
206
+ ["--dep", "jq"],
207
+ ["--egress-allow", "*.example.com"],
208
+ ["--init-script", "echo hi"],
209
+ ]) {
210
+ const { code, errors } = await run(["create", "--template", "node", ...specFlag], apiFetch([]))
211
+ expect(code).toBe(2)
212
+ expect(errors.some((e) => e.includes("mutually exclusive"))).toBe(true)
213
+ }
214
+ })
203
215
 
204
- expect(code).toBe(1)
205
- expect(errors[0]).toContain('templates are not available yet (requested "node")')
216
+ it("--template and --template-id conflict (usage error)", async () => {
217
+ const { code, errors } = await run(
218
+ ["create", "--template", "node", "--template-id", "11111111-2222-3333-4444-555555555555"],
219
+ apiFetch([]),
220
+ )
221
+ expect(code).toBe(2)
222
+ expect(errors.some((e) => e.includes("--template and --template-id"))).toBe(true)
223
+ })
224
+
225
+ it("sends templateName to the API", async () => {
226
+ const calls: { body?: unknown }[] = []
227
+ const rawFetch = stubFetch(async (url, init) => {
228
+ if (url.endsWith("workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
229
+ if (url.endsWith("/workspaces") && init?.method === "POST") {
230
+ calls.push({ body: JSON.parse(String(init.body)) })
231
+ return jsonResponse(wsJson({ status: "ACTIVE" }))
232
+ }
233
+ throw new Error(`unexpected: ${init?.method ?? "GET"} ${url}`)
234
+ })
235
+
236
+ await run(["create", "--template", "rust", "--no-wait"], rawFetch as typeof globalThis.fetch)
237
+
238
+ expect(calls[0]?.body).toMatchObject({
239
+ templateName: "rust",
240
+ clientType: "harness",
241
+ })
242
+ })
243
+
244
+ it("sends spec fields to the API", async () => {
245
+ const calls: { body?: unknown }[] = []
246
+ const rawFetch = stubFetch(async (url, init) => {
247
+ if (url.endsWith("workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
248
+ if (url.endsWith("/workspaces") && init?.method === "POST") {
249
+ calls.push({ body: JSON.parse(String(init.body)) })
250
+ return jsonResponse(wsJson({ status: "ACTIVE" }))
251
+ }
252
+ throw new Error(`unexpected: ${init?.method ?? "GET"} ${url}`)
253
+ })
254
+
255
+ await run(
256
+ [
257
+ "create",
258
+ "--cpu",
259
+ "500m",
260
+ "--memory",
261
+ "2Gi",
262
+ "--storage",
263
+ "10Gi",
264
+ "--dep",
265
+ "jq",
266
+ "--dep",
267
+ "node@22",
268
+ "--egress-allow",
269
+ "*.example.com",
270
+ "--egress-default-allow",
271
+ "--no-wait",
272
+ ],
273
+ rawFetch as typeof globalThis.fetch,
274
+ )
275
+
276
+ expect(calls[0]?.body).toMatchObject({
277
+ spec: {
278
+ resources: { cpu: "500m", memory: "2Gi", pvcSize: "10Gi" },
279
+ dependencies: ["jq", "node@22"],
280
+ egressPolicy: { denyByDefault: false, allowed: ["*.example.com"] },
281
+ },
282
+ })
206
283
  })
207
284
 
208
285
  it("rejects positional arguments and bad timeouts as usage errors", async () => {
@@ -352,6 +429,99 @@ describe("kimchictl workspace get", () => {
352
429
 
353
430
  // ---------------------------------------------------------------- delete
354
431
 
432
+ describe("kimchictl workspace templates", () => {
433
+ const TEMPLATE_JSON = {
434
+ id: "aaaa1111-2222-3333-4444-555555555555",
435
+ organizationId: "org-1",
436
+ name: "rust",
437
+ description: "Rust workspace",
438
+ spec: {
439
+ resources: { cpu: "2", memory: "4Gi", pvcSize: "20Gi" },
440
+ dependencies: ["jq", "node@22"],
441
+ egressPolicy: { denyByDefault: true, allowed: ["*.github.com"] },
442
+ initScript: "echo hi",
443
+ },
444
+ createdBy: "user-1",
445
+ updatedBy: "user-1",
446
+ createTime: "2026-01-01T00:00:00Z",
447
+ updateTime: "2026-01-01T00:00:00Z",
448
+ }
449
+
450
+ function templateFetch(items: unknown[]): typeof globalThis.fetch {
451
+ return stubFetch(async (url, init) => {
452
+ if (init?.method === "POST" || (init?.method ?? "GET") === "POST") {
453
+ if (url.endsWith("workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
454
+ }
455
+ if (url.endsWith("workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
456
+ if (url.includes("workspace-templates?")) return jsonResponse({ items })
457
+ if (url.endsWith("/workspace-templates/aaaa1111-2222-3333-4444-555555555555")) {
458
+ return jsonResponse(items[0])
459
+ }
460
+ throw new Error(`unexpected: ${init?.method ?? "GET"} ${url}`)
461
+ }) as typeof globalThis.fetch
462
+ }
463
+
464
+ it("prints group usage on help / missing verb", async () => {
465
+ const { code, lines } = await run(["templates", "help"], templateFetch([]))
466
+ expect(code).toBe(0)
467
+ expect(lines[0]).toContain("kimchictl workspace templates <verb>")
468
+ })
469
+
470
+ it("renders the template table", async () => {
471
+ const { code, lines } = await run(["templates", "list"], templateFetch([TEMPLATE_JSON]), {
472
+ now: () => new Date("2026-01-01T03:00:00Z"),
473
+ })
474
+ expect(code).toBe(0)
475
+ const table = lines[0]?.split("\n") ?? []
476
+ expect(table[0]).toMatch(/^NAME\s+DESCRIPTION\s+CPU\s+MEMORY\s+STORAGE\s+AGE$/)
477
+ expect(table[1]).toMatch(/^rust\s+Rust workspace\s+2\s+4Gi\s+20Gi\s+3h$/)
478
+ })
479
+
480
+ it("renders json output", async () => {
481
+ const { code, lines } = await run(["templates", "list", "--output", "json"], templateFetch([TEMPLATE_JSON]))
482
+ expect(code).toBe(0)
483
+ const parsed = JSON.parse(lines.join(""))
484
+ expect(parsed[0]).toMatchObject({ name: "rust", description: "Rust workspace" })
485
+ })
486
+
487
+ it("renders the template detail card by name", async () => {
488
+ const { code, lines } = await run(["templates", "get", "rust"], templateFetch([TEMPLATE_JSON]), {
489
+ now: () => new Date("2026-01-01T03:00:00Z"),
490
+ })
491
+ expect(code).toBe(0)
492
+ const text = lines.join("\n")
493
+ expect(text).toContain("rust template")
494
+ expect(text).toContain("Rust workspace")
495
+ expect(text).toContain("deps jq, node@22")
496
+ expect(text).toContain("egress allow: *.github.com")
497
+ })
498
+
499
+ it("resolves by UUID without listing", async () => {
500
+ const fetch = templateFetch([TEMPLATE_JSON])
501
+ const calls: string[] = []
502
+ const trackedFetch = stubFetch(async (url, init) => {
503
+ calls.push(url)
504
+ return fetch(url, init)
505
+ }) as typeof globalThis.fetch
506
+
507
+ const { code } = await run(["templates", "get", "aaaa1111-2222-3333-4444-555555555555"], trackedFetch)
508
+ expect(code).toBe(0)
509
+ // No list call — the UUID went straight to GET
510
+ expect(calls.some((u) => u.includes("workspace-templates?"))).toBe(false)
511
+ })
512
+
513
+ it("errors on unknown template", async () => {
514
+ const { code, errors } = await run(["templates", "get", "nope"], templateFetch([]))
515
+ expect(code).toBe(1)
516
+ expect(errors[0]).toContain('No workspace template matches "nope"')
517
+ })
518
+
519
+ it("rejects unknown verbs and bad output", async () => {
520
+ expect((await run(["templates", "frobnicate"], templateFetch([]))).code).toBe(2)
521
+ expect((await run(["templates", "list", "--output", "yaml"], templateFetch([]))).code).toBe(2)
522
+ })
523
+ })
524
+
355
525
  describe("kimchictl workspace delete", () => {
356
526
  it("deletes after an affirmative confirmation", async () => {
357
527
  const fetch = apiFetch([