@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,50 @@
|
|
|
1
|
+
import { runCompletion } from "./completion.js"
|
|
2
|
+
import { guardCommand } from "./guard.js"
|
|
3
|
+
import { runLogin } from "./login.js"
|
|
4
|
+
import { runLogout } from "./logout.js"
|
|
5
|
+
import { runSsh } from "./ssh.js"
|
|
6
|
+
import { runUpdate } from "./update.js"
|
|
7
|
+
import { runVersion } from "./version.js"
|
|
8
|
+
import { runWhoami } from "./whoami.js"
|
|
9
|
+
import { runWorkspace } from "./workspace.js"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shape mirrors kimchi-dev's `src/commands/registry.ts` CommandDefinition so
|
|
13
|
+
* the harness bridge can mount kimchictl commands without adaptation.
|
|
14
|
+
*/
|
|
15
|
+
export interface CommandDefinition {
|
|
16
|
+
name: string
|
|
17
|
+
summary: string
|
|
18
|
+
/** Hidden commands stay functional but are omitted from help (internal bridges like `ssh proxy`). */
|
|
19
|
+
hidden?: boolean
|
|
20
|
+
/** Run this command. Receives args after the command name. Return the exit code; undefined means 0. */
|
|
21
|
+
run: (args: string[]) => Promise<number | undefined> | number | undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const COMMANDS: CommandDefinition[] = [
|
|
25
|
+
{ name: "login", summary: "Authenticate via browser (shared with the kimchi harness)", run: guardCommand(runLogin) },
|
|
26
|
+
{ name: "logout", summary: "Sign out everywhere (also signs out the harness)", run: guardCommand(runLogout) },
|
|
27
|
+
{ name: "whoami", summary: "Show the authenticated user", run: guardCommand(runWhoami) },
|
|
28
|
+
{
|
|
29
|
+
name: "workspace",
|
|
30
|
+
summary: "Manage remote workspaces (create/list/get/delete)",
|
|
31
|
+
run: guardCommand(runWorkspace),
|
|
32
|
+
},
|
|
33
|
+
{ name: "ws", summary: "Alias for workspace", hidden: true, run: guardCommand(runWorkspace) },
|
|
34
|
+
{ name: "ssh", summary: "Connect to a workspace over SSH", run: guardCommand(runSsh) },
|
|
35
|
+
{ name: "update", summary: "Check for and install a newer kimchictl release", run: guardCommand(runUpdate) },
|
|
36
|
+
{
|
|
37
|
+
name: "completion",
|
|
38
|
+
summary: "Print shell completions (kimchictl completion bash|zsh|fish)",
|
|
39
|
+
run: guardCommand(runCompletion),
|
|
40
|
+
},
|
|
41
|
+
{ name: "version", summary: "Print the kimchictl version", run: runVersion },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
export function findCommand(name: string): CommandDefinition | undefined {
|
|
45
|
+
return COMMANDS.find((c) => c.name === name)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isKnownCommand(name: string | undefined): boolean {
|
|
49
|
+
return name !== undefined && COMMANDS.some((c) => c.name === name)
|
|
50
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { captureConsole, cleanupTempDirs, makeTempDir } from "../test-support.js"
|
|
5
|
+
import { guardCommand } from "./guard.js"
|
|
6
|
+
import { runSsh } from "./ssh.js"
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
const home = makeTempDir()
|
|
10
|
+
vi.stubEnv("HOME", home)
|
|
11
|
+
vi.stubEnv("KIMCHICTL_HOME", join(home, ".kimchictl"))
|
|
12
|
+
vi.stubEnv("KIMCHI_API_KEY", "key-1")
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanupTempDirs()
|
|
17
|
+
vi.unstubAllEnvs()
|
|
18
|
+
vi.restoreAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const guard = guardCommand(runSsh)
|
|
22
|
+
|
|
23
|
+
describe("kimchictl ssh", () => {
|
|
24
|
+
it("prints usage (exit 2) with no subcommand", async () => {
|
|
25
|
+
const { errors } = captureConsole()
|
|
26
|
+
|
|
27
|
+
const code = await guard([])
|
|
28
|
+
|
|
29
|
+
expect(code).toBe(2)
|
|
30
|
+
expect(errors[0]).toContain("kimchictl ssh <name>")
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("prints help (exit 0) for ssh help", async () => {
|
|
34
|
+
const { lines } = captureConsole()
|
|
35
|
+
|
|
36
|
+
const code = await guard(["help"])
|
|
37
|
+
|
|
38
|
+
expect(code).toBe(0)
|
|
39
|
+
expect(lines[0]).toContain("kimchictl ssh <name>")
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("kimchictl ssh setup", () => {
|
|
44
|
+
it("configures integration, reports idempotency on repeat", async () => {
|
|
45
|
+
const { lines } = captureConsole()
|
|
46
|
+
|
|
47
|
+
const first = await guard(["setup"])
|
|
48
|
+
expect(first).toBe(0)
|
|
49
|
+
expect(lines.some((line) => line.startsWith("✓ SSH integration configured"))).toBe(true)
|
|
50
|
+
const sshConfigPath = join(process.env.KIMCHICTL_HOME ?? "", "ssh_config")
|
|
51
|
+
expect(readFileSync(sshConfigPath, "utf-8")).toContain("Host *.remote.kimchi.dev")
|
|
52
|
+
expect(readFileSync(join(process.env.HOME ?? "", ".ssh", "config"), "utf-8")).toContain("kimchictl managed include")
|
|
53
|
+
|
|
54
|
+
lines.length = 0
|
|
55
|
+
const second = await guard(["setup"])
|
|
56
|
+
expect(second).toBe(0)
|
|
57
|
+
expect(lines[0]).toContain("already configured")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it("honors --domain", async () => {
|
|
61
|
+
const { lines } = captureConsole()
|
|
62
|
+
|
|
63
|
+
await guard(["setup", "--domain", "remote.dev.example"])
|
|
64
|
+
|
|
65
|
+
const sshConfigPath = join(process.env.KIMCHICTL_HOME ?? "", "ssh_config")
|
|
66
|
+
expect(readFileSync(sshConfigPath, "utf-8")).toContain("Host *.remote.dev.example")
|
|
67
|
+
expect(lines.some((line) => line.includes("remote.dev.example"))).toBe(true)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("--uninstall removes the integration", async () => {
|
|
71
|
+
captureConsole()
|
|
72
|
+
await guard(["setup"])
|
|
73
|
+
const userConfigPath = join(process.env.HOME ?? "", ".ssh", "config")
|
|
74
|
+
|
|
75
|
+
const code = await guard(["setup", "--uninstall"])
|
|
76
|
+
|
|
77
|
+
expect(code).toBe(0)
|
|
78
|
+
expect(existsSync(join(process.env.KIMCHICTL_HOME ?? "", "ssh_config"))).toBe(false)
|
|
79
|
+
expect(readFileSync(userConfigPath, "utf-8")).not.toContain("kimchictl managed include")
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it("--uninstall on a fresh machine is a clean no-op", async () => {
|
|
83
|
+
const { lines } = captureConsole()
|
|
84
|
+
|
|
85
|
+
const code = await guard(["setup", "--uninstall"])
|
|
86
|
+
|
|
87
|
+
expect(code).toBe(0)
|
|
88
|
+
expect(lines[0]).toContain("Nothing to remove")
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe("kimchictl ssh proxy (usage validation)", () => {
|
|
93
|
+
it("rejects a missing host and a bad port with usage errors (exit 2)", async () => {
|
|
94
|
+
const { errors } = captureConsole()
|
|
95
|
+
|
|
96
|
+
expect(await guard(["proxy"])).toBe(2)
|
|
97
|
+
expect(await guard(["proxy", "ws-1.remote.kimchi.dev", "--port", "nope"])).toBe(2)
|
|
98
|
+
expect(errors.some((line) => line.includes("one host"))).toBe(true)
|
|
99
|
+
expect(errors.some((line) => line.includes("--port"))).toBe(true)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { verifyApiKey } from "../api/keys.js"
|
|
2
|
+
import { requireApiKey } from "../auth/resolve.js"
|
|
3
|
+
import { bridgeStdioToWebSocket } from "../ssh/bridge.js"
|
|
4
|
+
import {
|
|
5
|
+
isSshIntegrationConfigured,
|
|
6
|
+
resolveSshDomain,
|
|
7
|
+
resolveSshPaths,
|
|
8
|
+
setupSshIntegration,
|
|
9
|
+
uninstallSshIntegration,
|
|
10
|
+
} from "../ssh/config.js"
|
|
11
|
+
import { connectToWorkspace } from "../ssh/connect.js"
|
|
12
|
+
import { resolveTunnel } from "../ssh/tunnel.js"
|
|
13
|
+
import { parseFlags, UsageError } from "./flags.js"
|
|
14
|
+
|
|
15
|
+
export interface SshDeps {
|
|
16
|
+
fetch?: typeof globalThis.fetch
|
|
17
|
+
env?: NodeJS.ProcessEnv
|
|
18
|
+
interactive?: boolean
|
|
19
|
+
/** Exec seam (tests). */
|
|
20
|
+
exec?: (args: string[]) => number
|
|
21
|
+
sleep?: (ms: number) => Promise<void>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const GROUP_USAGE = `Usage:
|
|
25
|
+
kimchictl ssh <name> [command...] Connect to a workspace (auto-configures SSH on first use)
|
|
26
|
+
kimchictl ssh setup [--uninstall] Manage the native SSH integration`
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `kimchictl ssh …` — connect, or manage the SSH integration. The hidden
|
|
30
|
+
* `proxy` subcommand is the ProxyCommand target in the generated ssh_config
|
|
31
|
+
* and is deliberately absent from help (internal plumbing, not user-facing).
|
|
32
|
+
*/
|
|
33
|
+
export async function runSsh(args: string[], deps: SshDeps = {}): Promise<number> {
|
|
34
|
+
const [head, ...rest] = args
|
|
35
|
+
switch (head) {
|
|
36
|
+
case "setup":
|
|
37
|
+
return runSetup(rest, deps)
|
|
38
|
+
case "proxy":
|
|
39
|
+
return runProxy(rest, deps)
|
|
40
|
+
case "help":
|
|
41
|
+
case "--help":
|
|
42
|
+
case "-h":
|
|
43
|
+
console.log(GROUP_USAGE)
|
|
44
|
+
return 0
|
|
45
|
+
case undefined:
|
|
46
|
+
console.error(GROUP_USAGE)
|
|
47
|
+
return 2
|
|
48
|
+
default:
|
|
49
|
+
return connectToWorkspace(head, rest, deps)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ---------------------------------------------------------------- setup
|
|
54
|
+
|
|
55
|
+
async function runSetup(args: string[], deps: SshDeps): Promise<number> {
|
|
56
|
+
const { values, positionals } = parseFlags("ssh setup", args, {
|
|
57
|
+
uninstall: { type: "boolean" },
|
|
58
|
+
domain: { type: "string" },
|
|
59
|
+
})
|
|
60
|
+
if (positionals.length > 0) {
|
|
61
|
+
throw new UsageError("kimchictl ssh setup: unexpected positional argument")
|
|
62
|
+
}
|
|
63
|
+
const env = deps.env ?? process.env
|
|
64
|
+
const paths = resolveSshPaths(env)
|
|
65
|
+
|
|
66
|
+
if (values.uninstall) {
|
|
67
|
+
const { notes } = await uninstallSshIntegration({ paths })
|
|
68
|
+
if (notes.length === 0) {
|
|
69
|
+
console.log("Nothing to remove — SSH integration was not configured.")
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
console.log("✓ SSH integration removed")
|
|
73
|
+
for (const note of notes) console.log(` ${note}`)
|
|
74
|
+
return 0
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const domain = resolveSshDomain(typeof values.domain === "string" && values.domain ? values.domain : undefined, env)
|
|
78
|
+
if (await isSshIntegrationConfigured(paths, domain)) {
|
|
79
|
+
console.log(`SSH integration already configured for *.${domain} (undo: kimchictl ssh setup --uninstall)`)
|
|
80
|
+
return 0
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const { notes } = await setupSshIntegration({ paths, domain })
|
|
84
|
+
console.log("✓ SSH integration configured")
|
|
85
|
+
for (const note of notes) console.log(` ${note}`)
|
|
86
|
+
console.log("") // blank line before the usage hint
|
|
87
|
+
console.log(`You can now run: ssh <workspace-name>.${domain} (scp, VS Code Remote-SSH, …)`)
|
|
88
|
+
return 0
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------- proxy (hidden)
|
|
92
|
+
|
|
93
|
+
async function runProxy(args: string[], deps: SshDeps): Promise<number> {
|
|
94
|
+
const { values, positionals } = parseFlags("ssh proxy", args, {
|
|
95
|
+
port: { type: "string", default: "443" },
|
|
96
|
+
})
|
|
97
|
+
const host = positionals[0]
|
|
98
|
+
if (!host || positionals.length > 1) {
|
|
99
|
+
throw new UsageError("kimchictl ssh proxy: expected one host")
|
|
100
|
+
}
|
|
101
|
+
const port = Number(values.port)
|
|
102
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
103
|
+
throw new UsageError("kimchictl ssh proxy: --port must be an integer 1-65535")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const env = deps.env ?? process.env
|
|
107
|
+
const { key } = requireApiKey(env)
|
|
108
|
+
await verifyApiKey(key, { fetch: deps.fetch })
|
|
109
|
+
const creds = await resolveTunnel(key, host, { port, fetch: deps.fetch })
|
|
110
|
+
await bridgeStdioToWebSocket({ wsUrl: creds.wsUrl, token: creds.token })
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { formatTable } from "./table.js"
|
|
3
|
+
|
|
4
|
+
describe("formatTable", () => {
|
|
5
|
+
it("pads columns to the widest cell and trims trailing whitespace", () => {
|
|
6
|
+
expect(
|
|
7
|
+
formatTable(
|
|
8
|
+
["NAME", "STATUS"],
|
|
9
|
+
[
|
|
10
|
+
["bright-oak-otter", "active"],
|
|
11
|
+
["demo", "suspended"],
|
|
12
|
+
],
|
|
13
|
+
),
|
|
14
|
+
).toBe("NAME STATUS\nbright-oak-otter active\n" + "demo suspended")
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it("renders headers alone when there are no rows", () => {
|
|
18
|
+
expect(formatTable(["A", "B"], [])).toBe("A B")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("tolerates ragged rows", () => {
|
|
22
|
+
expect(formatTable(["A", "B"], [["x"]])).toBe("A B\nx")
|
|
23
|
+
})
|
|
24
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Minimal kubectl-style table renderer: pad columns to the widest cell, two-space gutter. */
|
|
2
|
+
export function formatTable(headers: readonly string[], rows: readonly (readonly string[])[]): string {
|
|
3
|
+
const widths = headers.map((header, i) => Math.max(header.length, ...rows.map((row) => row[i]?.length ?? 0)))
|
|
4
|
+
const line = (cells: readonly string[]) =>
|
|
5
|
+
cells
|
|
6
|
+
.map((cell, i) => cell.padEnd(widths[i] ?? 0))
|
|
7
|
+
.join(" ")
|
|
8
|
+
.trimEnd()
|
|
9
|
+
return [line(headers), ...rows.map(line)].join("\n")
|
|
10
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync } from "node:fs"
|
|
2
|
+
import { writeFile } from "node:fs/promises"
|
|
3
|
+
import { tmpdir } from "node:os"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
6
|
+
import { captureConsole, jsonResponse, stubFetch } from "../test-support.js"
|
|
7
|
+
import { runUpdate, type UpdateDeps } from "./update.js"
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
vi.restoreAllMocks()
|
|
11
|
+
vi.unstubAllEnvs()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
function latestReleaseFetch(version: string): typeof globalThis.fetch {
|
|
15
|
+
return stubFetch(async (url) => {
|
|
16
|
+
if (url.includes("releases/latest")) return jsonResponse({ tag_name: version })
|
|
17
|
+
throw new Error(`unexpected request: ${url}`)
|
|
18
|
+
}) as typeof globalThis.fetch
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DIRECTED_DEPS: { [method: string]: Partial<UpdateDeps> } = {
|
|
22
|
+
homebrew: { installMethod: "homebrew" },
|
|
23
|
+
runtime: { installMethod: "runtime" },
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("kimchictl update", () => {
|
|
27
|
+
it("delegates to Homebrew for brew installs", async () => {
|
|
28
|
+
const { lines } = captureConsole()
|
|
29
|
+
|
|
30
|
+
const code = await runUpdate([], DIRECTED_DEPS.homebrew as UpdateDeps)
|
|
31
|
+
|
|
32
|
+
expect(code).toBe(0)
|
|
33
|
+
expect(lines.join("\n")).toContain("brew upgrade kimchictl")
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("delegates to npm for runtime installs", async () => {
|
|
37
|
+
const { lines } = captureConsole()
|
|
38
|
+
|
|
39
|
+
const code = await runUpdate([], DIRECTED_DEPS.runtime as UpdateDeps)
|
|
40
|
+
|
|
41
|
+
expect(code).toBe(0)
|
|
42
|
+
expect(lines.join("\n")).toContain("npm update -g kimchictl")
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("skips self-update for dev builds", async () => {
|
|
46
|
+
const { lines } = captureConsole()
|
|
47
|
+
|
|
48
|
+
const code = await runUpdate([], { installMethod: "binary", currentVersion: "0.0.0-dev" })
|
|
49
|
+
|
|
50
|
+
expect(code).toBe(0)
|
|
51
|
+
expect(lines.join("\n")).toContain("Dev build")
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("reports when already up to date", async () => {
|
|
55
|
+
const { lines } = captureConsole()
|
|
56
|
+
|
|
57
|
+
const code = await runUpdate([], {
|
|
58
|
+
installMethod: "binary",
|
|
59
|
+
currentVersion: "0.3.0",
|
|
60
|
+
fetch: latestReleaseFetch("0.3.0"),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
expect(code).toBe(0)
|
|
64
|
+
expect(lines.join("\n")).toContain("already up to date")
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("downloads and swaps in the new binary", async () => {
|
|
68
|
+
const { lines } = captureConsole()
|
|
69
|
+
const dir = mkdtempSync(join(tmpdir(), "kimchictl-update-test-"))
|
|
70
|
+
const target = join(dir, "kimchictl")
|
|
71
|
+
const payload = Buffer.from("#!/bin/fake new kimchictl\n")
|
|
72
|
+
|
|
73
|
+
const code = await runUpdate([], {
|
|
74
|
+
installMethod: "binary",
|
|
75
|
+
currentVersion: "0.2.0",
|
|
76
|
+
targetPath: target,
|
|
77
|
+
fetch: stubFetch(async (url) => {
|
|
78
|
+
if (url.includes("releases/latest")) return jsonResponse({ tag_name: "0.3.0" })
|
|
79
|
+
throw new Error(`unexpected request: ${url}`)
|
|
80
|
+
}) as typeof globalThis.fetch,
|
|
81
|
+
download: async (url, dest) => {
|
|
82
|
+
if (url.endsWith("checksums.txt")) throw new Error("404")
|
|
83
|
+
await writeFile(dest, payload)
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
expect(code).toBe(0)
|
|
88
|
+
expect(readFileSync(target)).toEqual(payload)
|
|
89
|
+
expect(lines.join("\n")).toContain("✓ kimchictl 0.3.0 installed")
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { fetchWithTimeout } from "../api/http.js"
|
|
2
|
+
import { defaultInstallMethod, type InstallMethod, platformAssetName, selfUpdateToVersion } from "../updates/self.js"
|
|
3
|
+
import { VERSION } from "../version.js"
|
|
4
|
+
import { parseFlags, UsageError } from "./flags.js"
|
|
5
|
+
|
|
6
|
+
export interface UpdateDeps {
|
|
7
|
+
fetch?: typeof globalThis.fetch
|
|
8
|
+
env?: NodeJS.ProcessEnv
|
|
9
|
+
/** Injection seams (tests). */
|
|
10
|
+
installMethod?: InstallMethod
|
|
11
|
+
download?: (url: string, destPath: string) => Promise<void>
|
|
12
|
+
targetPath?: string
|
|
13
|
+
currentVersion?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LATEST_RELEASE_URL = "https://api.github.com/repos/getkimchi/kimchictl/releases/latest"
|
|
17
|
+
|
|
18
|
+
async function fetchLatestVersion(fetchImpl: typeof globalThis.fetch): Promise<string | undefined> {
|
|
19
|
+
const resp = await fetchWithTimeout(
|
|
20
|
+
LATEST_RELEASE_URL,
|
|
21
|
+
{ headers: { "User-Agent": `kimchictl/${VERSION}` } },
|
|
22
|
+
fetchImpl,
|
|
23
|
+
15_000,
|
|
24
|
+
)
|
|
25
|
+
if (!resp.ok) return undefined
|
|
26
|
+
const data = (await resp.json()) as { tag_name?: string }
|
|
27
|
+
return typeof data.tag_name === "string" ? data.tag_name.replace(/^v/, "") : undefined
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* `kimchictl update` — self-updates binary installs in place, tells brew/npm
|
|
32
|
+
* users the command their package manager owns.
|
|
33
|
+
*/
|
|
34
|
+
export async function runUpdate(args: string[], deps: UpdateDeps = {}): Promise<number> {
|
|
35
|
+
const { positionals } = parseFlags("update", args, {})
|
|
36
|
+
if (positionals.length > 0) {
|
|
37
|
+
throw new UsageError("kimchictl update: unexpected positional argument")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const method = deps.installMethod ?? defaultInstallMethod()
|
|
41
|
+
|
|
42
|
+
if (method === "homebrew") {
|
|
43
|
+
console.log("kimchictl was installed via Homebrew — update with:")
|
|
44
|
+
console.log("")
|
|
45
|
+
console.log(" brew upgrade kimchictl")
|
|
46
|
+
return 0
|
|
47
|
+
}
|
|
48
|
+
if (method === "runtime") {
|
|
49
|
+
console.log("This kimchictl runs under a JS runtime (npm install or dev checkout) — update with:")
|
|
50
|
+
console.log("")
|
|
51
|
+
console.log(" npm update -g kimchictl # npm users")
|
|
52
|
+
console.log(" git pull && pnpm install # source checkouts")
|
|
53
|
+
return 0
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const current = deps.currentVersion ?? VERSION
|
|
57
|
+
if (current.endsWith("-dev")) {
|
|
58
|
+
console.log("Dev build — self-update only works for released binaries.")
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fetchImpl = deps.fetch ?? globalThis.fetch
|
|
63
|
+
const latest = await fetchLatestVersion(fetchImpl)
|
|
64
|
+
if (latest === undefined) {
|
|
65
|
+
console.error("✗ could not determine the latest release (GitHub API unavailable)")
|
|
66
|
+
return 1
|
|
67
|
+
}
|
|
68
|
+
if (latest === current) {
|
|
69
|
+
console.log(`kimchictl ${current} — already up to date.`)
|
|
70
|
+
return 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const targetPath = deps.targetPath ?? process.execPath
|
|
74
|
+
console.log(`updating kimchictl ${current} → ${latest} (${platformAssetName()})…`)
|
|
75
|
+
await selfUpdateToVersion({ targetPath, version: latest, fetch: fetchImpl, download: deps.download })
|
|
76
|
+
console.log(`✓ kimchictl ${latest} installed at ${targetPath}`)
|
|
77
|
+
return 0
|
|
78
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { runWhoami } from "./whoami.js"
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanupTempDirs()
|
|
17
|
+
vi.unstubAllEnvs()
|
|
18
|
+
vi.restoreAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const guardedWhoami = guardCommand(runWhoami)
|
|
22
|
+
|
|
23
|
+
function seedAuth(dir: string): void {
|
|
24
|
+
writeFileSync(join(dir, "auth.json"), JSON.stringify({ "kimchi-dev": { type: "api_key", key: "key" } }))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("kimchictl whoami", () => {
|
|
28
|
+
it("prints the profile (text) and the key source on stderr", async () => {
|
|
29
|
+
const dir = makeTempDir()
|
|
30
|
+
stubAgentDirEnv(dir)
|
|
31
|
+
seedAuth(dir)
|
|
32
|
+
const { lines, errors } = captureConsole()
|
|
33
|
+
const fetch = stubFetch(async (url) => {
|
|
34
|
+
expect(url).toBe("https://app.kimchi.dev/api/v1/me")
|
|
35
|
+
return jsonResponse({ id: "user-1", username: "mike", email: "m@example.com" })
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
expect(await guardedWhoami([], { fetch })).toBe(0)
|
|
39
|
+
expect(lines).toEqual(["id: user-1", "username: mike", "email: m@example.com"])
|
|
40
|
+
expect(errors).toEqual(["(authenticated via auth.json)"])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("prints JSON with --output json", async () => {
|
|
44
|
+
const dir = makeTempDir()
|
|
45
|
+
stubAgentDirEnv(dir)
|
|
46
|
+
seedAuth(dir)
|
|
47
|
+
const { lines } = captureConsole()
|
|
48
|
+
const fetch = stubFetch(async () => jsonResponse({ id: "user-1", email: "m@example.com" }))
|
|
49
|
+
|
|
50
|
+
expect(await guardedWhoami(["--output", "json"], { fetch })).toBe(0)
|
|
51
|
+
expect(lines).toEqual([JSON.stringify({ id: "user-1", email: "m@example.com" })])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("reports not-logged-in via the guard (no stack trace)", async () => {
|
|
55
|
+
stubAgentDirEnv(makeTempDir())
|
|
56
|
+
const { errors } = captureConsole()
|
|
57
|
+
|
|
58
|
+
expect(await guardedWhoami([])).toBe(1)
|
|
59
|
+
expect(errors[0]).toMatch(/Not logged in/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("rejects an unknown --output flavor as usage error", async () => {
|
|
63
|
+
const dir = makeTempDir()
|
|
64
|
+
stubAgentDirEnv(dir)
|
|
65
|
+
seedAuth(dir)
|
|
66
|
+
const { errors } = captureConsole()
|
|
67
|
+
const fetch = stubFetch(async () => jsonResponse({ id: "u1" }))
|
|
68
|
+
|
|
69
|
+
expect(await guardedWhoami(["--output", "yaml"], { fetch })).toBe(2)
|
|
70
|
+
expect(errors[0]).toMatch(/unsupported --output "yaml"/)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { getMe } from "../api/me.js"
|
|
2
|
+
import { requireApiKey } from "../auth/resolve.js"
|
|
3
|
+
import { parseFlags } from "./flags.js"
|
|
4
|
+
|
|
5
|
+
export interface WhoamiDeps {
|
|
6
|
+
fetch?: typeof globalThis.fetch
|
|
7
|
+
env?: NodeJS.ProcessEnv
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `kimchictl whoami` — show the authenticated user (GET /v1/me). */
|
|
11
|
+
export async function runWhoami(args: string[], deps: WhoamiDeps = {}): Promise<number> {
|
|
12
|
+
const { values } = parseFlags("whoami", args, {
|
|
13
|
+
output: { type: "string", short: "o", default: "text" },
|
|
14
|
+
})
|
|
15
|
+
const env = deps.env ?? process.env
|
|
16
|
+
|
|
17
|
+
const { key, source } = requireApiKey(env)
|
|
18
|
+
if (values.output !== "json" && values.output !== "text") {
|
|
19
|
+
console.error(`kimchictl whoami: unsupported --output "${values.output}" (expected text|json)`)
|
|
20
|
+
return 2
|
|
21
|
+
}
|
|
22
|
+
const me = await getMe(key, { fetch: deps.fetch })
|
|
23
|
+
|
|
24
|
+
if (values.output === "json") {
|
|
25
|
+
console.log(JSON.stringify(me))
|
|
26
|
+
} else {
|
|
27
|
+
console.log(`id: ${me.id}`)
|
|
28
|
+
if (me.username) console.log(`username: ${me.username}`)
|
|
29
|
+
if (me.email) console.log(`email: ${me.email}`)
|
|
30
|
+
// Key source goes to stderr so stdout stays machine-parseable.
|
|
31
|
+
console.error(
|
|
32
|
+
`(authenticated via ${source === "environment" ? "KIMCHI_API_KEY" : source === "config" ? "config.json" : "auth.json"})`,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return 0
|
|
36
|
+
}
|