Skip to content

Commit a406743

Browse files
authored
Merge pull request #197 from skulidropek/issue-196
feat: add Remote-SSH config hints for editor access
2 parents 41a86a1 + dc6e3a7 commit a406743

7 files changed

Lines changed: 319 additions & 54 deletions

File tree

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

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
22
import type { PlatformError } from "@effect/platform/Error"
3-
import * as FileSystem from "@effect/platform/FileSystem"
4-
import * as Path from "@effect/platform/Path"
3+
import type * as FileSystem from "@effect/platform/FileSystem"
4+
import type * as Path from "@effect/platform/Path"
55
import { Duration, Effect, Fiber, Schedule } from "effect"
66

77
import type { CreateCommand } from "../../core/domain.js"
@@ -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: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,34 +10,41 @@ export const isRepoUrlInput = (input: string): boolean => {
1010
trimmed.startsWith("git@")
1111
}
1212

13+
type ConnectionInfoOptions = {
14+
readonly authorizedKeysPath: string
15+
readonly authorizedKeysExists: boolean
16+
readonly sshCommand: string
17+
readonly editorAccessDetails?: string
18+
}
19+
1320
export const formatConnectionInfo = (
1421
cwd: string,
1522
config: ProjectConfig,
16-
authorizedKeysPath: string,
17-
authorizedKeysExists: boolean,
18-
sshCommand: string
23+
options: ConnectionInfoOptions
1924
): string => {
2025
const hostnameLabel = config.template.clonedOnHostname === undefined
2126
? ""
2227
: `\nCloned on device: ${config.template.clonedOnHostname}`
28+
const editorAccessLabel = options.editorAccessDetails === undefined ? "" : `\n${options.editorAccessDetails}`
2329
return `Project directory: ${cwd}
2430
` +
2531
`Container: ${config.template.containerName}
2632
` +
2733
`Service: ${config.template.serviceName}
2834
` +
29-
`SSH command: ${sshCommand}
35+
`SSH command: ${options.sshCommand}
3036
` +
3137
`Repo: ${config.template.repoUrl} (${config.template.repoRef})
3238
` +
3339
`Workspace: ${config.template.targetDir}
3440
` +
35-
`Authorized keys: ${authorizedKeysPath}${authorizedKeysExists ? "" : " (missing)"}
41+
`Authorized keys: ${options.authorizedKeysPath}${options.authorizedKeysExists ? "" : " (missing)"}
3642
` +
3743
`Env global: ${config.template.envGlobalPath}
3844
` +
3945
`Env project: ${config.template.envProjectPath}
4046
` +
4147
`Codex auth: ${config.template.codexAuthPath} -> ${config.template.codexHome}` +
48+
editorAccessLabel +
4249
hostnameLabel
4350
}

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

Lines changed: 15 additions & 18 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"
@@ -15,27 +15,15 @@ import { renderError } from "./errors.js"
1515
import { defaultProjectsRoot, formatConnectionInfo } from "./menu-helpers.js"
1616
import { findSshPrivateKey, resolveAuthorizedKeysPath, resolvePathFromCwd } from "./path-helpers.js"
1717
import { withFsPathContext } from "./runtime.js"
18-
19-
const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
18+
import { buildEditorSshAccess, buildSshCommand, formatEditorSshAccessDetails } from "./ssh-access.js"
2019

2120
export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError
2221

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-
}
34-
3522
export type ProjectSummary = {
3623
readonly projectDir: string
3724
readonly config: ProjectConfig
3825
readonly sshCommand: string
26+
readonly sshKeyPath: string | null
3927
readonly ipAddress?: string | undefined
4028
readonly authorizedKeysPath: string
4129
readonly authorizedKeysExists: boolean
@@ -140,6 +128,7 @@ export const loadProjectSummary = (
140128
projectDir,
141129
config,
142130
sshCommand,
131+
sshKeyPath: sshKey,
143132
ipAddress,
144133
authorizedKeysPath: resolvedAuthorizedKeys,
145134
authorizedKeysExists: authExists
@@ -158,9 +147,15 @@ export const renderProjectSummary = (summary: ProjectSummary): string =>
158147
formatConnectionInfo(
159148
summary.projectDir,
160149
summary.config,
161-
summary.authorizedKeysPath,
162-
summary.authorizedKeysExists,
163-
summary.sshCommand
150+
{
151+
authorizedKeysPath: summary.authorizedKeysPath,
152+
authorizedKeysExists: summary.authorizedKeysExists,
153+
sshCommand: summary.sshCommand,
154+
editorAccessDetails: formatEditorSshAccessDetails(
155+
buildEditorSshAccess(summary.config.template, summary.sshKeyPath, summary.ipAddress),
156+
summary.config.template.clonedOnHostname
157+
)
158+
}
164159
)
165160

166161
const formatDisplayName = (repoUrl: string): string => {
@@ -315,3 +310,5 @@ export const withProjectIndexAndSsh = <A, E, R>(
315310
})
316311
)
317312
)
313+
314+
export { buildSshCommand } from "./ssh-access.js"

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

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

3132
const buildSshArgs = (item: ProjectItem): ReadonlyArray<string> => {
@@ -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)

0 commit comments

Comments
 (0)