@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.
@@ -1,8 +1,22 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { resolve } from "node:path"
1
3
  import { resolveWorkspace } from "../api/resolver.js"
2
4
  import { formatAge, formatBytes, formatMillicores } from "../api/resources.js"
3
- import { resolveTemplate } from "../api/templates.js"
5
+ import {
6
+ AmbiguousTemplateError,
7
+ getWorkspaceTemplate,
8
+ listWorkspaceTemplates,
9
+ TemplateNotFoundError,
10
+ type WorkspaceTemplate,
11
+ } from "../api/templates.js"
4
12
  import { waitForWorkspaceActive } from "../api/wait.js"
5
- import { createWorkspace, deleteWorkspace, listWorkspaces, type Workspace } from "../api/workspaces.js"
13
+ import {
14
+ type CreateWorkspaceSpec,
15
+ createWorkspace,
16
+ deleteWorkspace,
17
+ listWorkspaces,
18
+ type Workspace,
19
+ } from "../api/workspaces.js"
6
20
  import { requireApiKey } from "../auth/resolve.js"
7
21
  import { resolveSshDomain } from "../ssh/config.js"
8
22
  import { parseFlags, UsageError } from "./flags.js"
@@ -29,10 +43,26 @@ const GROUP_USAGE = `Usage:
29
43
  kimchictl workspace <verb> [options]
30
44
 
31
45
  Verbs:
32
- create Create a workspace (server generates the alias)
33
- list List workspaces (alias: ls)
34
- get Show workspace details
35
- delete Delete a workspace (alias: rm)
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)
51
+
52
+ Create options:
53
+ --desc <text> Description
54
+ --template <name> Workspace template (conflicts with spec flags)
55
+ --template-id <uuid> Template ID (alternative to --template)
56
+ --cpu <qty> CPU request (e.g. "500m", "2")
57
+ --memory <qty> Memory request (e.g. "1Gi", "512Mi")
58
+ --storage <qty> PVC size (e.g. "20Gi")
59
+ --dep <tool> CLI tool to install (repeatable, e.g. --dep jq --dep node@22)
60
+ --egress-allow <dest> Allow egress destination (repeatable)
61
+ --egress-deny <dest> Deny egress destination (repeatable)
62
+ --egress-default-allow Default-allow egress (instead of deny-by-default)
63
+ --init-script <script> Boot script (or @file to read from file)
64
+ --no-wait Skip waiting for ACTIVE
65
+ --timeout <seconds> Wait timeout (default 60)
36
66
 
37
67
  Options:
38
68
  --help Show this help`
@@ -51,6 +81,9 @@ export async function runWorkspace(args: string[], deps: WorkspaceDeps = {}): Pr
51
81
  case "delete":
52
82
  case "rm":
53
83
  return runDelete(rest, deps)
84
+ case "templates":
85
+ case "template":
86
+ return runTemplates(rest, deps)
54
87
  case "help":
55
88
  case "--help":
56
89
  case "-h":
@@ -70,6 +103,15 @@ async function runCreate(args: string[], deps: WorkspaceDeps): Promise<number> {
70
103
  const { values, positionals } = parseFlags("workspace create", args, {
71
104
  desc: { type: "string" },
72
105
  template: { type: "string", short: "t" },
106
+ "template-id": { type: "string" },
107
+ cpu: { type: "string" },
108
+ memory: { type: "string" },
109
+ storage: { type: "string" },
110
+ dep: { type: "string", multiple: true },
111
+ "egress-allow": { type: "string", multiple: true },
112
+ "egress-deny": { type: "string", multiple: true },
113
+ "egress-default-allow": { type: "boolean" },
114
+ "init-script": { type: "string" },
73
115
  "no-wait": { type: "boolean" },
74
116
  timeout: { type: "string", default: "60" },
75
117
  })
@@ -79,22 +121,92 @@ async function runCreate(args: string[], deps: WorkspaceDeps): Promise<number> {
79
121
  )
80
122
  }
81
123
 
