@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,123 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
4
|
+
import { cleanupTempDirs, jsonResponse, makeTempDir, stubFetch } from "../test-support.js"
|
|
5
|
+
import { connectToWorkspace } from "./connect.js"
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
const home = makeTempDir()
|
|
9
|
+
vi.stubEnv("HOME", home)
|
|
10
|
+
vi.stubEnv("KIMCHICTL_HOME", join(home, ".kimchictl"))
|
|
11
|
+
vi.stubEnv("KIMCHI_API_KEY", "key-1")
|
|
12
|
+
vi.spyOn(console, "error").mockImplementation(() => {})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
cleanupTempDirs()
|
|
17
|
+
vi.unstubAllEnvs()
|
|
18
|
+
vi.restoreAllMocks()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const UUID = "3d3dc322-dd05-4d0c-adab-beb2a0819e69"
|
|
22
|
+
|
|
23
|
+
function wsJson(status: string): Record<string, unknown> {
|
|
24
|
+
return {
|
|
25
|
+
id: UUID,
|
|
26
|
+
description: "",
|
|
27
|
+
status,
|
|
28
|
+
createTime: "2026-01-01T12:00:00Z",
|
|
29
|
+
uri: "ws-1.remote.kimchi.dev",
|
|
30
|
+
spec: { resources: { cpu: "500m", memory: "1Gi" } },
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function makeFetch(options?: { suspendedFirst?: boolean }): { fetch: typeof globalThis.fetch; calls: string[] } {
|
|
35
|
+
const calls: string[] = []
|
|
36
|
+
let resumed = false
|
|
37
|
+
const suspendedFirst = options?.suspendedFirst === true
|
|
38
|
+
const fetch = stubFetch(async (url) => {
|
|
39
|
+
calls.push(url)
|
|
40
|
+
if (url.endsWith("/workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
|
|
41
|
+
if (url.includes("?page.limit=")) {
|
|
42
|
+
// The list snapshot decides the initial status.
|
|
43
|
+
return jsonResponse({ items: [wsJson(suspendedFirst && !resumed ? "SUSPENDED" : "ACTIVE")] })
|
|
44
|
+
}
|
|
45
|
+
if (url.endsWith(`/${UUID}`)) {
|
|
46
|
+
// Read-through polls (post-resume wait) see the current state.
|
|
47
|
+
return jsonResponse(wsJson(resumed || !suspendedFirst ? "ACTIVE" : "SUSPENDED"))
|
|
48
|
+
}
|
|
49
|
+
if (url.endsWith(`/${UUID}:resume`)) {
|
|
50
|
+
resumed = true
|
|
51
|
+
return jsonResponse({})
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`unexpected request: ${url}`)
|
|
54
|
+
})
|
|
55
|
+
return { fetch, calls }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
describe("connectToWorkspace", () => {
|
|
59
|
+
it("execs ssh to <alias>.<domain> with the remote command, no resume when ACTIVE", async () => {
|
|
60
|
+
const { fetch, calls } = makeFetch()
|
|
61
|
+
const execArgs: string[][] = []
|
|
62
|
+
|
|
63
|
+
const code = await connectToWorkspace("ws-1", ["uptime"], {
|
|
64
|
+
fetch,
|
|
65
|
+
exec: (args) => {
|
|
66
|
+
execArgs.push(args)
|
|
67
|
+
return 0
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
expect(code).toBe(0)
|
|
72
|
+
expect(execArgs).toEqual([["ssh", "ws-1.remote.kimchi.dev", "uptime"]])
|
|
73
|
+
expect(calls.some((c) => c.endsWith(":resume"))).toBe(false)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("accepts a full hostname and still connects by alias", async () => {
|
|
77
|
+
const { fetch } = makeFetch()
|
|
78
|
+
const execArgs: string[][] = []
|
|
79
|
+
|
|
80
|
+
await connectToWorkspace("ws-1.remote.kimchi.dev", [], {
|
|
81
|
+
fetch,
|
|
82
|
+
exec: (args) => {
|
|
83
|
+
execArgs.push(args)
|
|
84
|
+
return 0
|
|
85
|
+
},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
expect(execArgs).toEqual([["ssh", "ws-1.remote.kimchi.dev"]])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("auto-configures SSH integration on first use", async () => {
|
|
92
|
+
const { fetch } = makeFetch()
|
|
93
|
+
await connectToWorkspace("ws-1", [], { fetch, exec: () => 0 })
|
|
94
|
+
|
|
95
|
+
expect(existsSync(join(process.env.KIMCHICTL_HOME ?? "", "ssh_config"))).toBe(true)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("skips auto-setup when KIMCHICTL_NO_SSH_SETUP is set", async () => {
|
|
99
|
+
vi.stubEnv("KIMCHICTL_NO_SSH_SETUP", "1")
|
|
100
|
+
const { fetch } = makeFetch()
|
|
101
|
+
await connectToWorkspace("ws-1", [], { fetch, exec: () => 0 })
|
|
102
|
+
|
|
103
|
+
expect(existsSync(join(process.env.KIMCHICTL_HOME ?? "", "ssh_config"))).toBe(false)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("resumes a suspended workspace, waits for ACTIVE, then connects", async () => {
|
|
107
|
+
const { fetch, calls } = makeFetch({ suspendedFirst: true })
|
|
108
|
+
const execArgs: string[][] = []
|
|
109
|
+
|
|
110
|
+
const code = await connectToWorkspace("ws-1", [], {
|
|
111
|
+
fetch,
|
|
112
|
+
sleep: async () => {},
|
|
113
|
+
exec: (args) => {
|
|
114
|
+
execArgs.push(args)
|
|
115
|
+
return 0
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
expect(code).toBe(0)
|
|
120
|
+
expect(calls.some((c) => c.endsWith(":resume"))).toBe(true)
|
|
121
|
+
expect(execArgs.length).toBe(1)
|
|
122
|
+
})
|
|
123
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process"
|
|
2
|
+
import { resolveWorkspace } from "../api/resolver.js"
|
|
3
|
+
import type { ApiOptions } from "../api/types.js"
|
|
4
|
+
import { waitForWorkspaceActive } from "../api/wait.js"
|
|
5
|
+
import { requireApiKey } from "../auth/resolve.js"
|
|
6
|
+
import {
|
|
7
|
+
isSshIntegrationConfigured,
|
|
8
|
+
isSshSetupDisabled,
|
|
9
|
+
resolveSshDomain,
|
|
10
|
+
resolveSshPaths,
|
|
11
|
+
setupSshIntegration,
|
|
12
|
+
} from "./config.js"
|
|
13
|
+
import { resumeForConnect, workspaceIdFromRef } from "./tunnel.js"
|
|
14
|
+
|
|
15
|
+
/** Post-resume wait budget for a hibernated workspace to reach ACTIVE. */
|
|
16
|
+
const RESUME_WAIT_TIMEOUT_MS = 90_000
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Validate that native SSH integration is configured; set it up automatically
|
|
20
|
+
* when missing (the "setup on use" half of the setup-on-install decision).
|
|
21
|
+
* Returns false when auto-setup is disabled via KIMCHICTL_NO_SSH_SETUP.
|
|
22
|
+
*/
|
|
23
|
+
export async function ensureSshIntegration(options: {
|
|
24
|
+
env?: NodeJS.ProcessEnv
|
|
25
|
+
domain?: string
|
|
26
|
+
/** Injection seams for tests. */
|
|
27
|
+
setup?: typeof setupSshIntegration
|
|
28
|
+
isConfigured?: typeof isSshIntegrationConfigured
|
|
29
|
+
}): Promise<boolean> {
|
|
30
|
+
const env = options.env ?? process.env
|
|
31
|
+
if (isSshSetupDisabled(env)) return false
|
|
32
|
+
|
|
33
|
+
const paths = resolveSshPaths(env)
|
|
34
|
+
const domain = resolveSshDomain(options.domain, env)
|
|
35
|
+
const isConfigured = options.isConfigured ?? isSshIntegrationConfigured
|
|
36
|
+
if (await isConfigured(paths, domain)) return true
|
|
37
|
+
|
|
38
|
+
const setup = options.setup ?? setupSshIntegration
|
|
39
|
+
const { notes } = await setup({ paths, domain })
|
|
40
|
+
console.error("✓ SSH integration configured")
|
|
41
|
+
for (const note of notes) console.error(` ${note}`)
|
|
42
|
+
console.error(" undo: kimchictl ssh setup --uninstall")
|
|
43
|
+
return true
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SshConnectOptions extends ApiOptions {
|
|
47
|
+
env?: NodeJS.ProcessEnv
|
|
48
|
+
/** Exec seam (tests): run system ssh, return its exit status. */
|
|
49
|
+
exec?: (args: string[]) => number
|
|
50
|
+
sleep?: (ms: number) => Promise<void>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function defaultExec(args: string[]): number {
|
|
54
|
+
return spawnSync(args[0] ?? "ssh", args.slice(1), { stdio: "inherit", shell: false }).status ?? 1
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Connect to a workspace with system ssh: ensure integration → resume a
|
|
59
|
+
* hibernated workspace (and wait for it) → exec `ssh <alias>.<domain>` with
|
|
60
|
+
* inherited stdio. Extra args are passed to ssh as the remote command.
|
|
61
|
+
*/
|
|
62
|
+
export async function connectToWorkspace(
|
|
63
|
+
name: string,
|
|
64
|
+
remoteCommand: string[],
|
|
65
|
+
options: SshConnectOptions = {},
|
|
66
|
+
): Promise<number> {
|
|
67
|
+
const env = options.env ?? process.env
|
|
68
|
+
await ensureSshIntegration({ env })
|
|
69
|
+
|
|
70
|
+
const { key } = requireApiKey(env)
|
|
71
|
+
|
|
72
|
+
// Accepts "<alias>", "<alias>.<domain>", a UUID, or an alias prefix.
|
|
73
|
+
const ref = workspaceIdFromRef(name)
|
|
74
|
+
let workspace = await resolveWorkspace(key, ref, options)
|
|
75
|
+
if (workspace.status === "suspended" || workspace.status === "initializing") {
|
|
76
|
+
if (workspace.status === "suspended") {
|
|
77
|
+
await resumeForConnect(key, workspace.id, options)
|
|
78
|
+
}
|
|
79
|
+
process.stderr.write(" resuming…")
|
|
80
|
+
workspace = await waitForWorkspaceActive(key, workspace.id, {
|
|
81
|
+
...options,
|
|
82
|
+
timeoutMs: RESUME_WAIT_TIMEOUT_MS,
|
|
83
|
+
sleep: options.sleep,
|
|
84
|
+
onTick: (elapsedSeconds) => process.stderr.write(`\r resuming… ${elapsedSeconds}s`),
|
|
85
|
+
})
|
|
86
|
+
process.stderr.write("\n")
|
|
87
|
+
if (workspace.status !== "active") {
|
|
88
|
+
process.stderr.write(
|
|
89
|
+
` ⚠ workspace is ${workspace.status} — connecting anyway; ` +
|
|
90
|
+
`if it fails, retry in a minute or check: kimchictl workspace get ${workspace.alias}\n`,
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const domain = resolveSshDomain(undefined, env)
|
|
96
|
+
const host = `${workspace.alias}.${domain}`
|
|
97
|
+
return (options.exec ?? defaultExec)(["ssh", host, ...remoteCommand])
|
|
98
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { jsonResponse, stubFetch } from "../test-support.js"
|
|
3
|
+
import { buildWsUrl, resolveTunnel, resumeForConnect, workspaceIdFromRef } from "./tunnel.js"
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
vi.unstubAllEnvs()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const UUID = "3d3dc322-dd05-4d0c-adab-beb2a0819e69"
|
|
10
|
+
|
|
11
|
+
const WS_JSON = {
|
|
12
|
+
id: UUID,
|
|
13
|
+
description: "",
|
|
14
|
+
status: "ACTIVE",
|
|
15
|
+
createTime: "2026-01-01T12:00:00Z",
|
|
16
|
+
uri: "ws-1.remote.kimchi.dev",
|
|
17
|
+
spec: { resources: { cpu: "500m", memory: "1Gi" } },
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Call {
|
|
21
|
+
url: string
|
|
22
|
+
init?: RequestInit
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("workspaceIdFromRef", () => {
|
|
26
|
+
it("keeps bare refs and strips a domain suffix", () => {
|
|
27
|
+
expect(workspaceIdFromRef("ws-1")).toBe("ws-1")
|
|
28
|
+
expect(workspaceIdFromRef("ws-1.remote.kimchi.dev")).toBe("ws-1")
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe("buildWsUrl", () => {
|
|
33
|
+
it("builds wss://<host>/ssh, no port suffix for 443", () => {
|
|
34
|
+
expect(buildWsUrl("ws-1.remote.kimchi.dev")).toBe("wss://ws-1.remote.kimchi.dev/ssh")
|
|
35
|
+
expect(buildWsUrl("wss://ws-1.remote.kimchi.dev/ws/ssh-proxy")).toBe("wss://ws-1.remote.kimchi.dev/ssh")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("appends a non-443 port", () => {
|
|
39
|
+
expect(buildWsUrl("ws-1.remote.kimchi.dev", 8443)).toBe("wss://ws-1.remote.kimchi.dev:8443/ssh")
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe("resolveTunnel", () => {
|
|
44
|
+
it("resolves the alias via the workspace list, exchanges a token for the UUID, and assembles the WS URL", async () => {
|
|
45
|
+
const calls: Call[] = []
|
|
46
|
+
const fetch = stubFetch(async (url, init) => {
|
|
47
|
+
calls.push({ url, init })
|
|
48
|
+
if (url.endsWith("/workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
|
|
49
|
+
if (url.includes("?page.limit=")) return jsonResponse({ items: [WS_JSON] })
|
|
50
|
+
if (url.endsWith("/workspace-tokens:exchange")) {
|
|
51
|
+
return jsonResponse({ token: "ws-jwt-1", expireTime: "2026-01-01T01:00:00Z" })
|
|
52
|
+
}
|
|
53
|
+
throw new Error(`unexpected request: ${url}`)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const creds = await resolveTunnel("key-1", "ws-1.remote.kimchi.dev", { fetch })
|
|
57
|
+
|
|
58
|
+
expect(creds).toEqual({ wsUrl: "wss://ws-1.remote.kimchi.dev/ssh", token: "ws-jwt-1" })
|
|
59
|
+
const exchange = calls.find((c) => c.url.endsWith(":exchange"))
|
|
60
|
+
expect(JSON.parse(String(exchange?.init?.body))).toEqual({ workspaceId: UUID })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("goes straight to GET for a UUID ref", async () => {
|
|
64
|
+
const fetch = stubFetch(async (url) => {
|
|
65
|
+
if (url.endsWith("/workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
|
|
66
|
+
if (url.endsWith(`/workspaces/${UUID}`)) return jsonResponse(WS_JSON)
|
|
67
|
+
if (url.endsWith("/workspace-tokens:exchange")) return jsonResponse({ token: "ws-jwt-1" })
|
|
68
|
+
throw new Error(`unexpected request: ${url}`)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const creds = await resolveTunnel("key-1", UUID, { fetch })
|
|
72
|
+
|
|
73
|
+
expect(creds).toEqual({ wsUrl: "wss://ws-1.remote.kimchi.dev/ssh", token: "ws-jwt-1" })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("surfaces when the workspace has no URI yet", async () => {
|
|
77
|
+
// An alias cannot exist without a URI (alias falls back to the id), so the
|
|
78
|
+
// no-URI state is reachable via a UUID ref to a still-provisioning workspace.
|
|
79
|
+
const fetch = stubFetch(async (url) => {
|
|
80
|
+
if (url.endsWith("/workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
|
|
81
|
+
if (url.endsWith(`/workspaces/${UUID}`)) {
|
|
82
|
+
return jsonResponse({ ...WS_JSON, status: "INITIALIZING", uri: "" })
|
|
83
|
+
}
|
|
84
|
+
throw new Error(`unexpected request: ${url}`)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await expect(resolveTunnel("key-1", UUID, { fetch })).rejects.toMatchObject({
|
|
88
|
+
message: expect.stringContaining("no connection URI"),
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe("resumeForConnect", () => {
|
|
94
|
+
it("POSTs resume with the api key", async () => {
|
|
95
|
+
const calls: Call[] = []
|
|
96
|
+
const fetch = stubFetch(async (url, init) => {
|
|
97
|
+
calls.push({ url, init })
|
|
98
|
+
if (url.endsWith("/workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
|
|
99
|
+
if (url.endsWith(`/workspaces/${UUID}:resume`)) return jsonResponse({})
|
|
100
|
+
throw new Error(`unexpected request: ${url}`)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
await resumeForConnect("key-1", UUID, { fetch })
|
|
104
|
+
|
|
105
|
+
const resume = calls.find((c) => c.url.endsWith(":resume"))
|
|
106
|
+
expect(resume?.init?.method).toBe("POST")
|
|
107
|
+
expect(resume?.init?.headers).toMatchObject({ Authorization: "Bearer key-1" })
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it("tolerates 'not suspended' 400s (already running)", async () => {
|
|
111
|
+
const fetch = stubFetch(async (url) => {
|
|
112
|
+
if (url.endsWith("/workspace-tokens:verifyKey")) return jsonResponse({ organizationId: "org-1" })
|
|
113
|
+
if (url.endsWith(`/workspaces/${UUID}:resume`)) return new Response("workspace is not suspended", { status: 400 })
|
|
114
|
+
throw new Error(`unexpected request: ${url}`)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
await expect(resumeForConnect("key-1", UUID, { fetch })).resolves.toBeUndefined()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { verifyApiKey } from "../api/keys.js"
|
|
2
|
+
import { resolveWorkspace } from "../api/resolver.js"
|
|
3
|
+
import { exchangeWorkspaceToken, resumeWorkspace } from "../api/tokens.js"
|
|
4
|
+
import type { ApiOptions } from "../api/types.js"
|
|
5
|
+
import { RemoteNetworkError } from "../api/types.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tunnel resolution for the SSH bridge (TS port of kap's
|
|
9
|
+
* internal/ssh/bridge.go ResolveTunnel — refs resolve via api/resolver.ts).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface TunnelCreds {
|
|
13
|
+
/** wss://<workspace-host>/ssh */
|
|
14
|
+
wsUrl: string
|
|
15
|
+
/** Short-lived workspace JWT for the Authorization header. */
|
|
16
|
+
token: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Accepts a bare workspace id or a full hostname (id.domain) — the domain
|
|
21
|
+
* suffix is stripped, which is what makes this usable as the ProxyCommand
|
|
22
|
+
* target (`%h` expands to the full hostname).
|
|
23
|
+
*/
|
|
24
|
+
export function workspaceIdFromRef(wsRef: string): string {
|
|
25
|
+
const dot = wsRef.indexOf(".")
|
|
26
|
+
return dot > 0 ? wsRef.slice(0, dot) : wsRef
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** wss://{uri}/ssh — explicit port appended only when not 0/443 (kap parity). */
|
|
30
|
+
export function buildWsUrl(uri: string, port = 443): string {
|
|
31
|
+
// The API may return a bare host or a scheme-prefixed URI; normalize to host[:port].
|
|
32
|
+
const host = uri.includes("://") ? new URL(uri).host : uri
|
|
33
|
+
if (!host) throw new RemoteNetworkError(`Invalid workspace URI from server: ${uri}`)
|
|
34
|
+
return `wss://${host}${port === 0 || port === 443 ? "" : `:${port}`}/ssh`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ResolveTunnelOptions extends ApiOptions {
|
|
38
|
+
/** WebSocket port override (dev; default 443). */
|
|
39
|
+
port?: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function resolveTunnel(
|
|
43
|
+
apiKey: string,
|
|
44
|
+
wsRef: string,
|
|
45
|
+
options?: ResolveTunnelOptions,
|
|
46
|
+
): Promise<TunnelCreds> {
|
|
47
|
+
const ref = workspaceIdFromRef(wsRef)
|
|
48
|
+
const workspace = await resolveWorkspace(apiKey, ref, options)
|
|
49
|
+
if (!workspace.uri) {
|
|
50
|
+
throw new RemoteNetworkError(`Workspace ${ref} has no connection URI yet — it may still be provisioning`)
|
|
51
|
+
}
|
|
52
|
+
const { token } = await exchangeWorkspaceToken(apiKey, workspace.id, options)
|
|
53
|
+
return { wsUrl: buildWsUrl(workspace.uri, options?.port), token }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Wake a hibernated workspace. Best-effort by contract: resume is idempotent server-side. */
|
|
57
|
+
export async function resumeForConnect(
|
|
58
|
+
apiKey: string,
|
|
59
|
+
workspaceId: string,
|
|
60
|
+
options?: ApiOptions & { orgId?: string },
|
|
61
|
+
): Promise<void> {
|
|
62
|
+
const orgId = options?.orgId ?? (await verifyApiKey(apiKey, options))
|
|
63
|
+
await resumeWorkspace(apiKey, workspaceId, { ...options, orgId })
|
|
64
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared test helpers — co-located *.test.ts files import from here instead of
|
|
3
|
+
* re-rolling temp-dir / fetch-stub / console-capture boilerplate per file.
|
|
4
|
+
* Excluded from the published build (tsconfig.build.json).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
8
|
+
import { tmpdir } from "node:os"
|
|
9
|
+
import { dirname, join } from "node:path"
|
|
10
|
+
import { vi } from "vitest"
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = []
|
|
13
|
+
|
|
14
|
+
export function makeTempDir(prefix = "kimchictl-test-"): string {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), prefix))
|
|
16
|
+
tempDirs.push(dir)
|
|
17
|
+
return dir
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Call from afterEach — removes every dir created via makeTempDir. */
|
|
21
|
+
export function cleanupTempDirs(): void {
|
|
22
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type FetchImpl = typeof globalThis.fetch
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a fetch stub from a narrow implementation. The `as unknown` adapts the
|
|
29
|
+
* test's string-URL signature to fetch's wide `(string | URL | Request)` one —
|
|
30
|
+
* deliberate, one place, test-only.
|
|
31
|
+
*/
|
|
32
|
+
export function stubFetch(impl: (url: string, init?: RequestInit) => Promise<Response>): FetchImpl {
|
|
33
|
+
return impl as unknown as FetchImpl
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function jsonResponse(body: unknown, status = 200): Response {
|
|
37
|
+
return new Response(JSON.stringify(body), {
|
|
38
|
+
status,
|
|
39
|
+
headers: { "Content-Type": "application/json" },
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Point the shared-credential paths at an isolated HOME + agent dir and neutralize ambient key env vars. Returns the stubbed HOME — the shared config.json lives at `<home>/.config/kimchi/config.json` (sibling of the harness agent dir, matching kimchi-dev:src/config.ts). */
|
|
44
|
+
export function stubAgentDirEnv(dir: string): { home: string } {
|
|
45
|
+
const home = makeTempDir()
|
|
46
|
+
vi.stubEnv("HOME", home)
|
|
47
|
+
vi.stubEnv("KIMCHI_CODING_AGENT_DIR", dir)
|
|
48
|
+
// resolveApiKey treats an empty KIMCHI_API_KEY as absent.
|
|
49
|
+
vi.stubEnv("KIMCHI_API_KEY", "")
|
|
50
|
+
return { home }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Path of the harness's shared wizard config under a (stubbed) HOME. */
|
|
54
|
+
export function sharedConfigPath(home: string): string {
|
|
55
|
+
return join(home, ".config", "kimchi", "config.json")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Seed the shared config.json under a stubbed HOME (creates parent dirs). */
|
|
59
|
+
export function writeSharedConfigJson(home: string, value: unknown): string {
|
|
60
|
+
const configPath = sharedConfigPath(home)
|
|
61
|
+
mkdirSync(dirname(configPath), { recursive: true })
|
|
62
|
+
writeFileSync(configPath, JSON.stringify(value, null, 2))
|
|
63
|
+
return configPath
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Capture console.log / console.error; restore() in afterEach (or rely on vi.restoreAllMocks). */
|
|
67
|
+
export function captureConsole(): { lines: string[]; errors: string[] } {
|
|
68
|
+
const lines: string[] = []
|
|
69
|
+
const errors: string[] = []
|
|
70
|
+
vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
|
|
71
|
+
lines.push(args.map(String).join(" "))
|
|
72
|
+
})
|
|
73
|
+
vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => {
|
|
74
|
+
errors.push(args.map(String).join(" "))
|
|
75
|
+
})
|
|
76
|
+
return { lines, errors }
|
|
77
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
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 { cleanupTempDirs, jsonResponse, makeTempDir, stubFetch } from "../test-support.js"
|
|
5
|
+
import { checkForUpdate, isNewer } from "./check.js"
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
const home = makeTempDir()
|
|
9
|
+
vi.stubEnv("HOME", home)
|
|
10
|
+
vi.stubEnv("KIMCHICTL_HOME", join(home, ".kimchictl"))
|
|
11
|
+
vi.stubEnv("KIMCHICTL_NO_UPDATE_CHECK", "")
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanupTempDirs()
|
|
16
|
+
vi.unstubAllEnvs()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe("isNewer", () => {
|
|
20
|
+
it("compares semver-ish strings numerically", () => {
|
|
21
|
+
expect(isNewer("0.2.0", "0.10.0")).toBe(false)
|
|
22
|
+
expect(isNewer("0.10.0", "0.2.0")).toBe(true)
|
|
23
|
+
expect(isNewer("1.0.0", "1.0.0")).toBe(false)
|
|
24
|
+
expect(isNewer("v0.3.0", "0.2.9")).toBe(true)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
describe("checkForUpdate", () => {
|
|
29
|
+
it("is silent for dev builds", async () => {
|
|
30
|
+
const fetch = stubFetch(async () => {
|
|
31
|
+
throw new Error("should not fetch")
|
|
32
|
+
})
|
|
33
|
+
expect(await checkForUpdate({ fetch, currentVersion: "0.0.0-dev" })).toBeUndefined()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("respects the escape-hatch env var", async () => {
|
|
37
|
+
vi.stubEnv("KIMCHICTL_NO_UPDATE_CHECK", "1")
|
|
38
|
+
const result = await checkForUpdate({
|
|
39
|
+
currentVersion: "0.1.0",
|
|
40
|
+
fetch: stubFetch(async () => {
|
|
41
|
+
throw new Error("should not fetch")
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
expect(result).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("fetches once a day, notifies only when newer, and caches", async () => {
|
|
48
|
+
let fetches = 0
|
|
49
|
+
const fetch = stubFetch(async (url) => {
|
|
50
|
+
fetches++
|
|
51
|
+
expect(url).toContain("/releases/latest")
|
|
52
|
+
return jsonResponse({ tag_name: "0.3.0" })
|
|
53
|
+
})
|
|
54
|
+
const now = () => new Date("2026-02-01T00:00:00Z")
|
|
55
|
+
|
|
56
|
+
const first = await checkForUpdate({ fetch, now, currentVersion: "0.2.0" })
|
|
57
|
+
expect(first).toEqual({ latest: "0.3.0", current: "0.2.0" })
|
|
58
|
+
|
|
59
|
+
// Fresh cache — no refetch.
|
|
60
|
+
const second = await checkForUpdate({ fetch, now, currentVersion: "0.2.0" })
|
|
61
|
+
expect(second).toEqual({ latest: "0.3.0", current: "0.2.0" })
|
|
62
|
+
expect(fetches).toBe(1)
|
|
63
|
+
|
|
64
|
+
const cachePath = join(process.env.KIMCHICTL_HOME ?? "", "update-check.json")
|
|
65
|
+
expect(existsSync(cachePath)).toBe(true)
|
|
66
|
+
expect(JSON.parse(readFileSync(cachePath, "utf-8"))).toEqual({
|
|
67
|
+
latestVersion: "0.3.0",
|
|
68
|
+
checkedAt: "2026-02-01T00:00:00.000Z",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Not newer → quiet.
|
|
72
|
+
const upToDate = await checkForUpdate({ fetch, now, currentVersion: "0.3.0" })
|
|
73
|
+
expect(upToDate).toBeUndefined()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("refetches when the cache is stale", async () => {
|
|
77
|
+
let fetches = 0
|
|
78
|
+
const fetch = stubFetch(async () => {
|
|
79
|
+
fetches++
|
|
80
|
+
return jsonResponse({ tag_name: "0.3.0" })
|
|
81
|
+
})
|
|
82
|
+
const day1 = () => new Date("2026-02-01T00:00:00Z")
|
|
83
|
+
const day2 = () => new Date("2026-02-03T12:00:00Z") // >24h later
|
|
84
|
+
|
|
85
|
+
await checkForUpdate({ fetch, now: day1, currentVersion: "0.2.0" })
|
|
86
|
+
await checkForUpdate({ fetch, now: day2, currentVersion: "0.2.0" })
|
|
87
|
+
expect(fetches).toBe(2)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it("never throws on network failure or malformed responses", async () => {
|
|
91
|
+
const failing = stubFetch(async () => {
|
|
92
|
+
throw new Error("offline")
|
|
93
|
+
})
|
|
94
|
+
expect(await checkForUpdate({ fetch: failing, currentVersion: "0.2.0" })).toBeUndefined()
|
|
95
|
+
|
|
96
|
+
const weird = stubFetch(async () => jsonResponse({ nope: true }))
|
|
97
|
+
expect(await checkForUpdate({ fetch: weird, currentVersion: "0.2.0" })).toBeUndefined()
|
|
98
|
+
})
|
|
99
|
+
})
|