@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,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
|
+
}
|