@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,70 @@
1
+ import type { AddressInfo } from "node:net"
2
+ import { Readable, Writable } from "node:stream"
3
+ import { afterEach, describe, expect, it } from "vitest"
4
+ import { WebSocketServer } from "ws"
5
+ import { BridgeError, bridgeStdioToWebSocket } from "./bridge.js"
6
+
7
+ const servers: WebSocketServer[] = []
8
+
9
+ afterEach(async () => {
10
+ await Promise.all(servers.splice(0).map((s) => new Promise<void>((resolve) => s.close(() => resolve()))))
11
+ })
12
+
13
+ /** Start an echo+header-capture WS server. Returns the ws:// URL and captured state. */
14
+ async function startEchoServer(): Promise<{ url: string; authHeader: { value?: string } }> {
15
+ const wss = new WebSocketServer({ port: 0 })
16
+ servers.push(wss)
17
+ const authHeader: { value?: string } = {}
18
+ wss.on("connection", (ws, req) => {
19
+ authHeader.value = req.headers.authorization
20
+ ws.binaryType = "arraybuffer"
21
+ ws.on("message", (data, isBinary) => {
22
+ ws.send(data, { binary: isBinary })
23
+ })
24
+ })
25
+ await new Promise<void>((resolve) => wss.on("listening", resolve))
26
+ const { port } = wss.address() as AddressInfo
27
+ return { url: `ws://127.0.0.1:${port}/ssh`, authHeader }
28
+ }
29
+
30
+ function collectOutput(): { output: Writable; chunks: Buffer[] } {
31
+ const chunks: Buffer[] = []
32
+ const output = new Writable({
33
+ write(chunk, _encoding, callback) {
34
+ chunks.push(Buffer.from(chunk))
35
+ callback()
36
+ },
37
+ })
38
+ return { output, chunks }
39
+ }
40
+
41
+ describe("bridgeStdioToWebSocket", () => {
42
+ it("pumps stdin chunks through the WS and echoes them back to stdout", async () => {
43
+ const { url, authHeader } = await startEchoServer()
44
+ const { output, chunks } = collectOutput()
45
+
46
+ await bridgeStdioToWebSocket({
47
+ wsUrl: url,
48
+ token: "workspace-jwt",
49
+ input: Readable.from([Buffer.from("hello "), Buffer.from([0x00, 0xff, 0x42])]),
50
+ output,
51
+ })
52
+
53
+ expect(authHeader.value).toBe("Bearer workspace-jwt")
54
+ expect(Buffer.concat(chunks).toString("hex")).toBe(
55
+ Buffer.concat([Buffer.from("hello "), Buffer.from([0x00, 0xff, 0x42])]).toString("hex"),
56
+ )
57
+ })
58
+
59
+ it("fails to start with a BridgeError when the endpoint rejects the handshake", async () => {
60
+ // No server listening on this port.
61
+ await expect(
62
+ bridgeStdioToWebSocket({
63
+ wsUrl: "ws://127.0.0.1:1/ssh",
64
+ token: "t",
65
+ input: Readable.from([]),
66
+ output: collectOutput().output,
67
+ }),
68
+ ).rejects.toThrow(BridgeError)
69
+ })
70
+ })
@@ -0,0 +1,110 @@
1
+ import type { Readable, Writable } from "node:stream"
2
+ import { type RawData, WebSocket } from "ws"
3
+
4
+ /**
5
+ * Binary WebSocket↔stdio bridge — the runtime behind the hidden
6
+ * `kimchictl ssh proxy` command that the generated ssh_config references via
7
+ * ProxyCommand. TS port of kap's internal/ssh/bridge.go.
8
+ *
9
+ * Exit semantics (mirroring kap): stdin EOF or a normal WS close end the
10
+ * bridge without error; any other failure rejects with a descriptive error.
11
+ * The proxy process is short-lived — it exits when the bridge resolves, so
12
+ * dangling listeners cannot outlive it.
13
+ */
14
+
15
+ export class BridgeError extends Error {
16
+ constructor(message: string) {
17
+ super(message)
18
+ this.name = "BridgeError"
19
+ }
20
+ }
21
+
22
+ export interface BridgeOptions {
23
+ wsUrl: string
24
+ token: string
25
+ input?: Readable
26
+ output?: Writable
27
+ /** Injected WebSocket constructor (tests may pass a wrapped client). */
28
+ _WebSocket?: typeof WebSocket
29
+ }
30
+
31
+ export async function bridgeStdioToWebSocket(options: BridgeOptions): Promise<void> {
32
+ const WS = options._WebSocket ?? WebSocket
33
+ const input = options.input ?? process.stdin
34
+ const output = options.output ?? process.stdout
35
+
36
+ let ws: InstanceType<typeof WebSocket>
37
+ try {
38
+ ws = new WS(options.wsUrl, {
39
+ headers: { Authorization: `Bearer ${options.token}` },
40
+ })
41
+ } catch (err) {
42
+ throw new BridgeError(`websocket dial ${options.wsUrl}: ${err instanceof Error ? err.message : String(err)}`)
43
+ }
44
+
45
+ await new Promise<void>((resolve, reject) => {
46
+ let settled = false
47
+ const done = (err?: Error) => {
48
+ if (settled) return
49
+ settled = true
50
+ if (err) reject(err)
51
+ else resolve()
52
+ }
53
+
54
+ ws.on("open", () => done())
55
+ ws.on("unexpected-response", (_req, res) => {
56
+ done(
57
+ new BridgeError(
58
+ `websocket dial ${options.wsUrl}: unexpected HTTP ${res.statusCode} ${res.statusMessage ?? ""}`.trim(),
59
+ ),
60
+ )
61
+ })
62
+ ws.on("error", (err) => done(new BridgeError(`websocket dial ${options.wsUrl}: ${err.message}`)))
63
+ })
64
+
65
+ return new Promise<void>((resolve, reject) => {
66
+ let settled = false
67
+ const finish = (err?: Error) => {
68
+ if (settled) return
69
+ settled = true
70
+ input.removeAllListeners()
71
+ // Output is process.stdout when bridging for real — never end() that;
72
+ // only close streams the caller explicitly passed in.
73
+ if (options.output) output.end()
74
+ if (err) reject(err)
75
+ else resolve()
76
+ }
77
+
78
+ // WS → stdout
79
+ ws.on("message", (data: RawData, isBinary: boolean) => {
80
+ // The workspace bridge is binary-only; text frames would be a server bug,
81
+ // but writing them through is still friendlier than dropping bytes.
82
+ void isBinary
83
+ const buffer = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data)
84
+ if (!output.write(buffer)) {
85
+ // Keep memory bounded if the consumer stalls: pause the socket until drained.
86
+ ws.pause()
87
+ output.once("drain", () => ws.resume())
88
+ }
89
+ })
90
+ ws.on("close", () => finish())
91
+ ws.on("error", (err: Error & { code?: number }) =>
92
+ finish(
93
+ err.code === 1000 || err.code === 1001
94
+ ? undefined // normal / going-away closure
95
+ : new BridgeError(`websocket error: ${err.message}`),
96
+ ),
97
+ )
98
+
99
+ // stdin → WS
100
+ if (input.isPaused?.()) input.resume()
101
+ input.on("data", (chunk: Buffer | string) => {
102
+ if (ws.readyState !== WebSocket.OPEN) return
103
+ ws.send(chunk, { binary: true })
104
+ })
105
+ input.on("end", () => {
106
+ if (ws.readyState === WebSocket.OPEN) ws.close(1000)
107
+ })
108
+ input.on("error", (err) => finish(err))
109
+ })
110
+ }
@@ -0,0 +1,213 @@
1
+ import { readFileSync, statSync, writeFileSync } from "node:fs"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { afterEach, describe, expect, it, vi } from "vitest"
5
+ import { cleanupTempDirs, makeTempDir } from "../test-support.js"
6
+ import {
7
+ isSshIntegrationConfigured,
8
+ resolveProxyCommandTarget,
9
+ resolveSshDomain,
10
+ resolveSshPaths,
11
+ SSH_INCLUDE_BEGIN,
12
+ type SshPaths,
13
+ setupSshIntegration,
14
+ sshConfigContent,
15
+ uninstallSshIntegration,
16
+ } from "./config.js"
17
+
18
+ afterEach(() => {
19
+ cleanupTempDirs()
20
+ vi.unstubAllEnvs()
21
+ })
22
+
23
+ function makePaths(): { root: string; paths: SshPaths } {
24
+ const root = makeTempDir()
25
+ return {
26
+ root,
27
+ paths: {
28
+ home: join(root, ".kimchictl"),
29
+ sshConfig: join(root, ".kimchictl", "ssh_config"),
30
+ knownHosts: join(root, ".kimchictl", "known_hosts"),
31
+ userSshConfig: join(root, ".ssh", "config"),
32
+ },
33
+ }
34
+ }
35
+
36
+ const DOMAIN = "remote.kimchi.dev"
37
+
38
+ describe("setupSshIntegration", () => {
39
+ it("writes ssh_config (0600) and prepends the Include block", async () => {
40
+ const { paths } = makePaths()
41
+
42
+ const target = resolveProxyCommandTarget()
43
+ const { notes } = await setupSshIntegration({ paths, domain: DOMAIN })
44
+
45
+ const generated = readFileSync(paths.sshConfig, "utf-8")
46
+ expect(generated).toBe(sshConfigContent(DOMAIN, paths.knownHosts, target))
47
+ expect(statSync(paths.sshConfig).mode & 0o777).toBe(0o600)
48
+
49
+ const userConfig = readFileSync(paths.userSshConfig, "utf-8")
50
+ expect(userConfig).toBe(`${SSH_INCLUDE_BEGIN}\nInclude ${paths.sshConfig}\n# <<< kimchictl managed include <<<\n`)
51
+ expect(notes).toEqual([
52
+ `wrote ${paths.sshConfig} (Host *.${DOMAIN})`,
53
+ `added Include block to ${paths.userSshConfig}`,
54
+ ])
55
+ })
56
+
57
+ it("keeps the Include above existing user Host blocks (first match wins)", async () => {
58
+ const { paths } = makePaths()
59
+ await mkdir(join(paths.userSshConfig, ".."), { recursive: true })
60
+ writeFileSync(paths.userSshConfig, "Host example\n HostName example.com\n")
61
+
62
+ await setupSshIntegration({ paths, domain: DOMAIN })
63
+
64
+ const userConfig = readFileSync(paths.userSshConfig, "utf-8")
65
+ expect(userConfig.startsWith(SSH_INCLUDE_BEGIN)).toBe(true)
66
+ expect(userConfig).toContain("Host example\n HostName example.com\n")
67
+ })
68
+
69
+ it("is idempotent (second run produces no notes, no duplicate block)", async () => {
70
+ const { paths } = makePaths()
71
+
72
+ await setupSshIntegration({ paths, domain: DOMAIN })
73
+ const { notes } = await setupSshIntegration({ paths, domain: DOMAIN })
74
+
75
+ expect(notes).toEqual([])
76
+ const userConfig = readFileSync(paths.userSshConfig, "utf-8")
77
+ expect(userConfig.split(SSH_INCLUDE_BEGIN).length - 1).toBe(1)
78
+ })
79
+
80
+ it("replaces a stale managed block pointing at a different path", async () => {
81
+ const { paths } = makePaths()
82
+ await setupSshIntegration({ paths, domain: DOMAIN })
83
+
84
+ const moved = { ...paths, home: join(paths.home, "moved"), sshConfig: join(paths.home, "moved", "ssh_config") }
85
+ const { notes } = await setupSshIntegration({ paths: moved, domain: DOMAIN })
86
+
87
+ const userConfig = readFileSync(paths.userSshConfig, "utf-8")
88
+ expect(userConfig).toContain(`Include ${moved.sshConfig}`)
89
+ expect(userConfig).not.toContain(`Include ${paths.sshConfig}`)
90
+ expect(notes).toContain(`updated stale Include block in ${paths.userSshConfig}`)
91
+ })
92
+
93
+ it("warns about a conflicting kap-managed block", async () => {
94
+ const { paths } = makePaths()
95
+ await mkdir(join(paths.userSshConfig, ".."), { recursive: true })
96
+ writeFileSync(
97
+ paths.userSshConfig,
98
+ "# >>> kap managed include >>>\nInclude /home/x/.config/kap/ssh_config\n# <<< kap managed include <<<\n",
99
+ )
100
+
101
+ const { notes } = await setupSshIntegration({ paths, domain: DOMAIN })
102
+
103
+ expect(notes.some((n) => n.includes("kap-managed"))).toBe(true)
104
+ })
105
+ })
106
+
107
+ describe("uninstallSshIntegration", () => {
108
+ it("removes the block and the generated ssh_config, keeping other content", async () => {
109
+ const { paths } = makePaths()
110
+ await mkdir(join(paths.userSshConfig, ".."), { recursive: true })
111
+ writeFileSync(paths.userSshConfig, "Host example\n HostName example.com\n")
112
+ await setupSshIntegration({ paths, domain: DOMAIN })
113
+
114
+ const { notes } = await uninstallSshIntegration({ paths })
115
+
116
+ const userConfig = readFileSync(paths.userSshConfig, "utf-8")
117
+ expect(userConfig).not.toContain("kimchictl managed include")
118
+ expect(userConfig).toContain("Host example")
119
+ expect(notes).toEqual([`removed Include block from ${paths.userSshConfig}`, `removed ${paths.sshConfig}`])
120
+ })
121
+
122
+ it("is a no-op when nothing was configured", async () => {
123
+ const { paths } = makePaths()
124
+ const { notes } = await uninstallSshIntegration({ paths })
125
+ expect(notes).toEqual([])
126
+ })
127
+ })
128
+
129
+ describe("isSshIntegrationConfigured", () => {
130
+ it("false before setup, true after, false again on domain drift", async () => {
131
+ const { paths } = makePaths()
132
+
133
+ expect(await isSshIntegrationConfigured(paths, DOMAIN)).toBe(false)
134
+ await setupSshIntegration({ paths, domain: DOMAIN })
135
+ expect(await isSshIntegrationConfigured(paths, DOMAIN)).toBe(true)
136
+ // Endpoint changed → different wildcard domain → re-setup needed.
137
+ expect(await isSshIntegrationConfigured(paths, "remote.dev.example.com")).toBe(false)
138
+ })
139
+
140
+ it("re-triggers setup when the ProxyCommand target drifted (binary moved)", async () => {
141
+ const { paths } = makePaths()
142
+
143
+ await setupSshIntegration({ paths, domain: DOMAIN, proxyTarget: "/old/location/kimchictl" })
144
+
145
+ expect(await isSshIntegrationConfigured(paths, DOMAIN, "/new/location/kimchictl")).toBe(false)
146
+ expect(await isSshIntegrationConfigured(paths, DOMAIN, "/old/location/kimchictl")).toBe(true)
147
+ })
148
+ })
149
+
150
+ describe("resolveSshDomain", () => {
151
+ it("defaults to remote.kimchi.dev", () => {
152
+ vi.stubEnv("KIMCHI_REMOTE_ENDPOINT", "")
153
+ vi.stubEnv("KIMCHICTL_SSH_DOMAIN", "")
154
+ expect(resolveSshDomain()).toBe("remote.kimchi.dev")
155
+ })
156
+
157
+ it("derives from an endpoint override (app.X → remote.X)", () => {
158
+ expect(
159
+ resolveSshDomain(undefined, { KIMCHI_REMOTE_ENDPOINT: "https://app.dev.kimchi.dev/api" } as NodeJS.ProcessEnv),
160
+ ).toBe("remote.dev.kimchi.dev")
161
+ })
162
+
163
+ it("honors explicit flag and env override before derivation", () => {
164
+ expect(
165
+ resolveSshDomain("custom.example", {
166
+ KIMCHI_REMOTE_ENDPOINT: "https://app.dev.kimchi.dev/api",
167
+ } as NodeJS.ProcessEnv),
168
+ ).toBe("custom.example")
169
+ expect(resolveSshDomain(undefined, { KIMCHICTL_SSH_DOMAIN: "env.example" } as NodeJS.ProcessEnv)).toBe(
170
+ "env.example",
171
+ )
172
+ })
173
+ })
174
+
175
+ describe("resolveProxyCommandTarget", () => {
176
+ it("uses the compiled binary's own path when named like kimchictl", () => {
177
+ expect(resolveProxyCommandTarget("/usr/local/bin/kimchictl")).toBe("/usr/local/bin/kimchictl")
178
+ expect(resolveProxyCommandTarget("/opt/kimchictl-v1.2.3")).toBe("/opt/kimchictl-v1.2.3")
179
+ })
180
+
181
+ it("pins the runtime + absolute entry script under node/bun (no PATH reliance)", () => {
182
+ expect(resolveProxyCommandTarget("/usr/local/bin/node", "/opt/app/dist/cli.js")).toBe(
183
+ "/usr/local/bin/node /opt/app/dist/cli.js",
184
+ )
185
+ // Relative entries resolve to absolute so the config works from any cwd.
186
+ expect(resolveProxyCommandTarget("/usr/bin/bun", "src/cli.ts")).toBe(
187
+ `/usr/bin/bun ${join(process.cwd(), "src/cli.ts")}`,
188
+ )
189
+ })
190
+
191
+ it("quotes tokens containing spaces", () => {
192
+ expect(resolveProxyCommandTarget("/opt/My App/kimchictl")).toBe('"/opt/My App/kimchictl"')
193
+ expect(resolveProxyCommandTarget("/usr/bin/node", "/opt/My App/cli.js")).toBe('/usr/bin/node "/opt/My App/cli.js"')
194
+ })
195
+ })
196
+
197
+ describe("resolveSshPaths", () => {
198
+ it("defaults to ~/.config/kimchi/kimchictl, alongside the harness config tree", () => {
199
+ const paths = resolveSshPaths({ HOME: "/home/tester" })
200
+
201
+ expect(paths.home).toBe("/home/tester/.config/kimchi/kimchictl")
202
+ expect(paths.sshConfig).toBe("/home/tester/.config/kimchi/kimchictl/ssh_config")
203
+ expect(paths.knownHosts).toBe("/home/tester/.config/kimchi/kimchictl/known_hosts")
204
+ expect(paths.userSshConfig).toBe("/home/tester/.ssh/config")
205
+ })
206
+
207
+ it("honors KIMCHICTL_HOME as an explicit override", () => {
208
+ const paths = resolveSshPaths({ HOME: "/home/tester", KIMCHICTL_HOME: "/custom/state" })
209
+
210
+ expect(paths.home).toBe("/custom/state")
211
+ expect(paths.sshConfig).toBe("/custom/state/ssh_config")
212
+ })
213
+ })
@@ -0,0 +1,233 @@
1
+ import { existsSync } from "node:fs"
2
+ import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"
3
+ import { homedir } from "node:os"
4
+ import { dirname, join, resolve } from "node:path"
5
+
6
+ /**
7
+ * Native SSH integration (TS port of kap:internal/ssh/setup.go).
8
+ *
9
+ * Layout:
10
+ * ~/.config/kimchi/kimchictl/ssh_config — `Host *.<domain>` wildcard routing
11
+ * through `kimchictl ssh proxy %h` as ProxyCommand
12
+ * ~/.config/kimchi/kimchictl/known_hosts — dedicated known_hosts for workspace hosts
13
+ * ~/.ssh/config — gets a marker-wrapped `Include` block
14
+ *
15
+ * Behavior per the product decision "setup happens on install": writes are
16
+ * automatic (no y/N prompt) with a clear notice; `--uninstall` reverts.
17
+ */
18
+
19
+ export const SSH_INCLUDE_BEGIN = "# >>> kimchictl managed include >>>"
20
+ export const SSH_INCLUDE_END = "# <<< kimchictl managed include <<<"
21
+
22
+ export interface SshPaths {
23
+ /** ~/.config/kimchi/kimchictl root (also used for the update-check cache). */
24
+ home: string
25
+ sshConfig: string
26
+ knownHosts: string
27
+ userSshConfig: string
28
+ }
29
+
30
+ export function resolveSshPaths(env: NodeJS.ProcessEnv = process.env): SshPaths {
31
+ // Lives with the rest of the kimchi config tree (harness/, desktop/, memory/…).
32
+ const home = env.KIMCHICTL_HOME ?? join(env.HOME ?? homedir(), ".config", "kimchi", "kimchictl")
33
+ const userSshConfig = join(env.HOME ?? homedir(), ".ssh", "config")
34
+ return {
35
+ home,
36
+ sshConfig: join(home, "ssh_config"),
37
+ knownHosts: join(home, "known_hosts"),
38
+ userSshConfig,
39
+ }
40
+ }
41
+
42
+ /**
43
+ * The wildcard domain workspaces are reached at. Default remote.kimchi.dev;
44
+ * derived from the API endpoint host for dev environments
45
+ * (app.X → remote.X), overridable via flag/env for exotic setups.
46
+ */
47
+ export function resolveSshDomain(explicit?: string, env: NodeJS.ProcessEnv = process.env): string {
48
+ if (explicit) return explicit
49
+ if (env.KIMCHICTL_SSH_DOMAIN) return env.KIMCHICTL_SSH_DOMAIN
50
+ const endpoint = env.KIMCHI_REMOTE_ENDPOINT
51
+ if (endpoint) {
52
+ try {
53
+ const host = new URL(endpoint).hostname
54
+ const stripped = host.startsWith("app.") ? host.slice("app.".length) : host
55
+ return `remote.${stripped}`
56
+ } catch {
57
+ // Fall through to the production default below.
58
+ }
59
+ }
60
+ return "remote.kimchi.dev"
61
+ }
62
+
63
+ /** Shell-quote a single ProxyCommand token when it contains whitespace. */
64
+ function shellQuote(token: string): string {
65
+ return /\s|"|'/.test(token) ? `"${token.replace(/"/g, '\\"')}"` : token
66
+ }
67
+
68
+ /**
69
+ * The ProxyCommand must not rely on ssh's PATH lookup — point it at a
70
+ * fully-qualified command:
71
+ * - compiled binary: its own absolute path
72
+ * - node/bun runtime: the runtime plus the ABSOLUTE entry script
73
+ * (e.g. `/usr/bin/node /…/dist/cli.js`), so the config works from any cwd
74
+ * Bare `kimchictl` remains only as a last resort when no entry can be named.
75
+ */
76
+ export function resolveProxyCommandTarget(execPath: string = process.execPath, entryScript?: string): string {
77
+ const base = execPath.split("/").pop() ?? ""
78
+ if (base === "kimchictl" || base.startsWith("kimchictl-")) {
79
+ return shellQuote(execPath)
80
+ }
81
+ const entry = entryScript ?? process.argv[1]
82
+ if (entry) {
83
+ return `${shellQuote(execPath)} ${shellQuote(resolve(entry))}`
84
+ }
85
+ return "kimchictl"
86
+ }
87
+
88
+ export function sshConfigContent(domain: string, knownHostsPath: string, proxyTarget: string): string {
89
+ return (
90
+ `Host *.${domain}\n` +
91
+ ` ProxyCommand ${proxyTarget} ssh proxy %h\n` +
92
+ ` UserKnownHostsFile ${knownHostsPath}\n` +
93
+ ` StrictHostKeyChecking accept-new\n` +
94
+ ` User developer\n`
95
+ )
96
+ }
97
+
98
+ export interface SetupResult {
99
+ notes: string[]
100
+ }
101
+
102
+ async function writePrivateFile(path: string, content: string): Promise<void> {
103
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 })
104
+ await writeFile(path, content, { mode: 0o600 })
105
+ await chmod(path, 0o600)
106
+ }
107
+
108
+ function managedBlock(includePath: string): string {
109
+ return `${SSH_INCLUDE_BEGIN}\nInclude ${includePath}\n${SSH_INCLUDE_END}`
110
+ }
111
+
112
+ /** Extract an existing managed include block (any path), or undefined. */
113
+ function extractManagedBlock(content: string): string | undefined {
114
+ const start = content.indexOf(SSH_INCLUDE_BEGIN)
115
+ if (start === -1) return undefined
116
+ const endRel = content.slice(start).indexOf(SSH_INCLUDE_END)
117
+ if (endRel === -1) return undefined
118
+ return content.slice(start, start + endRel + SSH_INCLUDE_END.length + 1)
119
+ }
120
+
121
+ /**
122
+ * Write the kimchictl ssh_config and ensure ~/.ssh/config includes it.
123
+ * Idempotent; a stale managed block (different include path) is replaced in
124
+ * place. Returns human-readable notes of everything that changed.
125
+ */
126
+ export async function setupSshIntegration(options: {
127
+ paths: SshPaths
128
+ domain: string
129
+ proxyTarget?: string
130
+ /** Detect and warn about a kap-managed block (same wildcard domain conflict). */
131
+ warnKapConflict?: (note: string) => void
132
+ }): Promise<SetupResult> {
133
+ const { paths, domain } = options
134
+ const notes: string[] = []
135
+
136
+ const content = sshConfigContent(domain, paths.knownHosts, options.proxyTarget ?? resolveProxyCommandTarget())
137
+ const previous = existsSync(paths.sshConfig) ? await readFile(paths.sshConfig, "utf-8") : undefined
138
+ if (previous !== content) {
139
+ await writePrivateFile(paths.sshConfig, content)
140
+ notes.push(`wrote ${paths.sshConfig} (Host *.${domain})`)
141
+ }
142
+
143
+ let userConfig = ""
144
+ try {
145
+ userConfig = await readFile(paths.userSshConfig, "utf-8")
146
+ } catch (err) {
147
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
148
+ }
149
+
150
+ if (userConfig.includes("# >>> kap managed include >>>")) {
151
+ notes.push(
152
+ "⚠ found a kap-managed Include block in ~/.ssh/config — both wildcard configs match " +
153
+ "this domain; the first Include wins. Remove one if connections route to the wrong proxy.",
154
+ )
155
+ }
156
+
157
+ const block = managedBlock(paths.sshConfig)
158
+ const existing = extractManagedBlock(userConfig)
159
+ if (existing === undefined) {
160
+ // First match in ssh config wins for most options: the Include must come first.
161
+ const next = userConfig.length > 0 ? `${block}\n\n${userConfig}` : `${block}\n`
162
+ await writePrivateFile(paths.userSshConfig, next)
163
+ notes.push(`added Include block to ${paths.userSshConfig}`)
164
+ } else if (existing !== block && existing !== `${block}\n`) {
165
+ // Stale block (e.g. config dir moved) — replace in place.
166
+ const next = userConfig.replace(existing, `${block}\n`)
167
+ await writePrivateFile(paths.userSshConfig, next)
168
+ notes.push(`updated stale Include block in ${paths.userSshConfig}`)
169
+ }
170
+
171
+ return { notes }
172
+ }
173
+
174
+ /** Remove the Include block and the generated ssh_config. Returns notes. */
175
+ export async function uninstallSshIntegration(options: { paths: SshPaths }): Promise<SetupResult> {
176
+ const { paths } = options
177
+ const notes: string[] = []
178
+
179
+ let userConfig = ""
180
+ try {
181
+ userConfig = await readFile(paths.userSshConfig, "utf-8")
182
+ } catch (err) {
183
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err
184
+ }
185
+
186
+ const existing = extractManagedBlock(userConfig)
187
+ if (existing !== undefined) {
188
+ let next = userConfig.replace(existing, "")
189
+ // Tidy the blank line we added after the block when nothing precedes it.
190
+ next = next.replace(/^\n+/, "").replace(/\n{3,}/g, "\n\n")
191
+ await writePrivateFile(paths.userSshConfig, next)
192
+ notes.push(`removed Include block from ${paths.userSshConfig}`)
193
+ }
194
+
195
+ if (existsSync(paths.sshConfig)) {
196
+ // Remove rather than truncate: keep the dir (it also holds known_hosts/cache).
197
+ await rm(paths.sshConfig, { force: true })
198
+ notes.push(`removed ${paths.sshConfig}`)
199
+ }
200
+
201
+ return { notes }
202
+ }
203
+
204
+ /**
205
+ * True when the generated ssh_config exists AND the user config includes it.
206
+ * Used by `ssh <name>` / `login` to auto-setup when missing.
207
+ */
208
+ export async function isSshIntegrationConfigured(
209
+ paths: SshPaths,
210
+ domain: string,
211
+ proxyTarget?: string,
212
+ ): Promise<boolean> {
213
+ if (!existsSync(paths.sshConfig)) return false
214
+ try {
215
+ const userConfig = await readFile(paths.userSshConfig, "utf-8")
216
+ const expected = managedBlock(paths.sshConfig)
217
+ const existing = extractManagedBlock(userConfig)
218
+ if (existing !== expected && existing !== `${expected}\n`) return false
219
+ const generated = await readFile(paths.sshConfig, "utf-8")
220
+ // Both domain drift (endpoint override changed) and proxy-target drift
221
+ // (the executable moved since setup) must re-trigger setup: compare the
222
+ // full expected content.
223
+ return generated === sshConfigContent(domain, paths.knownHosts, proxyTarget ?? resolveProxyCommandTarget())
224
+ } catch {
225
+ return false
226
+ }
227
+ }
228
+
229
+ /** Disabled-marker env var honored by every auto-setup caller. */
230
+ export function isSshSetupDisabled(env: NodeJS.ProcessEnv = process.env): boolean {
231
+ const value = env.KIMCHICTL_NO_SSH_SETUP
232
+ return value !== undefined && value !== "" && value !== "0" && value !== "false"
233
+ }