@kimchi-dev/kimchictl 0.0.8 → 0.0.10

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.
@@ -0,0 +1,222 @@
1
+ import { formatAge, formatBytes, formatMillicores } from "../api/resources.js"
2
+ import {
3
+ AmbiguousTemplateError,
4
+ getWorkspaceTemplate,
5
+ listWorkspaceTemplates,
6
+ TemplateNotFoundError,
7
+ type WorkspaceTemplate,
8
+ } from "../api/templates.js"
9
+ import { requireApiKey } from "../auth/resolve.js"
10
+ import { parseFlags, UsageError } from "./flags.js"
11
+ import { guardCommand } from "./guard.js"
12
+ import { formatTable } from "./table.js"
13
+
14
+ export interface TemplatesDeps {
15
+ fetch?: typeof globalThis.fetch
16
+ env?: NodeJS.ProcessEnv
17
+ /** Override TTY interactivity detection (tests). */
18
+ interactive?: boolean
19
+ /** Clock override for deterministic AGE columns (tests). */
20
+ now?: () => Date
21
+ /** ANSI colors on stdout (tests); defaults to stdout being a TTY. */
22
+ color?: boolean
23
+ }
24
+
25
+ const TEMPLATES_USAGE = `Usage:
26
+ kimchictl templates <verb> [options]
27
+
28
+ Verbs:
29
+ list List workspace templates (alias: ls)
30
+ get Show template details
31
+
32
+ Options:
33
+ --help Show this help`
34
+
35
+ /** `kimchictl templates <verb> …` — read-only browse of workspace templates. */
36
+ export async function runTemplates(args: string[], deps: TemplatesDeps = {}): Promise<number> {
37
+ const [verb, ...rest] = args
38
+ switch (verb) {
39
+ case "list":
40
+ case "ls":
41
+ return runTemplatesList(rest, deps)
42
+ case "get":
43
+ return runTemplatesGet(rest, deps)
44
+ case "help":
45
+ case "--help":
46
+ case "-h":
47
+ console.log(TEMPLATES_USAGE)
48
+ return 0
49
+ case undefined:
50
+ console.error(TEMPLATES_USAGE)
51
+ return 2
52
+ default:
53
+ console.error(`kimchictl templates: unknown verb "${verb}"`)
54
+ console.error(TEMPLATES_USAGE)
55
+ return 2
56
+ }
57
+ }
58
+
59
+ async function runTemplatesList(args: string[], deps: TemplatesDeps): Promise<number> {
60
+ const { values, positionals } = parseFlags("templates list", args, {
61
+ output: { type: "string", short: "o", default: "table" },
62
+ })
63
+ if (values.help) {
64
+ console.log(`Usage:
65
+ kimchictl templates list [options]
66
+
67
+ Options:
68
+ --output <format> Output format: table | json (default: table)
69
+ --help Show this help`)
70
+ return 0
71
+ }
72
+ if (positionals.length > 0) {
73
+ throw new UsageError("kimchictl templates list: unexpected positional argument")
74
+ }
75
+ if (values.output !== "json" && values.output !== "table") {
76
+ throw new UsageError(
77
+ `kimchictl templates list: unsupported --output "${String(values.output)}" (expected table|json)`,
78
+ )
79
+ }
80
+
81
+ const { key } = requireApiKey(deps.env)
82
+ const templates = await listWorkspaceTemplates(key, { fetch: deps.fetch })
83
+
84
+ if (values.output === "json") {
85
+ console.log(JSON.stringify(templates.map(serializeTemplate), null, 2))
86
+ return 0
87
+ }
88
+
89
+ const now = deps.now?.() ?? new Date()
90
+ const rows = templates.map((t) => [
91
+ t.name || t.id.slice(0, 12),
92
+ t.description.length > 40 ? `${t.description.slice(0, 37)}…` : t.description || "-",
93
+ formatMillicores(t.spec?.resources?.cpu ? parseQuantity(t.spec.resources.cpu) : undefined),
94
+ formatBytes(t.spec?.resources?.memory ? parseByteQuantity(t.spec.resources.memory) : undefined),
95
+ formatBytes(t.spec?.resources?.pvcSize ? parseByteQuantity(t.spec.resources.pvcSize) : undefined),
96
+ formatAge(t.createTime, now),
97
+ ])
98
+ console.log(formatTable(["NAME", "DESCRIPTION", "CPU", "MEMORY", "STORAGE", "AGE"], rows))
99
+ return 0
100
+ }
101
+
102
+ async function runTemplatesGet(args: string[], deps: TemplatesDeps): Promise<number> {
103
+ const { values, positionals } = parseFlags("templates get", args, {
104
+ output: { type: "string", short: "o", default: "text" },
105
+ })
106
+ if (values.help) {
107
+ console.log(`Usage:
108
+ kimchictl templates get <name-or-id> [options]
109
+
110
+ Options:
111
+ --output <format> Output format: text | json (default: text)
112
+ --help Show this help`)
113
+ return 0
114
+ }
115
+ if (positionals.length !== 1) {
116
+ throw new UsageError("kimchictl templates get: expected exactly one template name or ID")
117
+ }
118
+ if (values.output !== "json" && values.output !== "text") {
119
+ throw new UsageError(
120
+ `kimchictl templates get: unsupported --output "${String(values.output)}" (expected text|json)`,
121
+ )
122
+ }
123
+
124
+ const { key } = requireApiKey(deps.env)
125
+ const ref = positionals[0] ?? ""
126
+
127
+ // Templates are addressed by ID in the API; when the ref is a name,
128
+ // list-and-match (the API has no by-name lookup).
129
+ let template: WorkspaceTemplate | undefined
130
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref)) {
131
+ template = await getWorkspaceTemplate(key, ref, { fetch: deps.fetch })
132
+ } else {
133
+ const all = await listWorkspaceTemplates(key, { fetch: deps.fetch })
134
+ const matches = all.filter((t) => t.name === ref || t.name.startsWith(ref))
135
+ if (matches.length === 0) throw new TemplateNotFoundError(ref)
136
+ if (matches.length > 1)
137
+ throw new AmbiguousTemplateError(
138
+ ref,
139
+ matches.map((t) => t.name),
140
+ )
141
+ template = matches[0]
142
+ }
143
+ if (!template) throw new TemplateNotFoundError(ref)
144
+
145
+ if (values.output === "json") {
146
+ console.log(JSON.stringify(serializeTemplate(template), null, 2))
147
+ return 0
148
+ }
149
+
150
+ for (const line of renderTemplateCard(template, deps.color ?? false, deps.now?.() ?? new Date())) {
151
+ console.log(line)
152
+ }
153
+ return 0
154
+ }
155
+
156
+ function renderTemplateCard(template: WorkspaceTemplate, color: boolean, now: Date): string[] {
157
+ const dimText = (text: string) => (color ? `\x1b[2m${text}\x1b[0m` : text)
158
+ const lines: string[] = [`${template.name || template.id.slice(0, 12)} ${dimText("template")}`]
159
+ if (template.description) lines.push(dimText(` ${template.description}`))
160
+
161
+ const parts: string[] = []
162
+ const res = template.spec?.resources
163
+ if (res) {
164
+ if (res.cpu) parts.push(formatMillicores(parseQuantity(res.cpu)))
165
+ if (res.memory) parts.push(formatBytes(parseByteQuantity(res.memory)))
166
+ }
167
+ if (parts.length > 0) {
168
+ lines.push(dimText(` ${parts.join(" · ")}`))
169
+ }
170
+
171
+ const dependencies = template.spec?.dependencies
172
+ if (dependencies && dependencies.length > 0) {
173
+ lines.push(` deps ${dependencies.join(", ")}`)
174
+ }
175
+
176
+ const eg = template.spec?.egressPolicy
177
+ if (eg) {
178
+ const egParts: string[] = []
179
+ if (eg.denyByDefault === false) egParts.push("default-allow")
180
+ if (eg.allowed && eg.allowed.length > 0) egParts.push(`allow: ${eg.allowed.join(", ")}`)
181
+ if (eg.denied && eg.denied.length > 0) egParts.push(`deny: ${eg.denied.join(", ")}`)
182
+ if (egParts.length > 0) lines.push(dimText(` egress ${egParts.join(" · ")}`))
183
+ }
184
+
185
+ lines.push(dimText(` updated ${formatAge(template.updateTime, now)} ago`))
186
+ return lines
187
+ }
188
+
189
+ function serializeTemplate(t: WorkspaceTemplate): Record<string, unknown> {
190
+ return {
191
+ id: t.id,
192
+ name: t.name,
193
+ ...(t.description ? { description: t.description } : {}),
194
+ spec: t.spec,
195
+ ...(t.createdBy ? { createdBy: t.createdBy } : {}),
196
+ ...(t.updateTime.getTime() > 0 ? { updateTime: t.updateTime.toISOString() } : {}),
197
+ }
198
+ }
199
+
200
+ /** Parse a k8s CPU quantity string ("500m", "2") to millicores. */
201
+ function parseQuantity(q: string): number | undefined {
202
+ const value = Number.parseFloat(q.replace(/m$/, ""))
203
+ if (Number.isNaN(value)) return undefined
204
+ return q.endsWith("m") ? value : value * 1000
205
+ }
206
+
207
+ /** Parse a k8s memory/storage quantity string ("1Gi", "512Mi") to bytes. */
208
+ function parseByteQuantity(q: string): number | undefined {
209
+ const units: Record<string, number> = { Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4 }
210
+ for (const [suffix, mult] of Object.entries(units)) {
211
+ if (q.endsWith(suffix)) {
212
+ const value = Number.parseFloat(q.slice(0, -suffix.length))
213
+ if (!Number.isNaN(value)) return value * mult
214
+ }
215
+ }
216
+ const value = Number.parseFloat(q)
217
+ if (!Number.isNaN(value)) return value
218
+ return undefined
219
+ }
220
+
221
+ /** Canonical harness-package entry: guard exit codes for the kimchi.commands manifest. */
222
+ export const run: (args: string[], deps?: TemplatesDeps) => Promise<number> = guardCommand(runTemplates)
@@ -323,11 +323,11 @@ describe("kimchictl workspace list", () => {
323
323
  expect(code).toBe(0)
324
324
  // formatTable prints the whole table as one console call.
325
325
  const table = lines[0]?.split("\n") ?? []
326
- expect(table[0]).toMatch(/^NAME\s+STATUS\s+CLUSTER\s+AGE\s+CPU\s+RAM\s+PVC\s+URI$/)
326
+ expect(table[0]).toMatch(/^NAME\s+STATUS\s+CLUSTER\s+TEMPLATE\s+AGE\s+CPU\s+RAM\s+PVC\s+URI$/)
327
327
  expect(table[1]).toMatch(
328
- /^bright-oak-otter\s+active\s+krep-us\s+3h\s+2\s+4Gi\s+20Gi\s+bright-oak-otter\.remote\.kimchi\.dev$/,
328
+ /^bright-oak-otter\s+active\s+krep-us\s+-\s+3h\s+2\s+4Gi\s+20Gi\s+bright-oak-otter\.remote\.kimchi\.dev$/,
329
329
  )
330
- expect(table[2]).toMatch(/^demo\s+suspended\s+krep-us\s+2d\s+-\s+-\s+-\s+demo\.remote\.kimchi\.dev$/)
330
+ expect(table[2]).toMatch(/^demo\s+suspended\s+krep-us\s+-\s+2d\s+-\s+-\s+-\s+demo\.remote\.kimchi\.dev$/)
331
331
  expect(table).toHaveLength(3)
332
332
  })
