@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.
- package/dist/api/workspaces.d.ts +2 -0
- package/dist/api/workspaces.d.ts.map +1 -1
- package/dist/api/workspaces.js +1 -0
- package/dist/api/workspaces.js.map +1 -1
- package/dist/commands/flags.d.ts +2 -0
- package/dist/commands/flags.d.ts.map +1 -1
- package/dist/commands/flags.js +7 -1
- package/dist/commands/flags.js.map +1 -1
- package/dist/commands/registry.d.ts.map +1 -1
- package/dist/commands/registry.js +2 -0
- package/dist/commands/registry.js.map +1 -1
- package/dist/commands/ssh.d.ts.map +1 -1
- package/dist/commands/ssh.js +19 -0
- package/dist/commands/ssh.js.map +1 -1
- package/dist/commands/templates.d.ts +15 -0
- package/dist/commands/templates.d.ts.map +1 -0
- package/dist/commands/templates.js +192 -0
- package/dist/commands/templates.js.map +1 -0
- package/dist/commands/workspace.d.ts.map +1 -1
- package/dist/commands/workspace.js +57 -174
- package/dist/commands/workspace.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/package.json +3 -2
- package/src/api/workspaces.ts +3 -0
- package/src/commands/flags.ts +7 -1
- package/src/commands/registry.ts +2 -0
- package/src/commands/ssh.ts +19 -0
- package/src/commands/templates.test.ts +126 -0
- package/src/commands/templates.ts +222 -0
- package/src/commands/workspace.test.ts +4 -97
- package/src/commands/workspace.ts +58 -194
- package/src/version.ts +1 -1
|
@@ -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
|
|
47
|
-
list
|
|
48
|
-
get
|
|
49
|
-
delete
|
|
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