82
- const template = values.template
83
- if (typeof template === "string" && template.length > 0) {
84
- // Placeholder: errors gracefully until the templates API contract lands.
85
- resolveTemplate(template)
124
+ const timeoutSeconds = parseTimeoutSeconds(values.timeout, "workspace create")
125
+ const desc = typeof values.desc === "string" && values.desc.length > 0 ? values.desc : undefined
126
+
127
+ const templateName = typeof values.template === "string" && values.template.length > 0 ? values.template : undefined
128
+ const templateId =
129
+ typeof values["template-id"] === "string" && values["template-id"].length > 0 ? values["template-id"] : undefined
130
+
131
+ // Collect explicit spec parameters from the flags.
132
+ const resources: { cpu?: string; memory?: string; pvcSize?: string } = {}
133
+ if (typeof values.cpu === "string" && values.cpu.length > 0) resources.cpu = values.cpu
134
+ if (typeof values.memory === "string" && values.memory.length > 0) resources.memory = values.memory
135
+ if (typeof values.storage === "string" && values.storage.length > 0) resources.pvcSize = values.storage
136
+
137
+ const dependencies = (Array.isArray(values.dep) ? values.dep : []).filter(
138
+ (d): d is string => typeof d === "string" && d.length > 0,
139
+ )
140
+ const egressAllowed = (Array.isArray(values["egress-allow"]) ? values["egress-allow"] : []).filter(
141
+ (d): d is string => typeof d === "string" && d.length > 0,
142
+ )
143
+ const egressDenied = (Array.isArray(values["egress-deny"]) ? values["egress-deny"] : []).filter(
144
+ (d): d is string => typeof d === "string" && d.length > 0,
145
+ )
146
+ const egressDefaultAllow = values["egress-default-allow"] === true
147
+ const initScriptRaw = typeof values["init-script"] === "string" ? values["init-script"] : undefined
148
+
149
+ // @file syntax for --init-script: read the script from a file.
150
+ let initScript: string | undefined
151
+ if (initScriptRaw) {
152
+ if (initScriptRaw.startsWith("@")) {
153
+ const filePath = initScriptRaw.slice(1)
154
+ try {
155
+ initScript = readFileSync(resolve(filePath), "utf-8")
156
+ } catch {
157
+ throw new UsageError(`kimchictl workspace create: --init-script: cannot read ${filePath}`)
158
+ }
159
+ } else {
160
+ initScript = initScriptRaw
161
+ }
86
162
  }
87
- if (values.template !== undefined && (typeof values.template !== "string" || values.template.length === 0)) {
88
- throw new UsageError("kimchictl workspace create: --template must not be empty")
163
+
164
+ const hasSpec =
165
+ Object.keys(resources).length > 0 ||
166
+ dependencies.length > 0 ||
167
+ egressAllowed.length > 0 ||
168
+ egressDenied.length > 0 ||
169
+ egressDefaultAllow ||
170
+ initScript !== undefined
171
+
172
+ // Mutual exclusion: template fields and spec parameters cannot coexist
173
+ // (the API rejects both — proto: "conflicts with spec").
174
+ if ((templateName || templateId) && hasSpec) {
175
+ throw new UsageError(
176
+ "kimchictl workspace create: --template and spec flags (--cpu, --memory, --storage, --dep, --egress-*, --init-script) are mutually exclusive — a template provides these values",
177
+ )
178
+ }
179
+ if (templateName && templateId) {
180
+ throw new UsageError("kimchictl workspace create: --template and --template-id are mutually exclusive")
89
181
  }
90
182
 
91
- const timeoutSeconds = parseTimeoutSeconds(values.timeout, "workspace create")
92
- const desc = typeof values.desc === "string" && values.desc.length > 0 ? values.desc : undefined
183
+ const spec: CreateWorkspaceSpec | undefined = hasSpec
184
+ ? {
185
+ ...(Object.keys(resources).length > 0 ? { resources } : {}),
186
+ ...(dependencies.length > 0 ? { dependencies } : {}),
187
+ ...(egressAllowed.length > 0 || egressDenied.length > 0 || egressDefaultAllow
188
+ ? {
189
+ egressPolicy: {
190
+ ...(egressDefaultAllow ? { denyByDefault: false } : {}),
191
+ ...(egressAllowed.length > 0 ? { allowed: egressAllowed } : {}),
192
+ ...(egressDenied.length > 0 ? { denied: egressDenied } : {}),
193
+ },
194
+ }
195
+ : {}),
196
+ ...(initScript !== undefined ? { initScript } : {}),
197
+ }
198
+ : undefined
93
199
 
94
200
  const { key } = requireApiKey(deps.env)
95
201
  const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
96
202
 
97
- const created = await createWorkspace(key, { description: desc, fetch: deps.fetch })
203
+ const created = await createWorkspace(key, {
204
+ description: desc,
205
+ templateName,
206
+ templateId,
207
+ spec,
208
+ fetch: deps.fetch,
209
+ })
98
210
  // Success-path info goes to stdout (terminals that color stderr red made
99
211
  // this look like an error); only warnings and the transient progress
100
212
  // spinner stay on stderr.
@@ -237,6 +349,184 @@ async function runDelete(args: string[], deps: WorkspaceDeps): Promise<number> {
237
349
  return 0
238
350
  }
239
351
 
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
+
240
530
  // ---------------------------------------------------------------- shared
241
531
 
242
532
  /** Tinted status dot: green=active, yellow=transitional/suspended, dim=terminated. */
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.6"
6
+ export const VERSION = "0.0.8"