Skip to content

Commit af2e2f5

Browse files
committed
feat(shell): add remote ssh config hints for editors
1 parent 41a86a1 commit af2e2f5

7 files changed

Lines changed: 265 additions & 44 deletions

File tree

packages/lib/src/usecases/actions/docker-up.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,12 @@ import {
1212
runDockerComposeUpRecreate,
1313
runDockerExecExitCode,
1414
runDockerInspectContainerBridgeIp,
15-
runDockerInspectContainerIp,
1615
runDockerNetworkConnectBridge
1716
} from "../../shell/docker.js"
1817
import type { DockerCommandError } from "../../shell/errors.js"
1918
import { AgentFailedError, CloneFailedError } from "../../shell/errors.js"
2019
import { ensureComposeNetworkReady } from "../docker-network-gc.js"
21-
import { findSshPrivateKey, resolveAuthorizedKeysPath } from "../path-helpers.js"
22-
import { buildSshCommand } from "../projects.js"
20+
import { formatEditorSshAccessDetails, resolveProjectSshAccess } from "../ssh-access.js"
2321

2422
const maxPortAttempts = 25
2523
const clonePollInterval = Duration.seconds(1)
@@ -34,33 +32,14 @@ const logSshAccess = (
3432
config: CreateCommand["config"]
3533
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor> =>
3634
Effect.gen(function*(_) {
37-
const fs = yield* _(FileSystem.FileSystem)
38-
const path = yield* _(Path.Path)
35+
const access = yield* _(resolveProjectSshAccess(baseDir, config))
3936

40-
const isInsideContainer = yield* _(fs.exists("/.dockerenv"))
41-
let ipAddress: string | undefined
42-
43-
if (isInsideContainer) {
44-
const containerIp = yield* _(
45-
runDockerInspectContainerIp(baseDir, config.containerName).pipe(
46-
Effect.orElse(() => Effect.succeed(""))
47-
)
48-
)
49-
if (containerIp.length > 0) {
50-
ipAddress = containerIp
51-
}
52-
}
53-
54-
const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath)
55-
const authExists = yield* _(fs.exists(resolvedAuthorizedKeys))
56-
const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd()))
57-
const sshCommand = buildSshCommand(config, sshKey, ipAddress)
58-
59-
yield* _(Effect.log(`SSH access: ${sshCommand}`))
60-
if (!authExists) {
37+
yield* _(Effect.log(`SSH access: ${access.sshCommand}`))
38+
yield* _(Effect.log(formatEditorSshAccessDetails(access.editor, config.clonedOnHostname)))
39+
if (!access.authorizedKeysExists) {
6140
yield* _(
6241
Effect.logWarning(
63-
`Authorized keys file missing: ${resolvedAuthorizedKeys} (SSH may fail without a matching key).`
42+
`Authorized keys file missing: ${access.authorizedKeysPath} (SSH may fail without a matching key).`
6443
)
6544
)
6645
}

packages/lib/src/usecases/menu-helpers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,13 @@ export const formatConnectionInfo = (
1515
config: ProjectConfig,
1616
authorizedKeysPath: string,
1717
authorizedKeysExists: boolean,
18-
sshCommand: string
18+
sshCommand: string,
19+
editorAccessDetails?: string
1920
): string => {
2021
const hostnameLabel = config.template.clonedOnHostname === undefined
2122
? ""
2223
: `\nCloned on device: ${config.template.clonedOnHostname}`
24+
const editorAccessLabel = editorAccessDetails === undefined ? "" : `\n${editorAccessDetails}`
2325
return `Project directory: ${cwd}
2426
` +
2527
`Container: ${config.template.containerName}
@@ -39,5 +41,6 @@ export const formatConnectionInfo = (
3941
`Env project: ${config.template.envProjectPath}
4042
` +
4143
`Codex auth: ${config.template.codexAuthPath} -> ${config.template.codexHome}` +
44+
editorAccessLabel +
4245
hostnameLabel
4346
}

packages/lib/src/usecases/projects-core.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import * as FileSystem from "@effect/platform/FileSystem"
44
import * as Path from "@effect/platform/Path"
55
import { Effect, pipe } from "effect"
66

7-
import type { ProjectConfig, TemplateConfig } from "../core/domain.js"
7+
import type { ProjectConfig } from "../core/domain.js"
88
import { deriveRepoPathParts } from "../core/domain.js"
99
import { readProjectConfig } from "../shell/config.js"
1010
import { runDockerInspectContainerIp } from "../shell/docker.js"
@@ -14,28 +14,17 @@ import { findDockerGitConfigPaths } from "./docker-git-config-search.js"
1414
import { renderError } from "./errors.js"
1515
import { defaultProjectsRoot, formatConnectionInfo } from "./menu-helpers.js"
1616
import { findSshPrivateKey, resolveAuthorizedKeysPath, resolvePathFromCwd } from "./path-helpers.js"
17+
import { buildEditorSshAccess, buildSshCommand, formatEditorSshAccessDetails } from "./ssh-access.js"
1718
import { withFsPathContext } from "./runtime.js"
1819

19-
const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
20-
2120
export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError
22-
23-
export const buildSshCommand = (
24-
config: TemplateConfig,
25-
sshKey: string | null,
26-
ipAddress?: string
27-
): string => {
28-
const host = ipAddress ?? "localhost"
29-
const port = ipAddress ? 22 : config.sshPort
30-
return sshKey === null
31-
? `ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}`
32-
: `ssh -i ${sshKey} ${sshOptions} -p ${port} ${config.sshUser}@${host}`
33-
}
21+
export { buildSshCommand }
3422

3523
export type ProjectSummary = {
3624
readonly projectDir: string
3725
readonly config: ProjectConfig
3826
readonly sshCommand: string
27+
readonly sshKeyPath: string | null
3928
readonly ipAddress?: string | undefined
4029
readonly authorizedKeysPath: string
4130
readonly authorizedKeysExists: boolean
@@ -140,6 +129,7 @@ export const loadProjectSummary = (
140129
projectDir,
141130
config,
142131
sshCommand,
132+
sshKeyPath: sshKey,
143133
ipAddress,
144134
authorizedKeysPath: resolvedAuthorizedKeys,
145135
authorizedKeysExists: authExists
@@ -160,7 +150,11 @@ export const renderProjectSummary = (summary: ProjectSummary): string =>
160150
summary.config,
161151
summary.authorizedKeysPath,
162152
summary.authorizedKeysExists,
163-
summary.sshCommand
153+
summary.sshCommand,
154+
formatEditorSshAccessDetails(
155+
buildEditorSshAccess(summary.config.template, summary.sshKeyPath, summary.ipAddress),
156+
summary.config.template.clonedOnHostname
157+
)
164158
)
165159

166160
const formatDisplayName = (repoUrl: string): string => {

packages/lib/src/usecases/projects-ssh.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
renderProjectStatusHeader,
2626
withProjectIndexAndSsh
2727
} from "./projects-core.js"
28+
import { buildEditorSshAccess, formatEditorSshAccessSummary } from "./ssh-access.js"
2829
import { runDockerComposeUpWithPortCheck } from "./projects-up.js"
2930
import { ensureTerminalCursorVisible } from "./terminal-cursor.js"
3031

@@ -213,8 +214,11 @@ export const listProjectStatus: Effect.Effect<
213214
getContainerIpIfInsideContainer(fs, status.projectDir, status.config.template.containerName)
214215
)
215216

217+
const editorAccess = buildEditorSshAccess(status.config.template, sshKey, ipAddress)
218+
216219
yield* _(Effect.log(renderProjectStatusHeader(status)))
217220
yield* _(Effect.log(`SSH access: ${buildSshCommand(status.config.template, sshKey, ipAddress)}`))
221+
yield* _(Effect.log(formatEditorSshAccessSummary(editorAccess, status.config.template.clonedOnHostname)))
218222

219223
const raw = yield* _(runDockerComposePsFormatted(status.projectDir))
220224
const rows = parseComposePsOutput(raw)
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import * as FileSystem from "@effect/platform/FileSystem"
4+
import * as Path from "@effect/platform/Path"
5+
import { Effect } from "effect"
6+
7+
import type { TemplateConfig } from "../core/domain.js"
8+
import { runDockerInspectContainerIp } from "../shell/docker.js"
9+
import { findSshPrivateKey, resolveAuthorizedKeysPath } from "./path-helpers.js"
10+
11+
const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
12+
13+
const sanitizeSshHostAlias = (value: string): string => {
14+
const normalized = value
15+
.trim()
16+
.replace(/[^A-Za-z0-9._-]+/g, "-")
17+
.replace(/-+/g, "-")
18+
.replace(/^[.-]+|[.-]+$/g, "")
19+
return normalized.length === 0 ? "docker-git" : normalized
20+
}
21+
22+
export type EditorSshAccess = {
23+
readonly alias: string
24+
readonly host: string
25+
readonly port: number
26+
readonly workspacePath: string
27+
readonly configSnippet: string
28+
readonly terminalShortcut: string
29+
}
30+
31+
export type ResolvedProjectSshAccess = {
32+
readonly sshCommand: string
33+
readonly editor: EditorSshAccess
34+
readonly authorizedKeysPath: string
35+
readonly authorizedKeysExists: boolean
36+
readonly ipAddress?: string | undefined
37+
}
38+
39+
// CHANGE: centralize ssh command rendering for terminal access
40+
// WHY: keep terminal ssh output and editor ssh output derived from the same topology
41+
// QUOTE(ТЗ): "подключиться по SSH"
42+
// REF: issue-196
43+
// SOURCE: n/a
44+
// FORMAT THEOREM: forall c: config(c) -> command(c) is deterministic
45+
// PURITY: CORE
46+
// EFFECT: n/a
47+
// INVARIANT: localhost uses configured sshPort; container IP uses port 22
48+
// COMPLEXITY: O(1)
49+
export const buildSshCommand = (
50+
config: TemplateConfig,
51+
sshKey: string | null,
52+
ipAddress?: string
53+
): string => {
54+
const host = ipAddress ?? "localhost"
55+
const port = ipAddress ? 22 : config.sshPort
56+
return sshKey === null
57+
? `ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}`
58+
: `ssh -i ${sshKey} ${sshOptions} -p ${port} ${config.sshUser}@${host}`
59+
}
60+
61+
// CHANGE: derive a stable Remote-SSH host alias and config snippet
62+
// WHY: Cursor/VS Code Remote-SSH are more reliable with ~/.ssh/config aliases than inline nested ssh commands
63+
// QUOTE(ТЗ): "Что бы можно было подключиться к SSH одной командой?"
64+
// REF: issue-196
65+
// SOURCE: n/a
66+
// FORMAT THEOREM: forall c: config(c) -> alias(c) ∧ snippet(c)
67+
// PURITY: CORE
68+
// EFFECT: n/a
69+
// INVARIANT: alias is shell-safe and derived from container identity
70+
// COMPLEXITY: O(1)
71+
export const buildEditorSshAccess = (
72+
config: TemplateConfig,
73+
sshKeyPath: string | null,
74+
ipAddress?: string
75+
): EditorSshAccess => {
76+
const alias = sanitizeSshHostAlias(config.containerName)
77+
const host = ipAddress ?? "localhost"
78+
const port = ipAddress ? 22 : config.sshPort
79+
const configLines = [
80+
`Host ${alias}`,
81+
` HostName ${host}`,
82+
` User ${config.sshUser}`,
83+
` Port ${port}`,
84+
" LogLevel ERROR",
85+
" StrictHostKeyChecking no",
86+
" UserKnownHostsFile /dev/null"
87+
]
88+
if (sshKeyPath !== null) {
89+
configLines.push(` IdentityFile ${sshKeyPath}`, " IdentitiesOnly yes")
90+
}
91+
return {
92+
alias,
93+
host,
94+
port,
95+
workspacePath: config.targetDir,
96+
configSnippet: configLines.join("\n"),
97+
terminalShortcut: `ssh ${alias}`
98+
}
99+
}
100+
101+
export const formatEditorSshAccessSummary = (
102+
access: EditorSshAccess,
103+
clonedOnHostname?: string
104+
): string => {
105+
const firstHopLine = clonedOnHostname === undefined
106+
? ""
107+
: `\nFirst hop host: ${clonedOnHostname} (add ProxyJump/ProxyCommand when connecting from another machine)`
108+
return `Remote-SSH host: ${access.alias}
109+
Terminal shortcut: ${access.terminalShortcut}
110+
Remote workspace: ${access.workspacePath}${firstHopLine}`
111+
}
112+
113+
export const formatEditorSshAccessDetails = (
114+
access: EditorSshAccess,
115+
clonedOnHostname?: string
116+
): string => {
117+
const firstHopNote = clonedOnHostname === undefined
118+
? ""
119+
: `\nIf your editor runs on another machine, keep this host block as the inner container target and add your own ProxyJump/ProxyCommand for the first hop to ${clonedOnHostname}.`
120+
return `Remote-SSH host: ${access.alias}
121+
Terminal shortcut: ${access.terminalShortcut}
122+
Remote workspace: ${access.workspacePath}
123+
VS Code/Cursor: Connect to Host... -> ${access.alias}
124+
125+
Add to ~/.ssh/config:
126+
${access.configSnippet}${firstHopNote}`
127+
}
128+
129+
// CHANGE: resolve terminal/editor SSH access from the current runtime context
130+
// WHY: create/clone and list flows need consistent access info without duplicating fs/docker probing
131+
// QUOTE(ТЗ): "как подключиться к SSH к Cursor, VS code"
132+
// REF: issue-196
133+
// SOURCE: n/a
134+
// FORMAT THEOREM: forall c: runtime(c) -> ssh(c) ∧ editor(c)
135+
// PURITY: SHELL
136+
// EFFECT: Effect<ResolvedProjectSshAccess, PlatformError, FileSystem | Path | CommandExecutor>
137+
// INVARIANT: authorized_keys path and ssh key are resolved against the same baseDir
138+
// COMPLEXITY: O(1) + docker inspect
139+
export const resolveProjectSshAccess = (
140+
baseDir: string,
141+
config: TemplateConfig
142+
): Effect.Effect<
143+
ResolvedProjectSshAccess,
144+
PlatformError,
145+
FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
146+
> =>
147+
Effect.gen(function*(_) {
148+
const fs = yield* _(FileSystem.FileSystem)
149+
const path = yield* _(Path.Path)
150+
151+
const isInsideContainer = yield* _(fs.exists("/.dockerenv"))
152+
const ipAddress = isInsideContainer
153+
? yield* _(
154+
runDockerInspectContainerIp(baseDir, config.containerName).pipe(
155+
Effect.orElse(() => Effect.succeed("")),
156+
Effect.map((value) => (value.length > 0 ? value : undefined))
157+
)
158+
)
159+
: undefined
160+
161+
const authorizedKeysPath = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath)
162+
const authorizedKeysExists = yield* _(fs.exists(authorizedKeysPath))
163+
const sshKeyPath = yield* _(findSshPrivateKey(fs, path, process.cwd()))
164+
const editor = buildEditorSshAccess(config, sshKeyPath, ipAddress)
165+
166+
return {
167+
sshCommand: buildSshCommand(config, sshKeyPath, ipAddress),
168+
editor,
169+
authorizedKeysPath,
170+
authorizedKeysExists,
171+
ipAddress
172+
}
173+
})

packages/lib/tests/usecases/connection-info.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest"
33
import type { ProjectConfig } from "../../src/core/domain.js"
44
import { defaultTemplateConfig } from "../../src/core/domain.js"
55
import { formatConnectionInfo } from "../../src/usecases/menu-helpers.js"
6+
import { buildEditorSshAccess, formatEditorSshAccessDetails } from "../../src/usecases/ssh-access.js"
67

78
const makeProjectConfig = (overrides: Partial<ProjectConfig["template"]> = {}): ProjectConfig => ({
89
schemaVersion: 1,
@@ -37,4 +38,21 @@ describe("formatConnectionInfo", () => {
3738
const output = formatConnectionInfo("/project", config, "/keys", true, "ssh dev@localhost")
3839
expect(output).not.toContain("Cloned on device")
3940
})
41+
42+
it("includes Remote-SSH details when provided", () => {
43+
const config = makeProjectConfig({ clonedOnHostname: "meadav" })
44+
const editorAccess = buildEditorSshAccess(config.template, "/home/user/.ssh/id_ed25519")
45+
const output = formatConnectionInfo(
46+
"/project",
47+
config,
48+
"/keys",
49+
true,
50+
"ssh dev@localhost",
51+
formatEditorSshAccessDetails(editorAccess, config.template.clonedOnHostname)
52+
)
53+
expect(output).toContain("Remote-SSH host: dg-test")
54+
expect(output).toContain("Terminal shortcut: ssh dg-test")
55+
expect(output).toContain("VS Code/Cursor: Connect to Host... -> dg-test")
56+
expect(output).toContain("If your editor runs on another machine")
57+
})
4058
})

0 commit comments

Comments
 (0)