@kimchi-dev/kimchictl 0.0.2
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/LICENSE +203 -0
- package/README.md +105 -0
- package/bin/kimchictl.mjs +17 -0
- package/dist/api/http.d.ts +7 -0
- package/dist/api/http.d.ts.map +1 -0
- package/dist/api/http.js +60 -0
- package/dist/api/http.js.map +1 -0
- package/dist/api/keys.d.ts +7 -0
- package/dist/api/keys.d.ts.map +1 -0
- package/dist/api/keys.js +28 -0
- package/dist/api/keys.js.map +1 -0
- package/dist/api/me.d.ts +12 -0
- package/dist/api/me.d.ts.map +1 -0
- package/dist/api/me.js +28 -0
- package/dist/api/me.js.map +1 -0
- package/dist/api/resolver.d.ts +26 -0
- package/dist/api/resolver.d.ts.map +1 -0
- package/dist/api/resolver.js +62 -0
- package/dist/api/resolver.js.map +1 -0
- package/dist/api/resources.d.ts +16 -0
- package/dist/api/resources.d.ts.map +1 -0
- package/dist/api/resources.js +96 -0
- package/dist/api/resources.js.map +1 -0
- package/dist/api/templates.d.ts +19 -0
- package/dist/api/templates.d.ts.map +1 -0
- package/dist/api/templates.js +25 -0
- package/dist/api/templates.js.map +1 -0
- package/dist/api/tokens.d.ts +23 -0
- package/dist/api/tokens.d.ts.map +1 -0
- package/dist/api/tokens.js +51 -0
- package/dist/api/tokens.js.map +1 -0
- package/dist/api/types.d.ts +34 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +32 -0
- package/dist/api/types.js.map +1 -0
- package/dist/api/wait.d.ts +17 -0
- package/dist/api/wait.d.ts.map +1 -0
- package/dist/api/wait.js +37 -0
- package/dist/api/wait.js.map +1 -0
- package/dist/api/workspaces.d.ts +33 -0
- package/dist/api/workspaces.d.ts.map +1 -0
- package/dist/api/workspaces.js +159 -0
- package/dist/api/workspaces.js.map +1 -0
- package/dist/auth/auth-file.d.ts +37 -0
- package/dist/auth/auth-file.d.ts.map +1 -0
- package/dist/auth/auth-file.js +100 -0
- package/dist/auth/auth-file.js.map +1 -0
- package/dist/auth/browser-login.d.ts +45 -0
- package/dist/auth/browser-login.d.ts.map +1 -0
- package/dist/auth/browser-login.js +195 -0
- package/dist/auth/browser-login.js.map +1 -0
- package/dist/auth/config-file.d.ts +4 -0
- package/dist/auth/config-file.d.ts.map +1 -0
- package/dist/auth/config-file.js +52 -0
- package/dist/auth/config-file.js.map +1 -0
- package/dist/auth/models-providers.d.ts +17 -0
- package/dist/auth/models-providers.d.ts.map +1 -0
- package/dist/auth/models-providers.js +63 -0
- package/dist/auth/models-providers.js.map +1 -0
- package/dist/auth/paths.d.ts +15 -0
- package/dist/auth/paths.d.ts.map +1 -0
- package/dist/auth/paths.js +25 -0
- package/dist/auth/paths.js.map +1 -0
- package/dist/auth/provider-ids.d.ts +4 -0
- package/dist/auth/provider-ids.d.ts.map +1 -0
- package/dist/auth/provider-ids.js +12 -0
- package/dist/auth/provider-ids.js.map +1 -0
- package/dist/auth/resolve.d.ts +19 -0
- package/dist/auth/resolve.d.ts.map +1 -0
- package/dist/auth/resolve.js +37 -0
- package/dist/auth/resolve.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/completion.d.ts +2 -0
- package/dist/commands/completion.d.ts.map +1 -0
- package/dist/commands/completion.js +71 -0
- package/dist/commands/completion.js.map +1 -0
- package/dist/commands/flags.d.ts +16 -0
- package/dist/commands/flags.d.ts.map +1 -0
- package/dist/commands/flags.js +23 -0
- package/dist/commands/flags.js.map +1 -0
- package/dist/commands/guard.d.ts +10 -0
- package/dist/commands/guard.d.ts.map +1 -0
- package/dist/commands/guard.js +41 -0
- package/dist/commands/guard.js.map +1 -0
- package/dist/commands/help.d.ts +4 -0
- package/dist/commands/help.d.ts.map +1 -0
- package/dist/commands/help.js +19 -0
- package/dist/commands/help.js.map +1 -0
- package/dist/commands/login.d.ts +18 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +80 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +14 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +41 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/prompt.d.ts +9 -0
- package/dist/commands/prompt.d.ts.map +1 -0
- package/dist/commands/prompt.js +21 -0
- package/dist/commands/prompt.js.map +1 -0
- package/dist/commands/registry.d.ts +16 -0
- package/dist/commands/registry.d.ts.map +1 -0
- package/dist/commands/registry.js +35 -0
- package/dist/commands/registry.js.map +1 -0
- package/dist/commands/ssh.d.ts +15 -0
- package/dist/commands/ssh.d.ts.map +1 -0
- package/dist/commands/ssh.js +90 -0
- package/dist/commands/ssh.js.map +1 -0
- package/dist/commands/table.d.ts +3 -0
- package/dist/commands/table.d.ts.map +1 -0
- package/dist/commands/table.js +10 -0
- package/dist/commands/table.js.map +1 -0
- package/dist/commands/update.d.ts +16 -0
- package/dist/commands/update.d.ts.map +1 -0
- package/dist/commands/update.js +57 -0
- package/dist/commands/update.js.map +1 -0
- package/dist/commands/version.d.ts +2 -0
- package/dist/commands/version.d.ts.map +1 -0
- package/dist/commands/version.js +6 -0
- package/dist/commands/version.js.map +1 -0
- package/dist/commands/whoami.d.ts +7 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/commands/workspace.d.ts +15 -0
- package/dist/commands/workspace.d.ts.map +1 -0
- package/dist/commands/workspace.js +206 -0
- package/dist/commands/workspace.js.map +1 -0
- package/dist/run.d.ts +10 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +30 -0
- package/dist/run.js.map +1 -0
- package/dist/ssh/bridge.d.ts +25 -0
- package/dist/ssh/bridge.d.ts.map +1 -0
- package/dist/ssh/bridge.js +95 -0
- package/dist/ssh/bridge.js.map +1 -0
- package/dist/ssh/config.d.ts +65 -0
- package/dist/ssh/config.d.ts.map +1 -0
- package/dist/ssh/config.js +198 -0
- package/dist/ssh/config.js.map +1 -0
- package/dist/ssh/connect.d.ts +27 -0
- package/dist/ssh/connect.d.ts.map +1 -0
- package/dist/ssh/connect.js +67 -0
- package/dist/ssh/connect.js.map +1 -0
- package/dist/ssh/tunnel.d.ts +29 -0
- package/dist/ssh/tunnel.d.ts.map +1 -0
- package/dist/ssh/tunnel.js +36 -0
- package/dist/ssh/tunnel.js.map +1 -0
- package/dist/test-support.d.ts +30 -0
- package/dist/test-support.d.ts.map +1 -0
- package/dist/test-support.js +67 -0
- package/dist/test-support.js.map +1 -0
- package/dist/updates/check.d.ts +18 -0
- package/dist/updates/check.d.ts.map +1 -0
- package/dist/updates/check.js +90 -0
- package/dist/updates/check.js.map +1 -0
- package/dist/updates/self.d.ts +36 -0
- package/dist/updates/self.d.ts.map +1 -0
- package/dist/updates/self.js +103 -0
- package/dist/updates/self.js.map +1 -0
- package/dist/version.d.ts +7 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +7 -0
- package/dist/version.js.map +1 -0
- package/package.json +77 -0
- package/src/api/http.ts +70 -0
- package/src/api/keys.ts +40 -0
- package/src/api/me.ts +48 -0
- package/src/api/resolver.test.ts +109 -0
- package/src/api/resolver.ts +72 -0
- package/src/api/resources.test.ts +56 -0
- package/src/api/resources.ts +92 -0
- package/src/api/templates.ts +28 -0
- package/src/api/tokens.ts +80 -0
- package/src/api/types.ts +50 -0
- package/src/api/wait.ts +45 -0
- package/src/api/workspaces.ts +234 -0
- package/src/auth/auth-file.test.ts +176 -0
- package/src/auth/auth-file.ts +103 -0
- package/src/auth/browser-login.test.ts +93 -0
- package/src/auth/browser-login.ts +247 -0
- package/src/auth/config-file.test.ts +68 -0
- package/src/auth/config-file.ts +50 -0
- package/src/auth/models-providers.test.ts +63 -0
- package/src/auth/models-providers.ts +76 -0
- package/src/auth/paths.ts +28 -0
- package/src/auth/provider-ids.ts +15 -0
- package/src/auth/resolve.test.ts +72 -0
- package/src/auth/resolve.ts +46 -0
- package/src/cli.ts +16 -0
- package/src/commands/completion.test.ts +31 -0
- package/src/commands/completion.ts +77 -0
- package/src/commands/flags.ts +29 -0
- package/src/commands/guard.ts +44 -0
- package/src/commands/help.ts +20 -0
- package/src/commands/login.test.ts +159 -0
- package/src/commands/login.ts +94 -0
- package/src/commands/logout.test.ts +89 -0
- package/src/commands/logout.ts +53 -0
- package/src/commands/prompt.ts +21 -0
- package/src/commands/registry.ts +50 -0
- package/src/commands/ssh.test.ts +101 -0
- package/src/commands/ssh.ts +112 -0
- package/src/commands/table.test.ts +24 -0
- package/src/commands/table.ts +10 -0
- package/src/commands/update.test.ts +91 -0
- package/src/commands/update.ts +78 -0
- package/src/commands/version.ts +6 -0
- package/src/commands/whoami.test.ts +72 -0
- package/src/commands/whoami.ts +36 -0
- package/src/commands/workspace.test.ts +360 -0
- package/src/commands/workspace.ts +250 -0
- package/src/run.test.ts +53 -0
- package/src/run.ts +34 -0
- package/src/ssh/bridge.test.ts +70 -0
- package/src/ssh/bridge.ts +110 -0
- package/src/ssh/config.test.ts +213 -0
- package/src/ssh/config.ts +233 -0
- package/src/ssh/connect.test.ts +123 -0
- package/src/ssh/connect.ts +98 -0
- package/src/ssh/tunnel.test.ts +119 -0
- package/src/ssh/tunnel.ts +64 -0
- package/src/test-support.ts +77 -0
- package/src/updates/check.test.ts +99 -0
- package/src/updates/check.ts +117 -0
- package/src/updates/self.test.ts +117 -0
- package/src/updates/self.ts +131 -0
- package/src/version.ts +6 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import {
|
|
5
|
+
captureConsole,
|
|
6
|
+
cleanupTempDirs,
|
|
7
|
+
jsonResponse,
|
|
8
|
+
makeTempDir,
|
|
9
|
+
stubAgentDirEnv,
|
|
10
|
+
stubFetch,
|
|
11
|
+
} from "../test-support.js"
|
|
12
|
+
import { guardCommand } from "./guard.js"
|
|
13
|
+
import { runWorkspace, type WorkspaceDeps } from "./workspace.js"
|
|
14
|
+
|
|
15
|
+
const guardedWorkspace = guardCommand(runWorkspace)
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
cleanupTempDirs()
|
|
19
|
+
vi.unstubAllEnvs()
|
|
20
|
+
vi.restoreAllMocks()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------- fixtures
|
|
24
|
+
|
|
25
|
+
interface WorkspaceOverrides extends Record<string, unknown> {
|
|
26
|
+
id?: string
|
|
27
|
+
alias?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function wsJson(over: WorkspaceOverrides = {}): Record<string, unknown> {
|
|
31
|
+
const alias = over.alias ?? "bright-oak-otter"
|
|
32
|
+
return {
|
|
33
|
+
id: over.id ?? "11111111-2222-3333-4444-555555555555",
|
|
34
|
+
description: "api work",
|
|
35
|
+
status: "ACTIVE",
|
|
36
|
+
createTime: "2026-01-01T00:00:00Z",
|
|
37
|
+
uri: `${alias}.remote.kimchi.dev`,
|
|
38
|
+
spec: { resources: { cpu: "2", memory: "4Gi", pvcSize: "20Gi" } },
|
|
39
|
+
...over,
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Route {
|
|
44
|
+
method: string
|
|
45
|
+
match: RegExp
|
|
46
|
+
respond: (call: number) => Response
|
|
47
|
+
/** Number of times this route must be hit at minimum (0 = optional). */
|
|
48
|
+
minCalls?: number
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Fetch stub routing on method+path. verifyKey is always answered with the
|
|
53
|
+
* fixed org — the commands start every flow by resolving the org id.
|
|
54
|
+
*/
|
|
55
|
+
function apiFetch(routes: Route[]): typeof globalThis.fetch {
|
|
56
|
+
const counts = new Map<Route, number>()
|
|
57
|
+
return stubFetch(async (url, init) => {
|
|
58
|
+
const method = init?.method ?? "GET"
|
|
59
|
+
if (method === "POST" && url.endsWith("workspace-tokens:verifyKey")) {
|
|
60
|
+
return jsonResponse({ organizationId: "org-1" })
|
|
61
|
+
}
|
|
62
|
+
for (const route of routes) {
|
|
63
|
+
if (route.method === method && route.match.test(url)) {
|
|
64
|
+
counts.set(route, (counts.get(route) ?? 0) + 1)
|
|
65
|
+
return route.respond(counts.get(route) ?? 1)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`unexpected request: ${method} ${url}`)
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function run(
|
|
73
|
+
args: string[],
|
|
74
|
+
fetch: typeof globalThis.fetch,
|
|
75
|
+
deps: WorkspaceDeps = {},
|
|
76
|
+
): Promise<{ code: number; lines: string[]; errors: string[] }> {
|
|
77
|
+
const dir = makeTempDir()
|
|
78
|
+
stubAgentDirEnv(dir)
|
|
79
|
+
writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "key" } }))
|
|
80
|
+
const { lines, errors } = captureConsole()
|
|
81
|
+
const code = await guardedWorkspace(args, { fetch, ...deps })
|
|
82
|
+
return { code, lines, errors }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------- group dispatch
|
|
86
|
+
|
|
87
|
+
describe("kimchictl workspace (group dispatch)", () => {
|
|
88
|
+
it("prints group usage on help / missing verb", async () => {
|
|
89
|
+
const { code, lines } = await run(["help"], apiFetch([]))
|
|
90
|
+
expect(code).toBe(0)
|
|
91
|
+
expect(lines[0]).toContain("kimchictl workspace <verb>")
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("rejects unknown verbs with exit 2", async () => {
|
|
95
|
+
const { code, errors } = await run(["frobnicate"], apiFetch([]))
|
|
96
|
+
expect(code).toBe(2)
|
|
97
|
+
expect(errors[0]).toBe('kimchictl workspace: unknown verb "frobnicate"')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("mounts the hidden ws alias", async () => {
|
|
101
|
+
const dir = makeTempDir()
|
|
102
|
+
stubAgentDirEnv(dir)
|
|
103
|
+
writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "key" } }))
|
|
104
|
+
captureConsole()
|
|
105
|
+
const code = await guardedWorkspace(["help"])
|
|
106
|
+
|
|
107
|
+
expect(code).toBe(0)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------- create
|
|
112
|
+
|
|
113
|
+
describe("kimchictl workspace create", () => {
|
|
114
|
+
it("creates, waits for ACTIVE, and prints the alias on stdout", async () => {
|
|
115
|
+
const fetch = apiFetch([
|
|
116
|
+
{
|
|
117
|
+
method: "POST",
|
|
118
|
+
match: /\/organizations\/org-1\/workspaces$/,
|
|
119
|
+
respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })),
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
method: "GET",
|
|
123
|
+
match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
|
|
124
|
+
respond: (call) => jsonResponse(wsJson({ status: call >= 2 ? "ACTIVE" : "INITIALIZING" })),
|
|
125
|
+
},
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
const { code, lines, errors } = await run(["create", "--desc", "api work"], fetch, { sleep: async () => {} })
|
|
129
|
+
|
|
130
|
+
expect(code).toBe(0)
|
|
131
|
+
expect(lines).toEqual(["bright-oak-otter"])
|
|
132
|
+
expect(errors[0]).toBe("✓ created bright-oak-otter (initializing)")
|
|
133
|
+
expect(errors.some((l) => l.includes("uri bright-oak-otter.remote.kimchi.dev"))).toBe(true)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("--no-wait returns immediately after create", async () => {
|
|
137
|
+
const fetch = apiFetch([
|
|
138
|
+
{
|
|
139
|
+
method: "POST",
|
|
140
|
+
match: /\/organizations\/org-1\/workspaces$/,
|
|
141
|
+
respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })),
|
|
142
|
+
},
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
const { code, lines } = await run(["create", "--no-wait"], fetch)
|
|
146
|
+
|
|
147
|
+
expect(code).toBe(0)
|
|
148
|
+
expect(lines).toEqual(["bright-oak-otter"])
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("warns when the workspace is not ACTIVE at the deadline", async () => {
|
|
152
|
+
const fetch = apiFetch([
|
|
153
|
+
{ method: "POST", match: /workspaces$/, respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })) },
|
|
154
|
+
{
|
|
155
|
+
method: "GET",
|
|
156
|
+
match: /11111111-2222-3333-4444-555555555555$/,
|
|
157
|
+
respond: () => jsonResponse(wsJson({ status: "INITIALIZING" })),
|
|
158
|
+
},
|
|
159
|
+
])
|
|
160
|
+
|
|
161
|
+
// deadline 1s, sleep override advances nothing — the loop exits when Date.now() passes the deadline…
|
|
162
|
+
// …so use --timeout 1 with a real timer of 0 polls by making sleep a no-op that burns real time? No:
|
|
163
|
+
// sleep is mocked to resolve instantly, and Date.now() stays under the deadline — the loop would spin
|
|
164
|
+
// forever. Instead, advance the clock via the sleep mock.
|
|
165
|
+
let now = Date.now()
|
|
166
|
+
const realNow = Date.now
|
|
167
|
+
vi.spyOn(Date, "now").mockImplementation(() => now)
|
|
168
|
+
|
|
169
|
+
const { code, errors } = await run(["create", "--timeout", "1"], fetch, {
|
|
170
|
+
sleep: async () => {
|
|
171
|
+
now += 2000
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
vi.spyOn(Date, "now").mockImplementation(realNow)
|
|
175
|
+
|
|
176
|
+
expect(code).toBe(0)
|
|
177
|
+
expect(errors.some((l) => l.includes("still initializing"))).toBe(true)
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it("--template errors gracefully until the templates API lands", async () => {
|
|
181
|
+
const { code, errors } = await run(["create", "--template", "node"], apiFetch([]))
|
|
182
|
+
|
|
183
|
+
expect(code).toBe(1)
|
|
184
|
+
expect(errors[0]).toContain('templates are not available yet (requested "node")')
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("rejects positional arguments and bad timeouts as usage errors", async () => {
|
|
188
|
+
for (const args of [
|
|
189
|
+
["create", "myname"],
|
|
190
|
+
["create", "--timeout", "abc"],
|
|
191
|
+
["create", "--timeout", "0"],
|
|
192
|
+
]) {
|
|
193
|
+
const { code } = await run(args, apiFetch([]))
|
|
194
|
+
expect(code).toBe(2)
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------- list
|
|
200
|
+
|
|
201
|
+
describe("kimchictl workspace list", () => {
|
|
202
|
+
it("renders the table", async () => {
|
|
203
|
+
const fetch = apiFetch([
|
|
204
|
+
{
|
|
205
|
+
method: "GET",
|
|
206
|
+
match: /\?page\.limit=/,
|
|
207
|
+
respond: () =>
|
|
208
|
+
jsonResponse({
|
|
209
|
+
items: [
|
|
210
|
+
wsJson(),
|
|
211
|
+
wsJson({
|
|
212
|
+
alias: "demo",
|
|
213
|
+
description: "",
|
|
214
|
+
status: "SUSPENDED",
|
|
215
|
+
createTime: "2025-12-30T00:00:00Z",
|
|
216
|
+
spec: undefined,
|
|
217
|
+
}),
|
|
218
|
+
],
|
|
219
|
+
}),
|
|
220
|
+
},
|
|
221
|
+
])
|
|
222
|
+
|
|
223
|
+
const { code, lines } = await run(["list"], fetch, { now: () => new Date("2026-01-01T03:00:00Z") })
|
|
224
|
+
|
|
225
|
+
expect(code).toBe(0)
|
|
226
|
+
// formatTable prints the whole table as one console call.
|
|
227
|
+
const table = lines[0]?.split("\n") ?? []
|
|
228
|
+
expect(table[0]).toBe("NAME STATUS CPU MEMORY AGE")
|
|
229
|
+
expect(table[1]).toMatch(/^bright-oak-otter\s+active\s+2\s+4Gi\s+3h$/)
|
|
230
|
+
expect(table[2]).toMatch(/^demo\s+suspended\s+-\s+-\s+2d$/)
|
|
231
|
+
expect(table).toHaveLength(3)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("renders json output and the empty table", async () => {
|
|
235
|
+
const fetch = apiFetch([{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [] }) }])
|
|
236
|
+
|
|
237
|
+
const jsonRun = await run(["list", "--output", "json"], fetch)
|
|
238
|
+
expect(JSON.parse(jsonRun.lines.join(""))).toEqual([])
|
|
239
|
+
|
|
240
|
+
const tableRun = await run(["list"], fetch)
|
|
241
|
+
expect(tableRun.lines).toEqual(["NAME STATUS CPU MEMORY AGE"])
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
it("rejects an unsupported output flavor", async () => {
|
|
245
|
+
const { code } = await run(["list", "--output", "yaml"], apiFetch([]))
|
|
246
|
+
expect(code).toBe(2)
|
|
247
|
+
})
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------- get
|
|
251
|
+
|
|
252
|
+
describe("kimchictl workspace get", () => {
|
|
253
|
+
it("renders the detail view", async () => {
|
|
254
|
+
const fetch = apiFetch([
|
|
255
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
|
|
256
|
+
])
|
|
257
|
+
|
|
258
|
+
const { code, lines } = await run(["get", "bright-oak-otter"], fetch)
|
|
259
|
+
|
|
260
|
+
expect(code).toBe(0)
|
|
261
|
+
expect(lines).toEqual([
|
|
262
|
+
"name: bright-oak-otter",
|
|
263
|
+
"description: api work",
|
|
264
|
+
"status: active",
|
|
265
|
+
"uri: bright-oak-otter.remote.kimchi.dev",
|
|
266
|
+
"created: 2026-01-01T00:00:00.000Z",
|
|
267
|
+
"cpu: 2",
|
|
268
|
+
"memory: 4Gi",
|
|
269
|
+
"storage: 20Gi",
|
|
270
|
+
])
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it("reports a missing workspace via the guard", async () => {
|
|
274
|
+
const fetch = apiFetch([
|
|
275
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
|
|
276
|
+
])
|
|
277
|
+
|
|
278
|
+
const { code, errors } = await run(["get", "ghost"], fetch)
|
|
279
|
+
|
|
280
|
+
expect(code).toBe(1)
|
|
281
|
+
expect(errors[0]).toMatch(/No workspace matches "ghost"/)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("resolves a UUID ref without listing", async () => {
|
|
285
|
+
const fetch = apiFetch([
|
|
286
|
+
{
|
|
287
|
+
method: "GET",
|
|
288
|
+
match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
|
|
289
|
+
respond: () => jsonResponse(wsJson()),
|
|
290
|
+
},
|
|
291
|
+
])
|
|
292
|
+
|
|
293
|
+
const { code, lines } = await run(["get", "11111111-2222-3333-4444-555555555555"], fetch)
|
|
294
|
+
|
|
295
|
+
expect(code).toBe(0)
|
|
296
|
+
expect(lines[0]).toBe("name: bright-oak-otter")
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it("requires exactly one name", async () => {
|
|
300
|
+
expect((await run(["get"], apiFetch([]))).code).toBe(2)
|
|
301
|
+
expect((await run(["get", "a", "b"], apiFetch([]))).code).toBe(2)
|
|
302
|
+
})
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
// ---------------------------------------------------------------- delete
|
|
306
|
+
|
|
307
|
+
describe("kimchictl workspace delete", () => {
|
|
308
|
+
it("deletes after an affirmative confirmation", async () => {
|
|
309
|
+
const fetch = apiFetch([
|
|
310
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
|
|
311
|
+
{
|
|
312
|
+
method: "DELETE",
|
|
313
|
+
match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
|
|
314
|
+
respond: () => new Response(null, { status: 200 }),
|
|
315
|
+
},
|
|
316
|
+
])
|
|
317
|
+
|
|
318
|
+
const { code, lines } = await run(["delete", "bright-oak-otter"], fetch, { confirm: async () => true })
|
|
319
|
+
|
|
320
|
+
expect(code).toBe(0)
|
|
321
|
+
expect(lines).toEqual(["✓ deleted bright-oak-otter"])
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("--force deletes without prompting", async () => {
|
|
325
|
+
const fetch = apiFetch([
|
|
326
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
|
|
327
|
+
{
|
|
328
|
+
method: "DELETE",
|
|
329
|
+
match: /\/workspaces\/11111111-2222-3333-4444-555555555555$/,
|
|
330
|
+
respond: () => new Response(null, { status: 200 }),
|
|
331
|
+
},
|
|
332
|
+
])
|
|
333
|
+
|
|
334
|
+
const { code } = await run(["delete", "--force", "bright-oak-otter"], fetch)
|
|
335
|
+
expect(code).toBe(0)
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
it("declined confirmation deletes nothing (no DELETE request)", async () => {
|
|
339
|
+
// Resolution still runs (it names the target in the prompt) — only the
|
|
340
|
+
// DELETE must not happen: any DELETE route would throw "unexpected request".
|
|
341
|
+
const fetch = apiFetch([
|
|
342
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
|
|
343
|
+
])
|
|
344
|
+
|
|
345
|
+
const { code, lines } = await run(["delete", "bright-oak-otter"], fetch, { confirm: async () => false })
|
|
346
|
+
|
|
347
|
+
expect(code).toBe(0)
|
|
348
|
+
expect(lines).toEqual(["Aborted."])
|
|
349
|
+
})
|
|
350
|
+
|
|
351
|
+
it("requires --force when non-interactive", async () => {
|
|
352
|
+
const fetch = apiFetch([
|
|
353
|
+
{ method: "GET", match: /\?page\.limit=/, respond: () => jsonResponse({ items: [wsJson()] }) },
|
|
354
|
+
])
|
|
355
|
+
const { code, errors } = await run(["delete", "bright-oak-otter"], fetch, { interactive: false })
|
|
356
|
+
|
|
357
|
+
expect(code).toBe(1)
|
|
358
|
+
expect(errors.some((l) => l.includes("--force"))).toBe(true)
|
|
359
|
+
})
|
|
360
|
+
})
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { resolveWorkspace } from "../api/resolver.js"
|
|
2
|
+
import { formatAge, formatBytes, formatMillicores } from "../api/resources.js"
|
|
3
|
+
import { resolveTemplate } from "../api/templates.js"
|
|
4
|
+
import { waitForWorkspaceActive } from "../api/wait.js"
|
|
5
|
+
import { createWorkspace, deleteWorkspace, listWorkspaces, type Workspace } from "../api/workspaces.js"
|
|
6
|
+
import { requireApiKey } from "../auth/resolve.js"
|
|
7
|
+
import { parseFlags, UsageError } from "./flags.js"
|
|
8
|
+
import { confirm, isInteractive } from "./prompt.js"
|
|
9
|
+
import { formatTable } from "./table.js"
|
|
10
|
+
|
|
11
|
+
export interface WorkspaceDeps {
|
|
12
|
+
fetch?: typeof globalThis.fetch
|
|
13
|
+
env?: NodeJS.ProcessEnv
|
|
14
|
+
/** Override TTY interactivity detection (tests). */
|
|
15
|
+
interactive?: boolean
|
|
16
|
+
/** Confirmation override (tests). */
|
|
17
|
+
confirm?: (question: string) => Promise<boolean>
|
|
18
|
+
/** Clock override for deterministic AGE columns (tests). */
|
|
19
|
+
now?: () => Date
|
|
20
|
+
/** Sleep override for the create wait loop (tests). */
|
|
21
|
+
sleep?: (ms: number) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GROUP_USAGE = `Usage:
|
|
25
|
+
kimchictl workspace <verb> [options]
|
|
26
|
+
|
|
27
|
+
Verbs:
|
|
28
|
+
create Create a workspace (server generates the alias)
|
|
29
|
+
list List workspaces (alias: ls)
|
|
30
|
+
get Show workspace details
|
|
31
|
+
delete Delete a workspace (alias: rm)
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
--help Show this help`
|
|
35
|
+
|
|
36
|
+
/** `kimchictl workspace <verb> …` — group command; also mounted as hidden alias `ws`. */
|
|
37
|
+
export async function runWorkspace(args: string[], deps: WorkspaceDeps = {}): Promise<number> {
|
|
38
|
+
const [verb, ...rest] = args
|
|
39
|
+
switch (verb) {
|
|
40
|
+
case "create":
|
|
41
|
+
return runCreate(rest, deps)
|
|
42
|
+
case "list":
|
|
43
|
+
case "ls":
|
|
44
|
+
return runList(rest, deps)
|
|
45
|
+
case "get":
|
|
46
|
+
return runGet(rest, deps)
|
|
47
|
+
case "delete":
|
|
48
|
+
case "rm":
|
|
49
|
+
return runDelete(rest, deps)
|
|
50
|
+
case "help":
|
|
51
|
+
case "--help":
|
|
52
|
+
case "-h":
|
|
53
|
+
case undefined:
|
|
54
|
+
console.log(GROUP_USAGE)
|
|
55
|
+
return verb === undefined ? 2 : 0
|
|
56
|
+
default:
|
|
57
|
+
console.error(`kimchictl workspace: unknown verb "${verb}"`)
|
|
58
|
+
console.error(GROUP_USAGE)
|
|
59
|
+
return 2
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------- create
|
|
64
|
+
|
|
65
|
+
async function runCreate(args: string[], deps: WorkspaceDeps): Promise<number> {
|
|
66
|
+
const { values, positionals } = parseFlags("workspace create", args, {
|
|
67
|
+
desc: { type: "string" },
|
|
68
|
+
template: { type: "string", short: "t" },
|
|
69
|
+
"no-wait": { type: "boolean" },
|
|
70
|
+
timeout: { type: "string", default: "60" },
|
|
71
|
+
})
|
|
72
|
+
if (positionals.length > 0) {
|
|
73
|
+
throw new UsageError(
|
|
74
|
+
"kimchictl workspace create: unexpected positional argument (workspaces are named by the server)",
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const template = values.template
|
|
79
|
+
if (typeof template === "string" && template.length > 0) {
|
|
80
|
+
// Placeholder: errors gracefully until the templates API contract lands.
|
|
81
|
+
resolveTemplate(template)
|
|
82
|
+
}
|
|
83
|
+
if (values.template !== undefined && (typeof values.template !== "string" || values.template.length === 0)) {
|
|
84
|
+
throw new UsageError("kimchictl workspace create: --template must not be empty")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const timeoutSeconds = parseTimeoutSeconds(values.timeout, "workspace create")
|
|
88
|
+
const desc = typeof values.desc === "string" && values.desc.length > 0 ? values.desc : undefined
|
|
89
|
+
|
|
90
|
+
const { key } = requireApiKey(deps.env)
|
|
91
|
+
const sleep = deps.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
92
|
+
|
|
93
|
+
const created = await createWorkspace(key, { description: desc, fetch: deps.fetch })
|
|
94
|
+
console.error(`✓ created ${created.alias} (${created.status})`)
|
|
95
|
+
|
|
96
|
+
let current = created
|
|
97
|
+
if (!values["no-wait"]) {
|
|
98
|
+
// kap's create poll: 2s cadence, deadline, progress on stderr.
|
|
99
|
+
current = await waitForWorkspaceActive(key, created.id, {
|
|
100
|
+
fetch: deps.fetch,
|
|
101
|
+
timeoutMs: timeoutSeconds * 1000,
|
|
102
|
+
sleep,
|
|
103
|
+
onTick: (elapsedSeconds) => process.stderr.write(`\r waiting… ${elapsedSeconds}s`),
|
|
104
|
+
})
|
|
105
|
+
process.stderr.write("\n")
|
|
106
|
+
if (current.status !== "active") {
|
|
107
|
+
console.error(` ⚠ workspace still initializing — check with: kimchictl workspace get ${created.alias}`)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (current.uri) console.error(` uri ${current.uri}`)
|
|
112
|
+
// The alias goes to stdout last so scripts can capture it: ALIAS=$(kimchictl workspace create)
|
|
113
|
+
console.log(current.alias)
|
|
114
|
+
return 0
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseTimeoutSeconds(raw: unknown, command: string): number {
|
|
118
|
+
const seconds = typeof raw === "string" ? Number(raw) : Number.NaN
|
|
119
|
+
if (!Number.isInteger(seconds) || seconds <= 0) {
|
|
120
|
+
throw new UsageError(`kimchictl ${command}: --timeout must be a positive number of seconds`)
|
|
121
|
+
}
|
|
122
|
+
return seconds
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------- list
|
|
126
|
+
|
|
127
|
+
async function runList(args: string[], deps: WorkspaceDeps): Promise<number> {
|
|
128
|
+
const { values, positionals } = parseFlags("workspace list", args, {
|
|
129
|
+
output: { type: "string", short: "o", default: "table" },
|
|
130
|
+
})
|
|
131
|
+
if (positionals.length > 0) {
|
|
132
|
+
throw new UsageError("kimchictl workspace list: unexpected positional argument")
|
|
133
|
+
}
|
|
134
|
+
if (values.output !== "json" && values.output !== "table") {
|
|
135
|
+
// Validate before touching the network — usage errors fail fast and offline.
|
|
136
|
+
throw new UsageError(
|
|
137
|
+
`kimchictl workspace list: unsupported --output "${String(values.output)}" (expected table|json)`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const { key } = requireApiKey(deps.env)
|
|
142
|
+
const workspaces = await listWorkspaces(key, { fetch: deps.fetch })
|
|
143
|
+
|
|
144
|
+
if (values.output === "json") {
|
|
145
|
+
console.log(JSON.stringify(workspaces.map(serializeWorkspace), null, 2))
|
|
146
|
+
return 0
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const now = deps.now?.() ?? new Date()
|
|
150
|
+
const rows = workspaces.map((w) => [
|
|
151
|
+
w.alias,
|
|
152
|
+
w.status,
|
|
153
|
+
formatMillicores(w.cpuMillicores),
|
|
154
|
+
formatBytes(w.ramBytes),
|
|
155
|
+
formatAge(w.createdAt, now),
|
|
156
|
+
])
|
|
157
|
+
console.log(formatTable(["NAME", "STATUS", "CPU", "MEMORY", "AGE"], rows))
|
|
158
|
+
return 0
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------- get
|
|
162
|
+
|
|
163
|
+
async function runGet(args: string[], deps: WorkspaceDeps): Promise<number> {
|
|
164
|
+
const { values, positionals } = parseFlags("workspace get", args, {
|
|
165
|
+
output: { type: "string", short: "o", default: "text" },
|
|
166
|
+
})
|
|
167
|
+
if (positionals.length !== 1) {
|
|
168
|
+
throw new UsageError("kimchictl workspace get: expected exactly one workspace name")
|
|
169
|
+
}
|
|
170
|
+
if (values.output !== "json" && values.output !== "text") {
|
|
171
|
+
throw new UsageError(
|
|
172
|
+
`kimchictl workspace get: unsupported --output "${String(values.output)}" (expected text|json)`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const { key } = requireApiKey(deps.env)
|
|
177
|
+
// Accepts a UUID, full alias, or alias prefix (see api/resolver.ts).
|
|
178
|
+
const workspace = await resolveWorkspace(key, positionals[0] ?? "", { fetch: deps.fetch })
|
|
179
|
+
|
|
180
|
+
if (values.output === "json") {
|
|
181
|
+
console.log(JSON.stringify(serializeWorkspace(workspace), null, 2))
|
|
182
|
+
return 0
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lines = [
|
|
186
|
+
`name: ${workspace.alias}`,
|
|
187
|
+
workspace.description ? `description: ${workspace.description}` : undefined,
|
|
188
|
+
`status: ${workspace.status}`,
|
|
189
|
+
workspace.uri ? `uri: ${workspace.uri}` : undefined,
|
|
190
|
+
`created: ${workspace.createdAt.toISOString()}`,
|
|
191
|
+
`cpu: ${formatMillicores(workspace.cpuMillicores)}`,
|
|
192
|
+
`memory: ${formatBytes(workspace.ramBytes)}`,
|
|
193
|
+
`storage: ${formatBytes(workspace.pvcSizeBytes)}`,
|
|
194
|
+
].filter((line): line is string => line !== undefined)
|
|
195
|
+
for (const line of lines) console.log(line)
|
|
196
|
+
return 0
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------- delete
|
|
200
|
+
|
|
201
|
+
async function runDelete(args: string[], deps: WorkspaceDeps): Promise<number> {
|
|
202
|
+
const { values, positionals } = parseFlags("workspace delete", args, {
|
|
203
|
+
force: { type: "boolean", short: "f" },
|
|
204
|
+
})
|
|
205
|
+
if (positionals.length !== 1) {
|
|
206
|
+
throw new UsageError("kimchictl workspace delete: expected exactly one workspace name")
|
|
207
|
+
}
|
|
208
|
+
const ref = positionals[0] ?? ""
|
|
209
|
+
|
|
210
|
+
const { key } = requireApiKey(deps.env)
|
|
211
|
+
// Resolve before prompting so the confirmation names the real target and
|
|
212
|
+
// typos fail fast instead of after the user answered the prompt.
|
|
213
|
+
const workspace = await resolveWorkspace(key, ref, { fetch: deps.fetch })
|
|
214
|
+
|
|
215
|
+
if (!values.force) {
|
|
216
|
+
const interactive = deps.interactive ?? isInteractive()
|
|
217
|
+
const question = `Delete workspace "${workspace.alias}"? [y/N] `
|
|
218
|
+
const ok = await (deps.confirm ? deps.confirm(question) : interactive ? confirm(question) : false)
|
|
219
|
+
if (!ok) {
|
|
220
|
+
if (!deps.confirm && !interactive) {
|
|
221
|
+
console.error(`kimchictl workspace delete: not interactive — re-run with --force to delete ${workspace.alias}`)
|
|
222
|
+
return 1
|
|
223
|
+
}
|
|
224
|
+
console.log("Aborted.")
|
|
225
|
+
return 0
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
await deleteWorkspace(key, workspace.id, { fetch: deps.fetch })
|
|
230
|
+
console.log(`✓ deleted ${workspace.alias}`)
|
|
231
|
+
return 0
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------- shared
|
|
235
|
+
|
|
236
|
+
function serializeWorkspace(w: Workspace): Record<string, unknown> {
|
|
237
|
+
return {
|
|
238
|
+
id: w.id,
|
|
239
|
+
alias: w.alias,
|
|
240
|
+
...(w.description ? { description: w.description } : {}),
|
|
241
|
+
status: w.status,
|
|
242
|
+
...(w.uri ? { uri: w.uri } : {}),
|
|
243
|
+
createdAt: w.createdAt.getTime() > 0 ? w.createdAt.toISOString() : undefined,
|
|
244
|
+
resources: {
|
|
245
|
+
cpuMillicores: w.cpuMillicores,
|
|
246
|
+
ramBytes: w.ramBytes,
|
|
247
|
+
pvcSizeBytes: w.pvcSizeBytes,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
package/src/run.test.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { run } from "./run.js"
|
|
3
|
+
import { VERSION } from "./version.js"
|
|
4
|
+
|
|
5
|
+
describe("run dispatcher", () => {
|
|
6
|
+
let logLines: string[]
|
|
7
|
+
let errorLines: string[]
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
logLines = []
|
|
11
|
+
errorLines = []
|
|
12
|
+
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
|
13
|
+
logLines.push(args.map(String).join(" "))
|
|
14
|
+
})
|
|
15
|
+
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
16
|
+
errorLines.push(args.map(String).join(" "))
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.restoreAllMocks()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it("prints help and exits 0 with no args", async () => {
|
|
25
|
+
expect(await run([])).toBe(0)
|
|
26
|
+
expect(logLines.some((line) => line.includes("Usage:"))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("prints help for `help`, --help and -h", async () => {
|
|
30
|
+
for (const args of [["help"], ["--help"], ["-h"]]) {
|
|
31
|
+
expect(await run(args)).toBe(0)
|
|
32
|
+
}
|
|
33
|
+
expect(logLines.filter((line) => line.includes("Usage:"))).toHaveLength(3)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("prints the version for the version command and --version / -v", async () => {
|
|
37
|
+
for (const args of [["version"], ["--version"], ["-v"]]) {
|
|
38
|
+
expect(await run(args)).toBe(0)
|
|
39
|
+
}
|
|
40
|
+
expect(logLines).toEqual([`kimchictl ${VERSION}`, `kimchictl ${VERSION}`, `kimchictl ${VERSION}`])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("exits 2 and reports an unknown command, without printing help to stdout", async () => {
|
|
44
|
+
expect(await run(["frobnicate"])).toBe(2)
|
|
45
|
+
expect(errorLines[0]).toBe('kimchictl: unknown command "frobnicate"')
|
|
46
|
+
expect(logLines).toHaveLength(0)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("omits hidden commands from help", async () => {
|
|
50
|
+
await run([])
|
|
51
|
+
expect(logLines.some((line) => line.includes("version"))).toBe(true)
|
|
52
|
+
})
|
|
53
|
+
})
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { printHelp } from "./commands/help.js"
|
|
2
|
+
import { COMMANDS, findCommand } from "./commands/registry.js"
|
|
3
|
+
import { VERSION } from "./version.js"
|
|
4
|
+
|
|
5
|
+
/** Usage errors (unknown command, bad flags) exit with this code. */
|
|
6
|
+
export const EXIT_USAGE = 2
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Shared CLI dispatcher — the single entry behind the standalone `kimchictl`
|
|
10
|
+
* binary, the npm bin shim, and the kimchi harness bridge (`kimchi workspace`).
|
|
11
|
+
* Returns the exit code instead of calling process.exit so hosts can run it
|
|
12
|
+
* in-process.
|
|
13
|
+
*/
|
|
14
|
+
export async function run(argv: readonly string[]): Promise<number> {
|
|
15
|
+
const [head, ...rest] = argv
|
|
16
|
+
|
|
17
|
+
if (head === undefined || head === "help" || head === "--help" || head === "-h") {
|
|
18
|
+
printHelp(COMMANDS)
|
|
19
|
+
return 0
|
|
20
|
+
}
|
|
21
|
+
if (head === "--version" || head === "-v") {
|
|
22
|
+
console.log(`kimchictl ${VERSION}`)
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const command = findCommand(head)
|
|
27
|
+
if (!command) {
|
|
28
|
+
console.error(`kimchictl: unknown command "${head}"`)
|
|
29
|
+
printHelp(COMMANDS, (line) => console.error(line))
|
|
30
|
+
return EXIT_USAGE
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (await command.run(rest)) ?? 0
|
|
34
|
+
}
|