@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.
Files changed (231) hide show
  1. package/LICENSE +203 -0
  2. package/README.md +105 -0
  3. package/bin/kimchictl.mjs +17 -0
  4. package/dist/api/http.d.ts +7 -0
  5. package/dist/api/http.d.ts.map +1 -0
  6. package/dist/api/http.js +60 -0
  7. package/dist/api/http.js.map +1 -0
  8. package/dist/api/keys.d.ts +7 -0
  9. package/dist/api/keys.d.ts.map +1 -0
  10. package/dist/api/keys.js +28 -0
  11. package/dist/api/keys.js.map +1 -0
  12. package/dist/api/me.d.ts +12 -0
  13. package/dist/api/me.d.ts.map +1 -0
  14. package/dist/api/me.js +28 -0
  15. package/dist/api/me.js.map +1 -0
  16. package/dist/api/resolver.d.ts +26 -0
  17. package/dist/api/resolver.d.ts.map +1 -0
  18. package/dist/api/resolver.js +62 -0
  19. package/dist/api/resolver.js.map +1 -0
  20. package/dist/api/resources.d.ts +16 -0
  21. package/dist/api/resources.d.ts.map +1 -0
  22. package/dist/api/resources.js +96 -0
  23. package/dist/api/resources.js.map +1 -0
  24. package/dist/api/templates.d.ts +19 -0
  25. package/dist/api/templates.d.ts.map +1 -0
  26. package/dist/api/templates.js +25 -0
  27. package/dist/api/templates.js.map +1 -0
  28. package/dist/api/tokens.d.ts +23 -0
  29. package/dist/api/tokens.d.ts.map +1 -0
  30. package/dist/api/tokens.js +51 -0
  31. package/dist/api/tokens.js.map +1 -0
  32. package/dist/api/types.d.ts +34 -0
  33. package/dist/api/types.d.ts.map +1 -0
  34. package/dist/api/types.js +32 -0
  35. package/dist/api/types.js.map +1 -0
  36. package/dist/api/wait.d.ts +17 -0
  37. package/dist/api/wait.d.ts.map +1 -0
  38. package/dist/api/wait.js +37 -0
  39. package/dist/api/wait.js.map +1 -0
  40. package/dist/api/workspaces.d.ts +33 -0
  41. package/dist/api/workspaces.d.ts.map +1 -0
  42. package/dist/api/workspaces.js +159 -0
  43. package/dist/api/workspaces.js.map +1 -0
  44. package/dist/auth/auth-file.d.ts +37 -0
  45. package/dist/auth/auth-file.d.ts.map +1 -0
  46. package/dist/auth/auth-file.js +100 -0
  47. package/dist/auth/auth-file.js.map +1 -0
  48. package/dist/auth/browser-login.d.ts +45 -0
  49. package/dist/auth/browser-login.d.ts.map +1 -0
  50. package/dist/auth/browser-login.js +195 -0
  51. package/dist/auth/browser-login.js.map +1 -0
  52. package/dist/auth/config-file.d.ts +4 -0
  53. package/dist/auth/config-file.d.ts.map +1 -0
  54. package/dist/auth/config-file.js +52 -0
  55. package/dist/auth/config-file.js.map +1 -0
  56. package/dist/auth/models-providers.d.ts +17 -0
  57. package/dist/auth/models-providers.d.ts.map +1 -0
  58. package/dist/auth/models-providers.js +63 -0
  59. package/dist/auth/models-providers.js.map +1 -0
  60. package/dist/auth/paths.d.ts +15 -0
  61. package/dist/auth/paths.d.ts.map +1 -0
  62. package/dist/auth/paths.js +25 -0
  63. package/dist/auth/paths.js.map +1 -0
  64. package/dist/auth/provider-ids.d.ts +4 -0
  65. package/dist/auth/provider-ids.d.ts.map +1 -0
  66. package/dist/auth/provider-ids.js +12 -0
  67. package/dist/auth/provider-ids.js.map +1 -0
  68. package/dist/auth/resolve.d.ts +19 -0
  69. package/dist/auth/resolve.d.ts.map +1 -0
  70. package/dist/auth/resolve.js +37 -0
  71. package/dist/auth/resolve.js.map +1 -0
  72. package/dist/cli.d.ts +2 -0
  73. package/dist/cli.d.ts.map +1 -0
  74. package/dist/cli.js +15 -0
  75. package/dist/cli.js.map +1 -0
  76. package/dist/commands/completion.d.ts +2 -0
  77. package/dist/commands/completion.d.ts.map +1 -0
  78. package/dist/commands/completion.js +71 -0
  79. package/dist/commands/completion.js.map +1 -0
  80. package/dist/commands/flags.d.ts +16 -0
  81. package/dist/commands/flags.d.ts.map +1 -0
  82. package/dist/commands/flags.js +23 -0
  83. package/dist/commands/flags.js.map +1 -0
  84. package/dist/commands/guard.d.ts +10 -0
  85. package/dist/commands/guard.d.ts.map +1 -0
  86. package/dist/commands/guard.js +41 -0
  87. package/dist/commands/guard.js.map +1 -0
  88. package/dist/commands/help.d.ts +4 -0
  89. package/dist/commands/help.d.ts.map +1 -0
  90. package/dist/commands/help.js +19 -0
  91. package/dist/commands/help.js.map +1 -0
  92. package/dist/commands/login.d.ts +18 -0
  93. package/dist/commands/login.d.ts.map +1 -0
  94. package/dist/commands/login.js +80 -0
  95. package/dist/commands/login.js.map +1 -0
  96. package/dist/commands/logout.d.ts +14 -0
  97. package/dist/commands/logout.d.ts.map +1 -0
  98. package/dist/commands/logout.js +41 -0
  99. package/dist/commands/logout.js.map +1 -0
  100. package/dist/commands/prompt.d.ts +9 -0
  101. package/dist/commands/prompt.d.ts.map +1 -0
  102. package/dist/commands/prompt.js +21 -0
  103. package/dist/commands/prompt.js.map +1 -0
  104. package/dist/commands/registry.d.ts +16 -0
  105. package/dist/commands/registry.d.ts.map +1 -0
  106. package/dist/commands/registry.js +35 -0
  107. package/dist/commands/registry.js.map +1 -0
  108. package/dist/commands/ssh.d.ts +15 -0
  109. package/dist/commands/ssh.d.ts.map +1 -0
  110. package/dist/commands/ssh.js +90 -0
  111. package/dist/commands/ssh.js.map +1 -0
  112. package/dist/commands/table.d.ts +3 -0
  113. package/dist/commands/table.d.ts.map +1 -0
  114. package/dist/commands/table.js +10 -0
  115. package/dist/commands/table.js.map +1 -0
  116. package/dist/commands/update.d.ts +16 -0
  117. package/dist/commands/update.d.ts.map +1 -0
  118. package/dist/commands/update.js +57 -0
  119. package/dist/commands/update.js.map +1 -0
  120. package/dist/commands/version.d.ts +2 -0
  121. package/dist/commands/version.d.ts.map +1 -0
  122. package/dist/commands/version.js +6 -0
  123. package/dist/commands/version.js.map +1 -0
  124. package/dist/commands/whoami.d.ts +7 -0
  125. package/dist/commands/whoami.d.ts.map +1 -0
  126. package/dist/commands/whoami.js +30 -0
  127. package/dist/commands/whoami.js.map +1 -0
  128. package/dist/commands/workspace.d.ts +15 -0
  129. package/dist/commands/workspace.d.ts.map +1 -0
  130. package/dist/commands/workspace.js +206 -0
  131. package/dist/commands/workspace.js.map +1 -0
  132. package/dist/run.d.ts +10 -0
  133. package/dist/run.d.ts.map +1 -0
  134. package/dist/run.js +30 -0
  135. package/dist/run.js.map +1 -0
  136. package/dist/ssh/bridge.d.ts +25 -0
  137. package/dist/ssh/bridge.d.ts.map +1 -0
  138. package/dist/ssh/bridge.js +95 -0
  139. package/dist/ssh/bridge.js.map +1 -0
  140. package/dist/ssh/config.d.ts +65 -0
  141. package/dist/ssh/config.d.ts.map +1 -0
  142. package/dist/ssh/config.js +198 -0
  143. package/dist/ssh/config.js.map +1 -0
  144. package/dist/ssh/connect.d.ts +27 -0
  145. package/dist/ssh/connect.d.ts.map +1 -0
  146. package/dist/ssh/connect.js +67 -0
  147. package/dist/ssh/connect.js.map +1 -0
  148. package/dist/ssh/tunnel.d.ts +29 -0
  149. package/dist/ssh/tunnel.d.ts.map +1 -0
  150. package/dist/ssh/tunnel.js +36 -0
  151. package/dist/ssh/tunnel.js.map +1 -0
  152. package/dist/test-support.d.ts +30 -0
  153. package/dist/test-support.d.ts.map +1 -0
  154. package/dist/test-support.js +67 -0
  155. package/dist/test-support.js.map +1 -0
  156. package/dist/updates/check.d.ts +18 -0
  157. package/dist/updates/check.d.ts.map +1 -0
  158. package/dist/updates/check.js +90 -0
  159. package/dist/updates/check.js.map +1 -0
  160. package/dist/updates/self.d.ts +36 -0
  161. package/dist/updates/self.d.ts.map +1 -0
  162. package/dist/updates/self.js +103 -0
  163. package/dist/updates/self.js.map +1 -0
  164. package/dist/version.d.ts +7 -0
  165. package/dist/version.d.ts.map +1 -0
  166. package/dist/version.js +7 -0
  167. package/dist/version.js.map +1 -0
  168. package/package.json +77 -0
  169. package/src/api/http.ts +70 -0
  170. package/src/api/keys.ts +40 -0
  171. package/src/api/me.ts +48 -0
  172. package/src/api/resolver.test.ts +109 -0
  173. package/src/api/resolver.ts +72 -0
  174. package/src/api/resources.test.ts +56 -0
  175. package/src/api/resources.ts +92 -0
  176. package/src/api/templates.ts +28 -0
  177. package/src/api/tokens.ts +80 -0
  178. package/src/api/types.ts +50 -0
  179. package/src/api/wait.ts +45 -0
  180. package/src/api/workspaces.ts +234 -0
  181. package/src/auth/auth-file.test.ts +176 -0
  182. package/src/auth/auth-file.ts +103 -0
  183. package/src/auth/browser-login.test.ts +93 -0
  184. package/src/auth/browser-login.ts +247 -0
  185. package/src/auth/config-file.test.ts +68 -0
  186. package/src/auth/config-file.ts +50 -0
  187. package/src/auth/models-providers.test.ts +63 -0
  188. package/src/auth/models-providers.ts +76 -0
  189. package/src/auth/paths.ts +28 -0
  190. package/src/auth/provider-ids.ts +15 -0
  191. package/src/auth/resolve.test.ts +72 -0
  192. package/src/auth/resolve.ts +46 -0
  193. package/src/cli.ts +16 -0
  194. package/src/commands/completion.test.ts +31 -0
  195. package/src/commands/completion.ts +77 -0
  196. package/src/commands/flags.ts +29 -0
  197. package/src/commands/guard.ts +44 -0
  198. package/src/commands/help.ts +20 -0
  199. package/src/commands/login.test.ts +159 -0
  200. package/src/commands/login.ts +94 -0
  201. package/src/commands/logout.test.ts +89 -0
  202. package/src/commands/logout.ts +53 -0
  203. package/src/commands/prompt.ts +21 -0
  204. package/src/commands/registry.ts +50 -0
  205. package/src/commands/ssh.test.ts +101 -0
  206. package/src/commands/ssh.ts +112 -0
  207. package/src/commands/table.test.ts +24 -0
  208. package/src/commands/table.ts +10 -0
  209. package/src/commands/update.test.ts +91 -0
  210. package/src/commands/update.ts +78 -0
  211. package/src/commands/version.ts +6 -0
  212. package/src/commands/whoami.test.ts +72 -0
  213. package/src/commands/whoami.ts +36 -0
  214. package/src/commands/workspace.test.ts +360 -0
  215. package/src/commands/workspace.ts +250 -0
  216. package/src/run.test.ts +53 -0
  217. package/src/run.ts +34 -0
  218. package/src/ssh/bridge.test.ts +70 -0
  219. package/src/ssh/bridge.ts +110 -0
  220. package/src/ssh/config.test.ts +213 -0
  221. package/src/ssh/config.ts +233 -0
  222. package/src/ssh/connect.test.ts +123 -0
  223. package/src/ssh/connect.ts +98 -0
  224. package/src/ssh/tunnel.test.ts +119 -0
  225. package/src/ssh/tunnel.ts +64 -0
  226. package/src/test-support.ts +77 -0
  227. package/src/updates/check.test.ts +99 -0
  228. package/src/updates/check.ts +117 -0
  229. package/src/updates/self.test.ts +117 -0
  230. package/src/updates/self.ts +131 -0
  231. 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
+ })