From af2e2f5103da0e78a11fec6b76042daa5c793b41 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:05:44 +0000 Subject: [PATCH 1/2] feat(shell): add remote ssh config hints for editors --- .../lib/src/usecases/actions/docker-up.ts | 33 +--- packages/lib/src/usecases/menu-helpers.ts | 5 +- packages/lib/src/usecases/projects-core.ts | 26 +-- packages/lib/src/usecases/projects-ssh.ts | 4 + packages/lib/src/usecases/ssh-access.ts | 173 ++++++++++++++++++ .../tests/usecases/connection-info.test.ts | 18 ++ .../lib/tests/usecases/ssh-access.test.ts | 50 +++++ 7 files changed, 265 insertions(+), 44 deletions(-) create mode 100644 packages/lib/src/usecases/ssh-access.ts create mode 100644 packages/lib/tests/usecases/ssh-access.test.ts diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index aadebeb..2013d57 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -12,14 +12,12 @@ import { runDockerComposeUpRecreate, runDockerExecExitCode, runDockerInspectContainerBridgeIp, - runDockerInspectContainerIp, runDockerNetworkConnectBridge } from "../../shell/docker.js" import type { DockerCommandError } from "../../shell/errors.js" import { AgentFailedError, CloneFailedError } from "../../shell/errors.js" import { ensureComposeNetworkReady } from "../docker-network-gc.js" -import { findSshPrivateKey, resolveAuthorizedKeysPath } from "../path-helpers.js" -import { buildSshCommand } from "../projects.js" +import { formatEditorSshAccessDetails, resolveProjectSshAccess } from "../ssh-access.js" const maxPortAttempts = 25 const clonePollInterval = Duration.seconds(1) @@ -34,33 +32,14 @@ const logSshAccess = ( config: CreateCommand["config"] ): Effect.Effect => Effect.gen(function*(_) { - const fs = yield* _(FileSystem.FileSystem) - const path = yield* _(Path.Path) + const access = yield* _(resolveProjectSshAccess(baseDir, config)) - const isInsideContainer = yield* _(fs.exists("/.dockerenv")) - let ipAddress: string | undefined - - if (isInsideContainer) { - const containerIp = yield* _( - runDockerInspectContainerIp(baseDir, config.containerName).pipe( - Effect.orElse(() => Effect.succeed("")) - ) - ) - if (containerIp.length > 0) { - ipAddress = containerIp - } - } - - const resolvedAuthorizedKeys = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath) - const authExists = yield* _(fs.exists(resolvedAuthorizedKeys)) - const sshKey = yield* _(findSshPrivateKey(fs, path, process.cwd())) - const sshCommand = buildSshCommand(config, sshKey, ipAddress) - - yield* _(Effect.log(`SSH access: ${sshCommand}`)) - if (!authExists) { + yield* _(Effect.log(`SSH access: ${access.sshCommand}`)) + yield* _(Effect.log(formatEditorSshAccessDetails(access.editor, config.clonedOnHostname))) + if (!access.authorizedKeysExists) { yield* _( Effect.logWarning( - `Authorized keys file missing: ${resolvedAuthorizedKeys} (SSH may fail without a matching key).` + `Authorized keys file missing: ${access.authorizedKeysPath} (SSH may fail without a matching key).` ) ) } diff --git a/packages/lib/src/usecases/menu-helpers.ts b/packages/lib/src/usecases/menu-helpers.ts index d745d5a..953a4f2 100644 --- a/packages/lib/src/usecases/menu-helpers.ts +++ b/packages/lib/src/usecases/menu-helpers.ts @@ -15,11 +15,13 @@ export const formatConnectionInfo = ( config: ProjectConfig, authorizedKeysPath: string, authorizedKeysExists: boolean, - sshCommand: string + sshCommand: string, + editorAccessDetails?: string ): string => { const hostnameLabel = config.template.clonedOnHostname === undefined ? "" : `\nCloned on device: ${config.template.clonedOnHostname}` + const editorAccessLabel = editorAccessDetails === undefined ? "" : `\n${editorAccessDetails}` return `Project directory: ${cwd} ` + `Container: ${config.template.containerName} @@ -39,5 +41,6 @@ export const formatConnectionInfo = ( `Env project: ${config.template.envProjectPath} ` + `Codex auth: ${config.template.codexAuthPath} -> ${config.template.codexHome}` + + editorAccessLabel + hostnameLabel } diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index 786d5f8..accbf24 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -4,7 +4,7 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import { Effect, pipe } from "effect" -import type { ProjectConfig, TemplateConfig } from "../core/domain.js" +import type { ProjectConfig } from "../core/domain.js" import { deriveRepoPathParts } from "../core/domain.js" import { readProjectConfig } from "../shell/config.js" import { runDockerInspectContainerIp } from "../shell/docker.js" @@ -14,28 +14,17 @@ import { findDockerGitConfigPaths } from "./docker-git-config-search.js" import { renderError } from "./errors.js" import { defaultProjectsRoot, formatConnectionInfo } from "./menu-helpers.js" import { findSshPrivateKey, resolveAuthorizedKeysPath, resolvePathFromCwd } from "./path-helpers.js" +import { buildEditorSshAccess, buildSshCommand, formatEditorSshAccessDetails } from "./ssh-access.js" import { withFsPathContext } from "./runtime.js" -const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" - export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError - -export const buildSshCommand = ( - config: TemplateConfig, - sshKey: string | null, - ipAddress?: string -): string => { - const host = ipAddress ?? "localhost" - const port = ipAddress ? 22 : config.sshPort - return sshKey === null - ? `ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}` - : `ssh -i ${sshKey} ${sshOptions} -p ${port} ${config.sshUser}@${host}` -} +export { buildSshCommand } export type ProjectSummary = { readonly projectDir: string readonly config: ProjectConfig readonly sshCommand: string + readonly sshKeyPath: string | null readonly ipAddress?: string | undefined readonly authorizedKeysPath: string readonly authorizedKeysExists: boolean @@ -140,6 +129,7 @@ export const loadProjectSummary = ( projectDir, config, sshCommand, + sshKeyPath: sshKey, ipAddress, authorizedKeysPath: resolvedAuthorizedKeys, authorizedKeysExists: authExists @@ -160,7 +150,11 @@ export const renderProjectSummary = (summary: ProjectSummary): string => summary.config, summary.authorizedKeysPath, summary.authorizedKeysExists, - summary.sshCommand + summary.sshCommand, + formatEditorSshAccessDetails( + buildEditorSshAccess(summary.config.template, summary.sshKeyPath, summary.ipAddress), + summary.config.template.clonedOnHostname + ) ) const formatDisplayName = (repoUrl: string): string => { diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 7f10210..4cd6a8b 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -25,6 +25,7 @@ import { renderProjectStatusHeader, withProjectIndexAndSsh } from "./projects-core.js" +import { buildEditorSshAccess, formatEditorSshAccessSummary } from "./ssh-access.js" import { runDockerComposeUpWithPortCheck } from "./projects-up.js" import { ensureTerminalCursorVisible } from "./terminal-cursor.js" @@ -213,8 +214,11 @@ export const listProjectStatus: Effect.Effect< getContainerIpIfInsideContainer(fs, status.projectDir, status.config.template.containerName) ) + const editorAccess = buildEditorSshAccess(status.config.template, sshKey, ipAddress) + yield* _(Effect.log(renderProjectStatusHeader(status))) yield* _(Effect.log(`SSH access: ${buildSshCommand(status.config.template, sshKey, ipAddress)}`)) + yield* _(Effect.log(formatEditorSshAccessSummary(editorAccess, status.config.template.clonedOnHostname))) const raw = yield* _(runDockerComposePsFormatted(status.projectDir)) const rows = parseComposePsOutput(raw) diff --git a/packages/lib/src/usecases/ssh-access.ts b/packages/lib/src/usecases/ssh-access.ts new file mode 100644 index 0000000..1e32950 --- /dev/null +++ b/packages/lib/src/usecases/ssh-access.ts @@ -0,0 +1,173 @@ +import type * as CommandExecutor from "@effect/platform/CommandExecutor" +import type { PlatformError } from "@effect/platform/Error" +import * as FileSystem from "@effect/platform/FileSystem" +import * as Path from "@effect/platform/Path" +import { Effect } from "effect" + +import type { TemplateConfig } from "../core/domain.js" +import { runDockerInspectContainerIp } from "../shell/docker.js" +import { findSshPrivateKey, resolveAuthorizedKeysPath } from "./path-helpers.js" + +const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" + +const sanitizeSshHostAlias = (value: string): string => { + const normalized = value + .trim() + .replace(/[^A-Za-z0-9._-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[.-]+|[.-]+$/g, "") + return normalized.length === 0 ? "docker-git" : normalized +} + +export type EditorSshAccess = { + readonly alias: string + readonly host: string + readonly port: number + readonly workspacePath: string + readonly configSnippet: string + readonly terminalShortcut: string +} + +export type ResolvedProjectSshAccess = { + readonly sshCommand: string + readonly editor: EditorSshAccess + readonly authorizedKeysPath: string + readonly authorizedKeysExists: boolean + readonly ipAddress?: string | undefined +} + +// CHANGE: centralize ssh command rendering for terminal access +// WHY: keep terminal ssh output and editor ssh output derived from the same topology +// QUOTE(ТЗ): "подключиться по SSH" +// REF: issue-196 +// SOURCE: n/a +// FORMAT THEOREM: forall c: config(c) -> command(c) is deterministic +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: localhost uses configured sshPort; container IP uses port 22 +// COMPLEXITY: O(1) +export const buildSshCommand = ( + config: TemplateConfig, + sshKey: string | null, + ipAddress?: string +): string => { + const host = ipAddress ?? "localhost" + const port = ipAddress ? 22 : config.sshPort + return sshKey === null + ? `ssh ${sshOptions} -p ${port} ${config.sshUser}@${host}` + : `ssh -i ${sshKey} ${sshOptions} -p ${port} ${config.sshUser}@${host}` +} + +// CHANGE: derive a stable Remote-SSH host alias and config snippet +// WHY: Cursor/VS Code Remote-SSH are more reliable with ~/.ssh/config aliases than inline nested ssh commands +// QUOTE(ТЗ): "Что бы можно было подключиться к SSH одной командой?" +// REF: issue-196 +// SOURCE: n/a +// FORMAT THEOREM: forall c: config(c) -> alias(c) ∧ snippet(c) +// PURITY: CORE +// EFFECT: n/a +// INVARIANT: alias is shell-safe and derived from container identity +// COMPLEXITY: O(1) +export const buildEditorSshAccess = ( + config: TemplateConfig, + sshKeyPath: string | null, + ipAddress?: string +): EditorSshAccess => { + const alias = sanitizeSshHostAlias(config.containerName) + const host = ipAddress ?? "localhost" + const port = ipAddress ? 22 : config.sshPort + const configLines = [ + `Host ${alias}`, + ` HostName ${host}`, + ` User ${config.sshUser}`, + ` Port ${port}`, + " LogLevel ERROR", + " StrictHostKeyChecking no", + " UserKnownHostsFile /dev/null" + ] + if (sshKeyPath !== null) { + configLines.push(` IdentityFile ${sshKeyPath}`, " IdentitiesOnly yes") + } + return { + alias, + host, + port, + workspacePath: config.targetDir, + configSnippet: configLines.join("\n"), + terminalShortcut: `ssh ${alias}` + } +} + +export const formatEditorSshAccessSummary = ( + access: EditorSshAccess, + clonedOnHostname?: string +): string => { + const firstHopLine = clonedOnHostname === undefined + ? "" + : `\nFirst hop host: ${clonedOnHostname} (add ProxyJump/ProxyCommand when connecting from another machine)` + return `Remote-SSH host: ${access.alias} +Terminal shortcut: ${access.terminalShortcut} +Remote workspace: ${access.workspacePath}${firstHopLine}` +} + +export const formatEditorSshAccessDetails = ( + access: EditorSshAccess, + clonedOnHostname?: string +): string => { + const firstHopNote = clonedOnHostname === undefined + ? "" + : `\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}.` + return `Remote-SSH host: ${access.alias} +Terminal shortcut: ${access.terminalShortcut} +Remote workspace: ${access.workspacePath} +VS Code/Cursor: Connect to Host... -> ${access.alias} + +Add to ~/.ssh/config: +${access.configSnippet}${firstHopNote}` +} + +// CHANGE: resolve terminal/editor SSH access from the current runtime context +// WHY: create/clone and list flows need consistent access info without duplicating fs/docker probing +// QUOTE(ТЗ): "как подключиться к SSH к Cursor, VS code" +// REF: issue-196 +// SOURCE: n/a +// FORMAT THEOREM: forall c: runtime(c) -> ssh(c) ∧ editor(c) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: authorized_keys path and ssh key are resolved against the same baseDir +// COMPLEXITY: O(1) + docker inspect +export const resolveProjectSshAccess = ( + baseDir: string, + config: TemplateConfig +): Effect.Effect< + ResolvedProjectSshAccess, + PlatformError, + FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor +> => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const path = yield* _(Path.Path) + + const isInsideContainer = yield* _(fs.exists("/.dockerenv")) + const ipAddress = isInsideContainer + ? yield* _( + runDockerInspectContainerIp(baseDir, config.containerName).pipe( + Effect.orElse(() => Effect.succeed("")), + Effect.map((value) => (value.length > 0 ? value : undefined)) + ) + ) + : undefined + + const authorizedKeysPath = resolveAuthorizedKeysPath(path, baseDir, config.authorizedKeysPath) + const authorizedKeysExists = yield* _(fs.exists(authorizedKeysPath)) + const sshKeyPath = yield* _(findSshPrivateKey(fs, path, process.cwd())) + const editor = buildEditorSshAccess(config, sshKeyPath, ipAddress) + + return { + sshCommand: buildSshCommand(config, sshKeyPath, ipAddress), + editor, + authorizedKeysPath, + authorizedKeysExists, + ipAddress + } + }) diff --git a/packages/lib/tests/usecases/connection-info.test.ts b/packages/lib/tests/usecases/connection-info.test.ts index e6c8414..f90f626 100644 --- a/packages/lib/tests/usecases/connection-info.test.ts +++ b/packages/lib/tests/usecases/connection-info.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "@effect/vitest" import type { ProjectConfig } from "../../src/core/domain.js" import { defaultTemplateConfig } from "../../src/core/domain.js" import { formatConnectionInfo } from "../../src/usecases/menu-helpers.js" +import { buildEditorSshAccess, formatEditorSshAccessDetails } from "../../src/usecases/ssh-access.js" const makeProjectConfig = (overrides: Partial = {}): ProjectConfig => ({ schemaVersion: 1, @@ -37,4 +38,21 @@ describe("formatConnectionInfo", () => { const output = formatConnectionInfo("/project", config, "/keys", true, "ssh dev@localhost") expect(output).not.toContain("Cloned on device") }) + + it("includes Remote-SSH details when provided", () => { + const config = makeProjectConfig({ clonedOnHostname: "meadav" }) + const editorAccess = buildEditorSshAccess(config.template, "/home/user/.ssh/id_ed25519") + const output = formatConnectionInfo( + "/project", + config, + "/keys", + true, + "ssh dev@localhost", + formatEditorSshAccessDetails(editorAccess, config.template.clonedOnHostname) + ) + expect(output).toContain("Remote-SSH host: dg-test") + expect(output).toContain("Terminal shortcut: ssh dg-test") + expect(output).toContain("VS Code/Cursor: Connect to Host... -> dg-test") + expect(output).toContain("If your editor runs on another machine") + }) }) diff --git a/packages/lib/tests/usecases/ssh-access.test.ts b/packages/lib/tests/usecases/ssh-access.test.ts new file mode 100644 index 0000000..7f19c4f --- /dev/null +++ b/packages/lib/tests/usecases/ssh-access.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "@effect/vitest" + +import type { TemplateConfig } from "../../src/core/domain.js" +import { defaultTemplateConfig } from "../../src/core/domain.js" +import { buildEditorSshAccess, buildSshCommand } from "../../src/usecases/ssh-access.js" + +const makeTemplateConfig = (overrides: Partial = {}): TemplateConfig => ({ + ...defaultTemplateConfig, + containerName: "dg-test", + serviceName: "dg-test", + sshUser: "dev", + sshPort: 2222, + repoUrl: "https://github.com/org/repo.git", + repoRef: "main", + targetDir: "/home/dev/workspaces/org/repo", + volumeName: "dg-test-home", + dockerGitPath: "/workspace/.docker-git", + authorizedKeysPath: "/workspace/authorized_keys", + envGlobalPath: "/workspace/.orch/env/global.env", + envProjectPath: "/workspace/.orch/env/project.env", + codexAuthPath: "/workspace/.orch/auth/codex", + codexSharedAuthPath: "/workspace/.orch/auth/codex-shared", + geminiAuthPath: "/workspace/.orch/auth/gemini", + ...overrides +}) + +describe("ssh access helpers", () => { + it("builds Remote-SSH access details for localhost ssh", () => { + const template = makeTemplateConfig() + const access = buildEditorSshAccess(template, "/home/user/.ssh/id_ed25519") + + expect(access.alias).toBe("dg-test") + expect(access.terminalShortcut).toBe("ssh dg-test") + expect(access.workspacePath).toBe("/home/dev/workspaces/org/repo") + expect(access.configSnippet).toContain("Host dg-test") + expect(access.configSnippet).toContain("HostName localhost") + expect(access.configSnippet).toContain("Port 2222") + expect(access.configSnippet).toContain("IdentityFile /home/user/.ssh/id_ed25519") + }) + + it("switches to container IP addressing when nested inside docker", () => { + const template = makeTemplateConfig() + const access = buildEditorSshAccess(template, null, "172.17.0.6") + const sshCommand = buildSshCommand(template, null, "172.17.0.6") + + expect(access.configSnippet).toContain("HostName 172.17.0.6") + expect(access.configSnippet).toContain("Port 22") + expect(sshCommand).toContain("-p 22 dev@172.17.0.6") + }) +}) From dc6e3a716402cd6b2ad85e6ce10962330a8badaa Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:22:51 +0000 Subject: [PATCH 2/2] fix(shell): satisfy lint rules for ssh access hints --- .../lib/src/usecases/actions/docker-up.ts | 4 +- packages/lib/src/usecases/menu-helpers.ts | 18 ++++---- packages/lib/src/usecases/projects-core.ts | 21 ++++++---- packages/lib/src/usecases/projects-ssh.ts | 2 +- packages/lib/src/usecases/ssh-access.ts | 41 ++++++++++++++++--- .../tests/usecases/connection-info.test.ts | 26 +++++++----- 6 files changed, 78 insertions(+), 34 deletions(-) diff --git a/packages/lib/src/usecases/actions/docker-up.ts b/packages/lib/src/usecases/actions/docker-up.ts index 2013d57..2da56eb 100644 --- a/packages/lib/src/usecases/actions/docker-up.ts +++ b/packages/lib/src/usecases/actions/docker-up.ts @@ -1,7 +1,7 @@ import type * as CommandExecutor from "@effect/platform/CommandExecutor" import type { PlatformError } from "@effect/platform/Error" -import * as FileSystem from "@effect/platform/FileSystem" -import * as Path from "@effect/platform/Path" +import type * as FileSystem from "@effect/platform/FileSystem" +import type * as Path from "@effect/platform/Path" import { Duration, Effect, Fiber, Schedule } from "effect" import type { CreateCommand } from "../../core/domain.js" diff --git a/packages/lib/src/usecases/menu-helpers.ts b/packages/lib/src/usecases/menu-helpers.ts index 953a4f2..895d583 100644 --- a/packages/lib/src/usecases/menu-helpers.ts +++ b/packages/lib/src/usecases/menu-helpers.ts @@ -10,31 +10,35 @@ export const isRepoUrlInput = (input: string): boolean => { trimmed.startsWith("git@") } +type ConnectionInfoOptions = { + readonly authorizedKeysPath: string + readonly authorizedKeysExists: boolean + readonly sshCommand: string + readonly editorAccessDetails?: string +} + export const formatConnectionInfo = ( cwd: string, config: ProjectConfig, - authorizedKeysPath: string, - authorizedKeysExists: boolean, - sshCommand: string, - editorAccessDetails?: string + options: ConnectionInfoOptions ): string => { const hostnameLabel = config.template.clonedOnHostname === undefined ? "" : `\nCloned on device: ${config.template.clonedOnHostname}` - const editorAccessLabel = editorAccessDetails === undefined ? "" : `\n${editorAccessDetails}` + const editorAccessLabel = options.editorAccessDetails === undefined ? "" : `\n${options.editorAccessDetails}` return `Project directory: ${cwd} ` + `Container: ${config.template.containerName} ` + `Service: ${config.template.serviceName} ` + - `SSH command: ${sshCommand} + `SSH command: ${options.sshCommand} ` + `Repo: ${config.template.repoUrl} (${config.template.repoRef}) ` + `Workspace: ${config.template.targetDir} ` + - `Authorized keys: ${authorizedKeysPath}${authorizedKeysExists ? "" : " (missing)"} + `Authorized keys: ${options.authorizedKeysPath}${options.authorizedKeysExists ? "" : " (missing)"} ` + `Env global: ${config.template.envGlobalPath} ` + diff --git a/packages/lib/src/usecases/projects-core.ts b/packages/lib/src/usecases/projects-core.ts index accbf24..8e54ac3 100644 --- a/packages/lib/src/usecases/projects-core.ts +++ b/packages/lib/src/usecases/projects-core.ts @@ -14,11 +14,10 @@ import { findDockerGitConfigPaths } from "./docker-git-config-search.js" import { renderError } from "./errors.js" import { defaultProjectsRoot, formatConnectionInfo } from "./menu-helpers.js" import { findSshPrivateKey, resolveAuthorizedKeysPath, resolvePathFromCwd } from "./path-helpers.js" -import { buildEditorSshAccess, buildSshCommand, formatEditorSshAccessDetails } from "./ssh-access.js" import { withFsPathContext } from "./runtime.js" +import { buildEditorSshAccess, buildSshCommand, formatEditorSshAccessDetails } from "./ssh-access.js" export type ProjectLoadError = PlatformError | ConfigNotFoundError | ConfigDecodeError -export { buildSshCommand } export type ProjectSummary = { readonly projectDir: string @@ -148,13 +147,15 @@ export const renderProjectSummary = (summary: ProjectSummary): string => formatConnectionInfo( summary.projectDir, summary.config, - summary.authorizedKeysPath, - summary.authorizedKeysExists, - summary.sshCommand, - formatEditorSshAccessDetails( - buildEditorSshAccess(summary.config.template, summary.sshKeyPath, summary.ipAddress), - summary.config.template.clonedOnHostname - ) + { + authorizedKeysPath: summary.authorizedKeysPath, + authorizedKeysExists: summary.authorizedKeysExists, + sshCommand: summary.sshCommand, + editorAccessDetails: formatEditorSshAccessDetails( + buildEditorSshAccess(summary.config.template, summary.sshKeyPath, summary.ipAddress), + summary.config.template.clonedOnHostname + ) + } ) const formatDisplayName = (repoUrl: string): string => { @@ -309,3 +310,5 @@ export const withProjectIndexAndSsh = ( }) ) ) + +export { buildSshCommand } from "./ssh-access.js" diff --git a/packages/lib/src/usecases/projects-ssh.ts b/packages/lib/src/usecases/projects-ssh.ts index 4cd6a8b..cecdc8d 100644 --- a/packages/lib/src/usecases/projects-ssh.ts +++ b/packages/lib/src/usecases/projects-ssh.ts @@ -25,8 +25,8 @@ import { renderProjectStatusHeader, withProjectIndexAndSsh } from "./projects-core.js" -import { buildEditorSshAccess, formatEditorSshAccessSummary } from "./ssh-access.js" import { runDockerComposeUpWithPortCheck } from "./projects-up.js" +import { buildEditorSshAccess, formatEditorSshAccessSummary } from "./ssh-access.js" import { ensureTerminalCursorVisible } from "./terminal-cursor.js" const buildSshArgs = (item: ProjectItem): ReadonlyArray => { diff --git a/packages/lib/src/usecases/ssh-access.ts b/packages/lib/src/usecases/ssh-access.ts index 1e32950..d286553 100644 --- a/packages/lib/src/usecases/ssh-access.ts +++ b/packages/lib/src/usecases/ssh-access.ts @@ -10,12 +10,43 @@ import { findSshPrivateKey, resolveAuthorizedKeysPath } from "./path-helpers.js" const sshOptions = "-tt -Y -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" +const aliasCharPattern = /^[A-Za-z0-9._-]$/u + +const isAliasChar = (value: string): boolean => aliasCharPattern.test(value) + +const trimAliasEdges = (value: string): string => { + let start = 0 + let end = value.length + + while (start < end && (value[start] === "." || value[start] === "-")) { + start += 1 + } + + while (end > start && (value[end - 1] === "." || value[end - 1] === "-")) { + end -= 1 + } + + return value.slice(start, end) +} + const sanitizeSshHostAlias = (value: string): string => { - const normalized = value - .trim() - .replace(/[^A-Za-z0-9._-]+/g, "-") - .replace(/-+/g, "-") - .replace(/^[.-]+|[.-]+$/g, "") + let normalized = "" + let previousWasDash = false + + for (const char of value.trim()) { + if (isAliasChar(char)) { + normalized += char + previousWasDash = false + continue + } + + if (!previousWasDash) { + normalized += "-" + previousWasDash = true + } + } + + normalized = trimAliasEdges(normalized) return normalized.length === 0 ? "docker-git" : normalized } diff --git a/packages/lib/tests/usecases/connection-info.test.ts b/packages/lib/tests/usecases/connection-info.test.ts index f90f626..3a0dd50 100644 --- a/packages/lib/tests/usecases/connection-info.test.ts +++ b/packages/lib/tests/usecases/connection-info.test.ts @@ -29,27 +29,33 @@ const makeProjectConfig = (overrides: Partial = {}): describe("formatConnectionInfo", () => { it("includes clonedOnHostname when present", () => { const config = makeProjectConfig({ clonedOnHostname: "my-laptop" }) - const output = formatConnectionInfo("/project", config, "/keys", true, "ssh dev@localhost") + const output = formatConnectionInfo("/project", config, { + authorizedKeysPath: "/keys", + authorizedKeysExists: true, + sshCommand: "ssh dev@localhost" + }) expect(output).toContain("Cloned on device: my-laptop") }) it("omits clonedOnHostname line when undefined", () => { const config = makeProjectConfig() - const output = formatConnectionInfo("/project", config, "/keys", true, "ssh dev@localhost") + const output = formatConnectionInfo("/project", config, { + authorizedKeysPath: "/keys", + authorizedKeysExists: true, + sshCommand: "ssh dev@localhost" + }) expect(output).not.toContain("Cloned on device") }) it("includes Remote-SSH details when provided", () => { const config = makeProjectConfig({ clonedOnHostname: "meadav" }) const editorAccess = buildEditorSshAccess(config.template, "/home/user/.ssh/id_ed25519") - const output = formatConnectionInfo( - "/project", - config, - "/keys", - true, - "ssh dev@localhost", - formatEditorSshAccessDetails(editorAccess, config.template.clonedOnHostname) - ) + const output = formatConnectionInfo("/project", config, { + authorizedKeysPath: "/keys", + authorizedKeysExists: true, + sshCommand: "ssh dev@localhost", + editorAccessDetails: formatEditorSshAccessDetails(editorAccess, config.template.clonedOnHostname) + }) expect(output).toContain("Remote-SSH host: dg-test") expect(output).toContain("Terminal shortcut: ssh dg-test") expect(output).toContain("VS Code/Cursor: Connect to Host... -> dg-test")