333
333
 
@@ -338,7 +338,7 @@ describe("kimchictl workspace list", () => {
338
338
  expect(JSON.parse(jsonRun.lines.join(""))).toEqual([])
339
339
 
340
340
  const tableRun = await run(["list"], fetch)
341
- expect(tableRun.lines).toEqual(["NAME STATUS CLUSTER AGE CPU RAM PVC URI"])
341
+ expect(tableRun.lines).toEqual(["NAME STATUS CLUSTER TEMPLATE AGE CPU RAM PVC URI"])
342
342
  })
343
343
 
344
344
  it("rejects an unsupported output flavor", async () => {
@@ -429,99 +429,6 @@ describe("kimchictl workspace get", () => {
429
429
 
430
430
  // ---------------------------------------------------------------- delete
431
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
-
525
432
  describe("kimchictl workspace delete", () => {
526
433
  it("deletes after an affirmative confirmation", async () => {
527
434
  const fetch = apiFetch([
@@ -2,13 +2,6 @@ import { readFileSync } from "node:fs"
2
2
  import { resolve } from "node:path"
3
3
  import { resolveWorkspace } from "../api/resolver.js"
4
4
  import { formatAge, formatBytes, formatMillicores } from "../api/resources.js"
5
- import {
6
- AmbiguousTemplateError,
7
- getWorkspaceTemplate,
8
- listWorkspaceTemplates,
9
- TemplateNotFoundError,
10
- type WorkspaceTemplate,
11
- } from "../api/templates.js"
12
5
  import { waitForWorkspaceActive } from "../api/wait.js"
13
6
  import {
14
7
  type CreateWorkspaceSpec,
@@ -43,11 +36,10 @@ const GROUP_USAGE = `Usage:
43
36
  kimchictl workspace <verb> [options]
44
37
 
45
38
  Verbs:
46
- create Create a workspace (server generates the alias)
47
- list List workspaces (alias: ls)
48
- get Show workspace details
49
- delete Delete a workspace (alias: rm)
50
- templates Browse workspace templates (list | get)
39
+ create Create a workspace (server generates the alias)
40
+ list List workspaces (alias: ls)
41
+ get Show workspace details
42
+ delete Delete a workspace (alias: rm)
51
43
 
52
44
  Create options:
53
45
  --desc <text> Description
@@ -81,9 +73,6 @@ export async function runWorkspace(args: string[], deps: WorkspaceDeps = {}): Pr
81
73
  case "delete":
82
74
  case "rm":
83
75
  return runDelete(rest, deps)
84
- case "templates":
85
- case "template":
86
- return runTemplates(rest, deps)
87
76
  case "help":
88
77
  case "--help":
89
78
  case "-h":
@@ -99,6 +88,25 @@ export async function runWorkspace(args: string[], deps: WorkspaceDeps = {}): Pr
99
88
 
100
89
  // ---------------------------------------------------------------- create
101
90
 
91
+ const CREATE_USAGE = `Usage:
92
+ kimchictl workspace create [options]
93
+
94
+ Options:
95
+ --desc <text> Description
96
+ --template <name> Workspace template (conflicts with spec flags)
97
+ --template-id <uuid> Template ID (alternative to --template)
98
+ --cpu <qty> CPU request (e.g. "500m", "2")
99
+ --memory <qty> Memory request (e.g. "1Gi", "512Mi")
100
+ --storage <qty> PVC size (e.g. "20Gi")
101
+ --dep <tool> CLI tool to install (repeatable, e.g. --dep jq --dep node@22)
102
+ --egress-allow <dest> Allow egress destination (repeatable)
103
+ --egress-deny <dest> Deny egress destination (repeatable)
104
+ --egress-default-allow Default-allow egress (instead of deny-by-default)
105
+ --init-script <script> Boot script (or @file to read from file)
106
+ --no-wait Skip waiting for ACTIVE
107
+ --timeout <seconds> Wait timeout (default 60)
108
+ --help Show this help`
109
+
102
110
  async function runCreate(args: string[], deps: WorkspaceDeps): Promise<number> {
103
111
  const { values, positionals } = parseFlags("workspace create", args, {
104
112
  desc: { type: "string" },
@@ -115,6 +123,10 @@ async function runCreate(args: string[], deps: WorkspaceDeps): Promise<number> {
115
123
  "no-wait": { type: "boolean" },
116
124
  timeout: { type: "string", default: "60" },
117
125
  })
126
+ if (values.help) {
127
+ console.log(CREATE_USAGE)
128
+ return 0
129
+ }
118
130
  if (positionals.length > 0) {
119
131
  throw new UsageError(
120
132
  "kimchictl workspace create: unexpected positional argument (workspaces are named by the server)",
@@ -251,6 +263,15 @@ async function runList(args: string[], deps: WorkspaceDeps): Promise<number> {
251
263
  const { values, positionals } = parseFlags("workspace list", args, {
252
264
  output: { type: "string", short: "o", default: "table" },
253
265
  })
266
+ if (values.help) {
267
+ console.log(`Usage:
268
+ kimchictl workspace list [options]
269
+
270
+ Options:
271
+ --output <format> Output format: table | json (default: table)
272
+ --help Show this help`)
273
+ return 0
274
+ }
254
275
  if (positionals.length > 0) {
255
276
  throw new UsageError("kimchictl workspace list: unexpected positional argument")
256
277
  }
@@ -274,13 +295,14 @@ async function runList(args: string[], deps: WorkspaceDeps): Promise<number> {
274
295
  w.alias,
275
296
  w.status,
276
297
  w.cluster,
298
+ w.templateName || "-",
277
299
  formatAge(w.createdAt, now),
278
300
  formatMillicores(w.cpuMillicores),
279
301
  formatBytes(w.ramBytes),
280
302
  formatBytes(w.pvcSizeBytes),
281
303
  w.uri ?? "",
282
304
  ])
283
- console.log(formatTable(["NAME", "STATUS", "CLUSTER", "AGE", "CPU", "RAM", "PVC", "URI"], rows))
305
+ console.log(formatTable(["NAME", "STATUS", "CLUSTER", "TEMPLATE", "AGE", "CPU", "RAM", "PVC", "URI"], rows))
284
306
  return 0
285
307
  }
286
308
 
@@ -290,6 +312,15 @@ async function runGet(args: string[], deps: WorkspaceDeps): Promise<number> {
290
312
  const { values, positionals } = parseFlags("workspace get", args, {
291
313
  output: { type: "string", short: "o", default: "text" },
292
314
  })
315
+ if (values.help) {
316
+ console.log(`Usage:
317
+ kimchictl workspace get <name> [options]
318
+
319
+ Options:
320
+ --output <format> Output format: text | json (default: text)
321
+ --help Show this help`)
322
+ return 0
323
+ }
293
324
  if (positionals.length !== 1) {
294
325
  throw new UsageError("kimchictl workspace get: expected exactly one workspace name")
295
326
  }
@@ -320,6 +351,15 @@ async function runDelete(args: string[], deps: WorkspaceDeps): Promise<number> {
320
351
  const { values, positionals } = parseFlags("workspace delete", args, {
321
352
  force: { type: "boolean", short: "f" },
322
353
  })
354
+ if (values.help) {
355
+ console.log(`Usage:
356
+ kimchictl workspace delete <name> [options]
357
+
358
+ Options:
359
+ --force, -f Skip the confirmation prompt
360
+ --help Show this help`)
361
+ return 0
362
+ }
323
363
  if (positionals.length !== 1) {
324
364
  throw new UsageError("kimchictl workspace delete: expected exactly one workspace name")
325
365
  }
@@ -349,184 +389,6 @@ async function runDelete(args: string[], deps: WorkspaceDeps): Promise<number> {
349
389
  return 0
350
390
  }
351
391
 
352
- // ---------------------------------------------------------------- templates
353
-
354
- async function runTemplates(args: string[], deps: WorkspaceDeps): Promise<number> {
355
- const [verb, ...rest] = args
356
- switch (verb) {
357
- case "list":
358
- case "ls":
359
- return runTemplatesList(rest, deps)
360
- case "get":
361
- return runTemplatesGet(rest, deps)
362
- case "help":
363
- case "--help":
364
- case "-h":
365
- console.log(TEMPLATES_USAGE)
366
- return 0
367
- case undefined:
368
- console.error(TEMPLATES_USAGE)
369
- return 2
370
- default:
371
- console.error(`kimchictl workspace templates: unknown verb "${verb}"`)
372
- console.error(TEMPLATES_USAGE)
373
- return 2
374
- }
375
- }
376
-
377
- const TEMPLATES_USAGE = `Usage:
378
- kimchictl workspace templates <verb> [options]
379
-
380
- Verbs:
381
- list List workspace templates (alias: ls)
382
- get Show template details
383
-
384
- Options:
385
- --help Show this help`
386
-
387
- async function runTemplatesList(args: string[], deps: WorkspaceDeps): Promise<number> {
388
- const { values, positionals } = parseFlags("workspace templates list", args, {
389
- output: { type: "string", short: "o", default: "table" },
390
- })
391
- if (positionals.length > 0) {
392
- throw new UsageError("kimchictl workspace templates list: unexpected positional argument")
393
- }
394
- if (values.output !== "json" && values.output !== "table") {
395
- throw new UsageError(
396
- `kimchictl workspace templates list: unsupported --output "${String(values.output)}" (expected table|json)`,
397
- )
398
- }
399
-
400
- const { key } = requireApiKey(deps.env)
401
- const templates = await listWorkspaceTemplates(key, { fetch: deps.fetch })
402
-
403
- if (values.output === "json") {
404
- console.log(JSON.stringify(templates.map(serializeTemplate), null, 2))
405
- return 0
406
- }
407
-
408
- const now = deps.now?.() ?? new Date()
409
- const rows = templates.map((t) => [
410
- t.name || t.id.slice(0, 12),
411
- t.description.length > 40 ? `${t.description.slice(0, 37)}…` : t.description || "-",
412
- formatMillicores(t.spec?.resources?.cpu ? parseQuantity(t.spec.resources.cpu) : undefined),
413
- formatBytes(t.spec?.resources?.memory ? parseByteQuantity(t.spec.resources.memory) : undefined),
414
- formatBytes(t.spec?.resources?.pvcSize ? parseByteQuantity(t.spec.resources.pvcSize) : undefined),
415
- formatAge(t.createTime, now),
416
- ])
417
- console.log(formatTable(["NAME", "DESCRIPTION", "CPU", "MEMORY", "STORAGE", "AGE"], rows))
418
- return 0
419
- }
420
-
421
- async function runTemplatesGet(args: string[], deps: WorkspaceDeps): Promise<number> {
422
- const { values, positionals } = parseFlags("workspace templates get", args, {
423
- output: { type: "string", short: "o", default: "text" },
424
- })
425
- if (positionals.length !== 1) {
426
- throw new UsageError("kimchictl workspace templates get: expected exactly one template name or ID")
427
- }
428
- if (values.output !== "json" && values.output !== "text") {
429
- throw new UsageError(
430
- `kimchictl workspace templates get: unsupported --output "${String(values.output)}" (expected text|json)`,
431
- )
432
- }
433
-
434
- const { key } = requireApiKey(deps.env)
435
- const ref = positionals[0] ?? ""
436
-
437
- // Templates are addressed by ID in the API; when the ref is a name,
438
- // list-and-match (the API has no by-name lookup).
439
- let template: WorkspaceTemplate | undefined
440
- if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ref)) {
441
- template = await getWorkspaceTemplate(key, ref, { fetch: deps.fetch })
442
- } else {
443
- const all = await listWorkspaceTemplates(key, { fetch: deps.fetch })
444
- const matches = all.filter((t) => t.name === ref || t.name.startsWith(ref))
445
- if (matches.length === 0) throw new TemplateNotFoundError(ref)
446
- if (matches.length > 1)
447
- throw new AmbiguousTemplateError(
448
- ref,
449
- matches.map((t) => t.name),
450
- )
451
- template = matches[0]
452
- }
453
- if (!template) throw new TemplateNotFoundError(ref)
454
-
455
- if (values.output === "json") {
456
- console.log(JSON.stringify(serializeTemplate(template), null, 2))
457
- return 0
458
- }
459
-
460
- for (const line of renderTemplateCard(template, deps.color ?? false, deps.now?.() ?? new Date())) {
461
- console.log(line)
462
- }
463
- return 0
464
- }
465
-
466
- function renderTemplateCard(template: WorkspaceTemplate, color: boolean, now: Date): string[] {
467
- const lines: string[] = [`${template.name || template.id.slice(0, 12)} ${dim("template", color)}`]
468
- if (template.description) lines.push(dim(` ${template.description}`, color))
469
-
470
- const parts: string[] = []
471
- const res = template.spec?.resources
472
- if (res) {
473
- if (res.cpu) parts.push(formatMillicores(parseQuantity(res.cpu)))
474
- if (res.memory) parts.push(formatBytes(parseByteQuantity(res.memory)))
475
- }
476
- if (parts.length > 0) {
477
- lines.push(dim(` ${parts.join(" · ")}`, color))
478
- }
479
-
480
- const deps = template.spec?.dependencies
481
- if (deps && deps.length > 0) {
482
- lines.push(` deps ${deps.join(", ")}`)
483
- }
484
-
485
- const eg = template.spec?.egressPolicy
486
- if (eg) {
487
- const egParts: string[] = []
488
- if (eg.denyByDefault === false) egParts.push("default-allow")
489
- if (eg.allowed && eg.allowed.length > 0) egParts.push(`allow: ${eg.allowed.join(", ")}`)
490
- if (eg.denied && eg.denied.length > 0) egParts.push(`deny: ${eg.denied.join(", ")}`)
491
- if (egParts.length > 0) lines.push(dim(` egress ${egParts.join(" · ")}`, color))
492
- }
493
-
494
- lines.push(dim(` updated ${formatAge(template.updateTime, now)} ago`, color))
495
- return lines
496
- }
497
-
498
- function serializeTemplate(t: WorkspaceTemplate): Record<string, unknown> {
499
- return {
500
- id: t.id,
501
- name: t.name,
502
- ...(t.description ? { description: t.description } : {}),
503
- spec: t.spec,
504
- ...(t.createdBy ? { createdBy: t.createdBy } : {}),
505
- ...(t.updateTime.getTime() > 0 ? { updateTime: t.updateTime.toISOString() } : {}),
506
- }
507
- }
508
-
509
- /** Parse a k8s CPU quantity string ("500m", "2") to millicores. */
510
- function parseQuantity(q: string): number | undefined {
511
- const value = Number.parseFloat(q.replace(/m$/, ""))
512
- if (Number.isNaN(value)) return undefined
513
- return q.endsWith("m") ? value : value * 1000
514
- }
515
-
516
- /** Parse a k8s memory/storage quantity string ("1Gi", "512Mi") to bytes. */
517
- function parseByteQuantity(q: string): number | undefined {
518
- const units: Record<string, number> = { Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4 }
519
- for (const [suffix, mult] of Object.entries(units)) {
520
- if (q.endsWith(suffix)) {
521
- const value = Number.parseFloat(q.slice(0, -suffix.length))
522
- if (!Number.isNaN(value)) return value * mult
523
- }
524
- }
525
- const value = Number.parseFloat(q)
526
- if (!Number.isNaN(value)) return value
527
- return undefined
528
- }
529
-
530
392
  // ---------------------------------------------------------------- shared
531
393
 
532
394
  /** Tinted status dot: green=active, yellow=transitional/suspended, dim=terminated. */
@@ -560,6 +422,7 @@ function renderWorkspaceCard(
560
422
  const meta = [
561
423
  workspace.cluster ? `cluster ${workspace.cluster}` : "",
562
424
  workspace.clientType,
425
+ workspace.templateName ? `from ${workspace.templateName}` : "",
563
426
  `created ${formatAge(workspace.createdAt, now)} ago`,
564
427
  ].filter(Boolean)
565
428
  if (meta.length > 0) lines.push(dim(` ${meta.join(" · ")}`, color))
@@ -593,6 +456,7 @@ function serializeWorkspace(w: Workspace): Record<string, unknown> {
593
456
  status: w.status,
594
457
  ...(w.cluster ? { cluster: w.cluster } : {}),
595
458
  ...(w.clientType ? { clientType: w.clientType } : {}),
459
+ ...(w.templateName ? { templateName: w.templateName } : {}),
596
460
  ...(w.uri ? { uri: w.uri } : {}),
597
461
  createdAt: w.createdAt.getTime() > 0 ? w.createdAt.toISOString() : undefined,
598
462
  resources: {
package/src/version.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * version over this constant at build time; the committed value stays a
4
4
  * sentinel so source checkouts are recognizable.
5
5
  */
6
- export const VERSION = "0.0.8"
6
+ export const VERSION = "0.0.10"