@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.
- package/dist/api/templates.d.ts +41 -14
- package/dist/api/templates.d.ts.map +1 -1
- package/dist/api/templates.js +106 -21
- package/dist/api/templates.js.map +1 -1
- package/dist/api/workspaces.d.ts +36 -5
- package/dist/api/workspaces.d.ts.map +1 -1
- package/dist/api/workspaces.js +50 -6
- package/dist/api/workspaces.js.map +1 -1
- package/dist/commands/flags.d.ts +1 -1
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/guard.d.ts.map +1 -1
- package/dist/commands/guard.js +3 -2
- package/dist/commands/guard.js.map +1 -1
- package/dist/commands/workspace.d.ts.map +1 -1
- package/dist/commands/workspace.js +268 -15
- package/dist/commands/workspace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/api/templates.ts +179 -20
- package/src/api/workspaces.ts +70 -10
- package/src/commands/flags.ts +1 -1
- package/src/commands/guard.ts +3 -2
- package/src/commands/workspace.test.ts +174 -4
- package/src/commands/workspace.ts +305 -15
- package/src/version.ts +1 -1
package/src/api/workspaces.ts
CHANGED
|
@@ -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.
|
|
127
|
-
*
|
|
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,
|
package/src/commands/flags.ts
CHANGED
|
@@ -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.
|
package/src/commands/guard.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AmbiguousWorkspaceError, WorkspaceNotFoundError } from "../api/resolver.js"
|
|
2
|
-
import {
|
|
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
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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([
|