Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
421ddaa
feat(app): URL-based container access sharing (issue #428)
skulidropek Jun 19, 2026
8550858
fix(app): VS Code open in share link — direct hostName URI + anchor tag
skulidropek Jun 19, 2026
781fe34
feat(api): per-share-link SSH cloudflared tunnel for VS Code Remote SSH
skulidropek Jun 20, 2026
e24edb0
feat(share-link): SSH password auth — no key needed for share link ac…
skulidropek Jun 20, 2026
26c967d
feat(share-link): wildcard CF SSH setup — one config for all containers
skulidropek Jun 20, 2026
49179e6
feat(app): per-container CF SSH tunnel for VS Code panel (#428)
skulidropek Jun 20, 2026
18aea42
feat(app): show SSH password in VS Code CF tunnel panel
skulidropek Jun 20, 2026
85ddedb
fix(app): SSH command opens targetDir on connect
skulidropek Jun 20, 2026
6a49c57
fix(app): SSH config shows specific hostname, not wildcard
skulidropek Jun 20, 2026
46f6880
fix(app): SSH config includes RemoteCommand for auto cd into project
skulidropek Jun 20, 2026
99ddb61
feat(app): auto-restart CF tunnel when process dies + refresh button
skulidropek Jun 20, 2026
ef4b61f
fix(api): store SSH password in tunnel record, return stable password
skulidropek Jun 20, 2026
95d30e3
fix(app): restore inline ProxyCommand in SSH connect command
skulidropek Jun 20, 2026
d455535
fix(app): remove remote command from inline SSH — conflicts with conf…
skulidropek Jun 20, 2026
eb1e0cf
fix(app): remove RemoteCommand from SSH config — breaks VS Code Remot…
skulidropek Jun 20, 2026
02a5bd2
fix(app): add User to SSH config block — VS Code used local Windows u…
skulidropek Jun 20, 2026
658a11e
feat(app): direct SSH (local network) section in VS Code panel
skulidropek Jun 20, 2026
ef931d8
feat(app): show password + auto-cd in Direct SSH section
skulidropek Jun 20, 2026
9132fdc
fix(pr-434): address CodeRabbit review issues
skulidropek Jun 20, 2026
0847b71
fix(ci): resolve all ESLint max-lines, max-params, and complexity vio…
skulidropek Jun 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
262 changes: 247 additions & 15 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,6 @@
"globals": "^17.6.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.61.1",
"vitest": "^4.1.9"
"vitest": "^3.2.0"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Vitest и связанные пакеты в package.json файлах =="
fd -a "package.json" | while read -r f; do
  echo "--- $f"
  rg -n '"vitest"|"`@vitest/`' "$f" || true
done

echo
echo "== Упоминания vitest в конфигурациях и тестовых скриптах =="
rg -n --glob '**/*.{json,ts,js,mjs,cjs,yml,yaml}' '\bvitest\b|`@vitest/`' .

echo
echo "== Lockfile entries (если есть) =="
fd -a 'pnpm-lock.yaml|package-lock.json|bun.lockb|bun.lock|yarn.lock' | while read -r lf; do
  echo "--- $lf"
  rg -n 'vitest|`@vitest/`' "$lf" || true
done

Repository: ProverCoderAI/docker-git

Length of output: 50380


Даунгрейд vitest до ^3.2.0 нарушает peer dependency requirement @effect/vitest и создаёт критический риск.

Версия @effect/vitest@0.29.0 (используется всеми пакетами, включая api) требует vitest@4.1.0 как peer dependency. Изменение packages/api/package.json на vitest@^3.2.0 конфликтует с этим требованием и приведёт к сбоям при запуске тестов (все test-файлы в packages/api/tests/ импортируют из @effect/vitest). Кроме того, это нарушает консистентность версий в монорепо, где все остальные пакеты используют vitest@^4.1.9.

Либо вернуть vitest@^4.1.9 с объяснением почему это не удалось, либо обновить @effect/vitest на совместимую версию и задокументировать причину миграции.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/api/package.json` at line 57, The vitest downgrade to ^3.2.0 in
packages/api/package.json creates a peer dependency conflict with
`@effect/vitest`@0.29.0 which requires vitest@4.1.0, causing test failures in
packages/api/tests/ where files import from `@effect/vitest`. Additionally, this
breaks version consistency with other monorepo packages using vitest@^4.1.9.
Either revert the vitest version back to ^4.1.9 with documentation explaining
why the downgrade was necessary if attempted, or upgrade `@effect/vitest` to a
version compatible with vitest@3.2.0 and clearly document the reason for the
entire migration in the commit message.

Source: Coding guidelines

}
}
26 changes: 26 additions & 0 deletions packages/api/src/api/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -809,3 +809,29 @@ export type ApiEvent = {
readonly at: string
readonly payload: unknown
}

export type ShareLinkInfo = {
readonly token: string
readonly projectKey: string
readonly projectDir: string
readonly displayName: string
readonly sshAlias: string
readonly sshConfigSnippet: string
readonly cfSshConfigSnippet: string | null
readonly vscodeUri: string
readonly cfVscodeUri: string | null
readonly workspacePath: string
readonly sshPassword: string | null
readonly createdAt: string
readonly expiresAt: string
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export type CreateShareLinkRequest = {
readonly ttlMs?: number | undefined
}

export type CreateShareLinkResponse = {
readonly ok: true
readonly link: ShareLinkInfo
readonly url: string
}
14 changes: 14 additions & 0 deletions packages/api/src/api/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ import {
ProjectDatabaseSessionSchema,
ProjectPortForwardRequestSchema,
ProjectSkillUpdateRequestSchema,
CreateShareLinkRequestSchema,
ShareLinkInfoResponseSchema,
CreateShareLinkResponseSchema,
ShareLinksListResponseSchema,
StartPanelCloudflareTunnelRequestSchema,
StartProjectTerminalSessionRequestSchema,
UpProjectRequestSchema
Expand Down Expand Up @@ -735,6 +739,8 @@ const TasksGroup = HttpApiGroup.make("tasks")
.addSuccess(OutputResponseSchema)
)

const ShareLinkTokenParam = HttpApiSchema.param("token", Schema.String)

const SharingGroup = HttpApiGroup.make("sharing")
.add(endpoint.get("readPanelCloudflareTunnel", "/cloudflare-tunnels/panel").addSuccess(PanelCloudflareTunnelResponseSchema))
.add(
Expand All @@ -743,6 +749,14 @@ const SharingGroup = HttpApiGroup.make("sharing")
.addSuccess(PanelCloudflareTunnelResponseSchema, { status: 202 })
)
.add(endpoint.del("stopPanelCloudflareTunnel", "/cloudflare-tunnels/panel").addSuccess(PanelCloudflareTunnelResponseSchema))
.add(endpoint.get("getShareLink")`/share-links/${ShareLinkTokenParam}`.addSuccess(ShareLinkInfoResponseSchema))
.add(
endpoint.post("createShareLink")`/projects/by-key/${ProjectKeyParam}/share-links`
.setPayload(CreateShareLinkRequestSchema)
.addSuccess(CreateShareLinkResponseSchema, { status: 201 })
)
.add(endpoint.get("listShareLinks")`/projects/by-key/${ProjectKeyParam}/share-links`.addSuccess(ShareLinksListResponseSchema))
.add(endpoint.del("deleteShareLink")`/projects/by-key/${ProjectKeyParam}/share-links/${ShareLinkTokenParam}`.addSuccess(OkResponseSchema))

export const DockerGitApi = HttpApi.make("docker-git")
.annotate(OpenApi.Title, "docker-git API")
Expand Down
34 changes: 34 additions & 0 deletions packages/api/src/api/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,3 +392,37 @@ export type ProjectPortForwardRequestInput = Schema.Schema.Type<typeof ProjectPo
export type ProjectDatabaseProfileRequestInput = Schema.Schema.Type<typeof ProjectDatabaseProfileRequestSchema>
export type CreateAgentRequestInput = Schema.Schema.Type<typeof CreateAgentRequestSchema>
export type CreateFollowRequestInput = Schema.Schema.Type<typeof CreateFollowRequestSchema>

export const CreateShareLinkRequestSchema = Schema.Struct({
ttlMs: Schema.optional(Schema.Number)
})

export const ShareLinkInfoSchema = Schema.Struct({
token: Schema.String,
projectKey: Schema.String,
projectDir: Schema.String,
displayName: Schema.String,
sshAlias: Schema.String,
sshConfigSnippet: Schema.String,
cfSshConfigSnippet: Schema.NullOr(Schema.String),
vscodeUri: Schema.String,
cfVscodeUri: Schema.NullOr(Schema.String),
workspacePath: Schema.String,
sshPassword: Schema.NullOr(Schema.String),
createdAt: Schema.String,
expiresAt: Schema.String
})

export const ShareLinkInfoResponseSchema = Schema.Struct({
link: ShareLinkInfoSchema
})

export const CreateShareLinkResponseSchema = Schema.Struct({
ok: Schema.Literal(true),
link: ShareLinkInfoSchema,
url: Schema.String
})

export const ShareLinksListResponseSchema = Schema.Struct({
links: Schema.Array(ShareLinkInfoSchema)
})
164 changes: 164 additions & 0 deletions packages/api/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
ProjectPortForwardRequestSchema,
ProjectPromptUpdateRequestSchema,
ProjectSkillUpdateRequestSchema,
CreateShareLinkRequestSchema,
StartProjectTerminalSessionRequestSchema,
StartPanelCloudflareTunnelRequestSchema,
StateCommitRequestSchema,
Expand Down Expand Up @@ -130,6 +131,24 @@ import {
startPanelCloudflareTunnel,
stopPanelCloudflareTunnel
} from "./services/panel-cloudflare-tunnel.js"
import {
createShareLink,
deleteShareLink,
listShareLinks,
resolveShareLink
} from "./services/project-share-links.js"
import {
getSshShareLinkTunnelHostname,
startSshShareLinkTunnel,
stopSshShareLinkTunnel
} from "./services/ssh-share-link-tunnels.js"
import { startSshProjectTunnel } from "./services/ssh-project-tunnels.js"
import {
disableContainerPasswordAuth,
enableContainerPasswordAuth,
generateSshPassword
} from "./services/ssh-password-setup.js"
import { buildShareLinkSshAccess } from "@effect-template/lib/usecases/ssh-access"
import {
deleteProjectDatabaseForward,
deleteProjectDatabaseProfile,
Expand Down Expand Up @@ -556,6 +575,13 @@ const skillScopeFromBody = (scope: string): ProjectSkillScope | null =>
const readProjectPortForwardRequest = () => HttpServerRequest.schemaBodyJson(ProjectPortForwardRequestSchema)
const readStartPanelCloudflareTunnelRequest = () =>
HttpServerRequest.schemaBodyJson(StartPanelCloudflareTunnelRequestSchema)
const readCreateShareLinkRequest = () =>
HttpServerRequest.schemaBodyJson(CreateShareLinkRequestSchema)

const ShareLinkTokenParamsSchema = Schema.Struct({ token: Schema.String })
const ShareLinkByProjectKeyParamsSchema = Schema.Struct({ projectKey: Schema.String, token: Schema.String })
const shareLinkTokenParams = HttpRouter.schemaParams(ShareLinkTokenParamsSchema)
const shareLinkByProjectKeyParams = HttpRouter.schemaParams(ShareLinkByProjectKeyParamsSchema)
const readProjectDatabaseProfileRequest = () => HttpServerRequest.schemaBodyJson(ProjectDatabaseProfileRequestSchema)
const readStateInitRequest = () => HttpServerRequest.schemaBodyJson(StateInitRequestSchema)
const readStateCommitRequest = () => HttpServerRequest.schemaBodyJson(StateCommitRequestSchema)
Expand Down Expand Up @@ -1104,6 +1130,144 @@ export const makeRouter = () => {
Effect.flatMap((tunnel) => jsonResponse({ tunnel }, 200)),
Effect.catchAll(errorResponse)
)
),
HttpRouter.get(
"/share-links/:token",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const { token } = yield* _(shareLinkTokenParams)
const projectsRoot = defaultProjectsRoot(process.cwd())
const link = yield* _(resolveShareLink(projectsRoot, token))
if (link === null) {
return yield* _(Effect.fail(new ApiNotFoundError({ message: `Share link not found or expired: ${token}` })))
}
const project = yield* _(getProjectItemByKey(link.projectKey))
const clientHost = new URL(request.url, "http://localhost").searchParams.get("host")
?? resolvePortPublicHost(request)
?? "localhost"
const sshCfHostname = getSshShareLinkTunnelHostname(link.token)
const sshAccess = buildShareLinkSshAccess({
containerName: project.containerName,
sshUser: project.sshUser,
sshPort: project.sshPort,
sshKeyPath: null,
targetDir: project.targetDir,
clientHost,
sshCfHostname
})
const shareLinkInfo = {
token: link.token,
projectKey: link.projectKey,
projectDir: link.projectDir,
displayName: project.displayName,
sshAlias: sshAccess.alias,
sshConfigSnippet: sshAccess.configSnippet,
cfSshConfigSnippet: sshAccess.cfConfigSnippet,
vscodeUri: sshAccess.vscodeUri,
cfVscodeUri: sshAccess.cfVscodeUri,
workspacePath: sshAccess.workspacePath,
sshPassword: link.sshPassword ?? null,
createdAt: link.createdAt,
expiresAt: link.expiresAt
}
return yield* _(jsonResponse({ link: shareLinkInfo }, 200))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.post(
"/projects/by-key/:projectKey/share-links",
Effect.gen(function*(_) {
const request = yield* _(HttpServerRequest.HttpServerRequest)
const { projectKey } = yield* _(projectKeyParams)
const body = yield* _(readCreateShareLinkRequest())
const project = yield* _(getProjectItemByKey(projectKey))
const projectsRoot = defaultProjectsRoot(process.cwd())
const sshPassword = generateSshPassword()
yield* _(
enableContainerPasswordAuth(project.containerName, sshPassword).pipe(
Effect.orElse(() => Effect.void)
)
)
const link = yield* _(createShareLink(projectsRoot, project.projectDir, projectKey, sshPassword, body.ttlMs))
const clientHost = resolvePortPublicHost(request) ?? "localhost"
const sshCfHostname = yield* _(
startSshShareLinkTunnel(link.token, project.sshPort).pipe(
Effect.orElse(() => Effect.succeed(null))
)
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const sshAccess = buildShareLinkSshAccess({
containerName: project.containerName,
sshUser: project.sshUser,
sshPort: project.sshPort,
sshKeyPath: null,
targetDir: project.targetDir,
clientHost,
sshCfHostname
})
const shareLinkInfo = {
token: link.token,
projectKey: link.projectKey,
projectDir: link.projectDir,
displayName: project.displayName,
sshAlias: sshAccess.alias,
sshConfigSnippet: sshAccess.configSnippet,
cfSshConfigSnippet: sshAccess.cfConfigSnippet,
vscodeUri: sshAccess.vscodeUri,
cfVscodeUri: sshAccess.cfVscodeUri,
workspacePath: sshAccess.workspacePath,
sshPassword,
createdAt: link.createdAt,
expiresAt: link.expiresAt
}
const url = `${resolveRequestOrigin(request)}/ssh/${encodeURIComponent(projectKey)}?t=${link.token}`
return yield* _(jsonResponse({ ok: true, link: shareLinkInfo, url }, 201))
}).pipe(Effect.catchAll(errorResponse))
),
HttpRouter.get(
"/projects/by-key/:projectKey/share-links",
projectKeyParams.pipe(
Effect.flatMap(({ projectKey }) =>
Effect.gen(function*(_) {
const project = yield* _(getProjectItemByKey(projectKey))
const projectsRoot = defaultProjectsRoot(process.cwd())
const links = yield* _(listShareLinks(projectsRoot, project.projectDir))
return { links }
})
),
Effect.flatMap((payload) => jsonResponse(payload, 200)),
Effect.catchAll(errorResponse)
)
),
HttpRouter.del(
"/projects/by-key/:projectKey/share-links/:token",
shareLinkByProjectKeyParams.pipe(
Effect.flatMap(({ projectKey, token }) =>
Effect.gen(function*(_) {
const project = yield* _(getProjectItemByKey(projectKey))
const projectsRoot = defaultProjectsRoot(process.cwd())
yield* _(deleteShareLink(projectsRoot, project.projectDir, token))
yield* _(stopSshShareLinkTunnel(token))
const remaining = yield* _(listShareLinks(projectsRoot, project.projectDir))
if (remaining.length === 0) {
yield* _(disableContainerPasswordAuth(project.containerName))
}
})
),
Effect.flatMap(() => jsonResponse({ ok: true }, 200)),
Effect.catchAll(errorResponse)
)
),
HttpRouter.post(
"/projects/by-key/:projectKey/ssh-tunnel",
Effect.gen(function*(_) {
const { projectKey } = yield* _(projectKeyParams)
const project = yield* _(getProjectItemByKey(projectKey))
const result = yield* _(
startSshProjectTunnel(projectKey, project.sshPort, project.containerName).pipe(
Effect.orElse(() => Effect.succeed({ hostname: null, sshPassword: "" }))
)
)
return yield* _(jsonResponse(result, 200))
}).pipe(Effect.catchAll(errorResponse))
)
)

Expand Down
Loading
Loading