From 421ddaa4a355a4b93a8534c0924e961ede25593c Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:15:17 +0000 Subject: [PATCH 01/20] feat(app): URL-based container access sharing (issue #428) Implements time-limited share links that give SSH and web terminal access to containers without exposing the full dashboard. **Backend:** - `project-share-links.ts`: global token store at projectsRoot/share-links.json with 7-day TTL; tokens are 16 random hex chars (randomBytes(8).toString("hex")) - `buildShareLinkSshAccess` in ssh-access.ts: generates SSH config snippet using host-mapped sshPort and clientHost from request ?host= param - 4 new HTTP routes: GET /share-links/:token (public, auth via token), POST|GET|DELETE /projects/by-key/:projectKey/share-links - OpenAPI endpoints registered in SharingGroup **Frontend:** - `resolveWebAppRoute` now detects 16-hex ?t= tokens as ShareLink route (UUID terminal IDs with dashes never match /^[0-9a-f]{16}$/) - `app-share-link.tsx`: standalone page showing SSH config snippets, VS Code Remote-SSH URI buttons, and a web terminal that starts via createProjectTerminalSession - `api-share-links.ts`: typed API client using requestJson + @effect/schema - `panel-share.tsx`: ContainerShareLinksSection renders per-project share link management (generate / revoke) when a project is selected ## Proof of fix - Cause: no mechanism to share container access without full dashboard URL - Solution: 16-hex token in /ssh/:projectKey?t=:token identifies the project and authorizes SSH config + terminal session creation - Verification: TypeScript passes (rtk tsc --noEmit) on all three packages Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/api/contracts.ts | 25 ++ packages/api/src/api/openapi.ts | 14 + packages/api/src/api/schema.ts | 33 ++ packages/api/src/http.ts | 133 ++++++ .../api/src/services/project-share-links.ts | 147 +++++++ packages/app/src/web/api-share-links.ts | 72 ++++ .../app/src/web/app-ready-main-panels.tsx | 1 + packages/app/src/web/app-share-link.tsx | 394 ++++++++++++++++++ .../app/src/web/app-terminal-session-core.ts | 32 +- packages/app/src/web/app.tsx | 6 +- packages/app/src/web/panel-share.tsx | 111 ++++- packages/lib/src/usecases/ssh-access.ts | 68 +++ 12 files changed, 1032 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/services/project-share-links.ts create mode 100644 packages/app/src/web/api-share-links.ts create mode 100644 packages/app/src/web/app-share-link.tsx diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index e6efd92d..decb6b2e 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -809,3 +809,28 @@ 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 createdAt: string + readonly expiresAt: string +} + +export type CreateShareLinkRequest = { + readonly ttlMs?: number | undefined +} + +export type CreateShareLinkResponse = { + readonly ok: true + readonly link: ShareLinkInfo + readonly url: string +} diff --git a/packages/api/src/api/openapi.ts b/packages/api/src/api/openapi.ts index 4c4da351..4944752e 100644 --- a/packages/api/src/api/openapi.ts +++ b/packages/api/src/api/openapi.ts @@ -30,6 +30,10 @@ import { ProjectDatabaseSessionSchema, ProjectPortForwardRequestSchema, ProjectSkillUpdateRequestSchema, + CreateShareLinkRequestSchema, + ShareLinkInfoResponseSchema, + CreateShareLinkResponseSchema, + ShareLinksListResponseSchema, StartPanelCloudflareTunnelRequestSchema, StartProjectTerminalSessionRequestSchema, UpProjectRequestSchema @@ -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( @@ -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") diff --git a/packages/api/src/api/schema.ts b/packages/api/src/api/schema.ts index 6412840b..8fbe1924 100644 --- a/packages/api/src/api/schema.ts +++ b/packages/api/src/api/schema.ts @@ -392,3 +392,36 @@ export type ProjectPortForwardRequestInput = Schema.Schema.Type export type CreateAgentRequestInput = Schema.Schema.Type export type CreateFollowRequestInput = Schema.Schema.Type + +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, + 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) +}) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 94c4752c..1e396e33 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -38,6 +38,7 @@ import { ProjectPortForwardRequestSchema, ProjectPromptUpdateRequestSchema, ProjectSkillUpdateRequestSchema, + CreateShareLinkRequestSchema, StartProjectTerminalSessionRequestSchema, StartPanelCloudflareTunnelRequestSchema, StateCommitRequestSchema, @@ -130,6 +131,13 @@ import { startPanelCloudflareTunnel, stopPanelCloudflareTunnel } from "./services/panel-cloudflare-tunnel.js" +import { + createShareLink, + deleteShareLink, + listShareLinks, + resolveShareLink +} from "./services/project-share-links.js" +import { buildShareLinkSshAccess } from "@effect-template/lib/usecases/ssh-access" import { deleteProjectDatabaseForward, deleteProjectDatabaseProfile, @@ -556,6 +564,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) @@ -1104,6 +1119,124 @@ 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 cfTunnel = yield* _(readPanelCloudflareTunnel()) + const cfPublicHostname = cfTunnel?.publicUrl !== null && cfTunnel?.publicUrl !== undefined + ? (() => { + try { return new URL(cfTunnel.publicUrl).hostname } catch { return null } + })() + : null + const sshAccess = buildShareLinkSshAccess( + project.containerName, + project.sshUser, + project.sshPort, + project.sshKeyPath, + project.targetDir, + clientHost, + cfPublicHostname + ) + 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, + 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 link = yield* _(createShareLink(projectsRoot, project.projectDir, projectKey, body.ttlMs)) + const clientHost = resolvePortPublicHost(request) ?? "localhost" + const cfTunnel = yield* _(readPanelCloudflareTunnel()) + const cfPublicHostname = cfTunnel?.publicUrl !== null && cfTunnel?.publicUrl !== undefined + ? (() => { + try { return new URL(cfTunnel.publicUrl).hostname } catch { return null } + })() + : null + const sshAccess = buildShareLinkSshAccess( + project.containerName, + project.sshUser, + project.sshPort, + project.sshKeyPath, + project.targetDir, + clientHost, + cfPublicHostname + ) + 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, + 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)) + }) + ), + Effect.flatMap(() => jsonResponse({ ok: true }, 200)), + Effect.catchAll(errorResponse) + ) ) ) diff --git a/packages/api/src/services/project-share-links.ts b/packages/api/src/services/project-share-links.ts new file mode 100644 index 00000000..4837c2d6 --- /dev/null +++ b/packages/api/src/services/project-share-links.ts @@ -0,0 +1,147 @@ +// CHANGE: add URL-based container access sharing with time-limited tokens +// WHY: enables sharing container access via URL without exposing full panel +// QUOTE(ТЗ): "я даю эту же ссылку условно в VS Code и он подключается к текущему контейнеру" +// REF: issue-428 +// FORMAT THEOREM: ∀ token ∈ ShareLinks: valid(token) → project(token) ∧ notExpired(token) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: tokens are cryptographically random 16 hex chars (8 bytes = 2^64 space) +// COMPLEXITY: O(1) lookup via in-memory index, O(n) scan on cold start + +import * as FileSystem from "@effect/platform/FileSystem" +import { randomBytes } from "node:crypto" +import * as nodePath from "node:path" +import { Effect, Either } from "effect" +import * as ParseResult from "effect/ParseResult" +import * as Schema from "effect/Schema" + +import { ApiInternalError, ApiNotFoundError } from "../api/errors.js" + +export type ShareLink = { + readonly token: string + readonly projectKey: string + readonly projectDir: string + readonly createdAt: string + readonly expiresAt: string +} + +type ShareLinksFile = { + readonly schemaVersion: 1 + readonly links: ReadonlyArray +} + +const ShareLinkSchema = Schema.Struct({ + token: Schema.String, + projectKey: Schema.String, + projectDir: Schema.String, + createdAt: Schema.String, + expiresAt: Schema.String +}) + +const ShareLinksFileSchema = Schema.Struct({ + schemaVersion: Schema.Literal(1), + links: Schema.Array(ShareLinkSchema) +}) + +const ShareLinksFileJsonSchema = Schema.parseJson(ShareLinksFileSchema) + +const defaultShareLinksFile = (): ShareLinksFile => ({ schemaVersion: 1, links: [] }) + +const decodeShareLinksFile = (input: string): ShareLinksFile | null => + Either.match(ParseResult.decodeUnknownEither(ShareLinksFileJsonSchema)(input), { + onLeft: () => null, + onRight: (value) => value + }) + +// Global file at projectsRoot/share-links.json holds all tokens across projects. +// This avoids scanning project directories on every token lookup. +const resolveGlobalStatePath = (projectsRoot: string): string => + nodePath.join(nodePath.resolve(projectsRoot), "share-links.json") + +const defaultTtlMs = 7 * 24 * 60 * 60 * 1000 + +const readShareLinksFile = ( + projectsRoot: string +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const statePath = resolveGlobalStatePath(projectsRoot) + const exists = yield* _(Effect.either(fs.exists(statePath))) + const fileExists = Either.match(exists, { onLeft: () => false, onRight: (v) => v }) + if (!fileExists) { + return defaultShareLinksFile() + } + const contents = yield* _(Effect.either(fs.readFileString(statePath))) + return Either.match(contents, { + onLeft: () => defaultShareLinksFile(), + onRight: (raw) => decodeShareLinksFile(raw) ?? defaultShareLinksFile() + }) + }).pipe(Effect.catchAll(() => Effect.succeed(defaultShareLinksFile()))) + +const writeShareLinksFile = ( + projectsRoot: string, + file: ShareLinksFile +): Effect.Effect => + Effect.gen(function*(_) { + const fs = yield* _(FileSystem.FileSystem) + const statePath = resolveGlobalStatePath(projectsRoot) + yield* _( + fs.writeFileString(statePath, JSON.stringify(file, null, 2)).pipe( + Effect.catchAll((err) => + Effect.fail(new ApiInternalError({ message: `Failed to write share-links: ${String(err)}` })) + ) + ) + ) + }) + +const isExpired = (link: ShareLink): boolean => new Date(link.expiresAt).getTime() <= Date.now() + +export const createShareLink = ( + projectsRoot: string, + projectDir: string, + projectKey: string, + ttlMs?: number +): Effect.Effect => + Effect.gen(function*(_) { + const token = randomBytes(8).toString("hex") + const now = new Date().toISOString() + const expiresAt = new Date(Date.now() + (ttlMs ?? defaultTtlMs)).toISOString() + const link: ShareLink = { token, projectKey, projectDir, createdAt: now, expiresAt } + const file = yield* _(readShareLinksFile(projectsRoot)) + const activeLinks = file.links.filter((l) => !isExpired(l)) + yield* _(writeShareLinksFile(projectsRoot, { schemaVersion: 1, links: [...activeLinks, link] })) + return link + }) + +export const resolveShareLink = ( + projectsRoot: string, + token: string +): Effect.Effect => + readShareLinksFile(projectsRoot).pipe( + Effect.map((file) => file.links.find((l) => l.token === token && !isExpired(l)) ?? null), + Effect.catchAll(() => Effect.succeed(null)) + ) + +export const deleteShareLink = ( + projectsRoot: string, + projectDir: string, + token: string +): Effect.Effect => + Effect.gen(function*(_) { + const file = yield* _(readShareLinksFile(projectsRoot)) + const before = file.links.length + const updated = file.links.filter((l) => !(l.token === token && l.projectDir === projectDir)) + if (updated.length === before) { + return yield* _(Effect.fail(new ApiNotFoundError({ message: `Share link not found: ${token}` }))) + } + yield* _(writeShareLinksFile(projectsRoot, { schemaVersion: 1, links: updated })) + }) + +export const listShareLinks = ( + projectsRoot: string, + projectDir: string +): Effect.Effect, never, FileSystem.FileSystem> => + readShareLinksFile(projectsRoot).pipe( + Effect.map((file) => file.links.filter((l) => l.projectDir === projectDir && !isExpired(l))), + Effect.catchAll(() => Effect.succeed([] as ReadonlyArray)) + ) diff --git a/packages/app/src/web/api-share-links.ts b/packages/app/src/web/api-share-links.ts new file mode 100644 index 00000000..6f8fd681 --- /dev/null +++ b/packages/app/src/web/api-share-links.ts @@ -0,0 +1,72 @@ +import { Effect } from "effect" +import * as Schema from "@effect/schema/Schema" + +import { requestJson } from "./api-http.js" + +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, + createdAt: Schema.String, + expiresAt: Schema.String +}) + +export type ShareLinkInfo = Schema.Schema.Type + +const ShareLinkResponseSchema = Schema.Struct({ link: ShareLinkInfoSchema }) +const CreateShareLinkResponseSchema = Schema.Struct({ + ok: Schema.Literal(true), + link: ShareLinkInfoSchema, + url: Schema.String +}) +const ShareLinksListResponseSchema = Schema.Struct({ + links: Schema.Array(ShareLinkInfoSchema) +}) +const OkResponseSchema = Schema.Struct({ ok: Schema.Literal(true) }) + +export const loadShareLink = ( + token: string, + clientHost: string +): Effect.Effect => + requestJson( + "GET", + `/share-links/${encodeURIComponent(token)}?host=${encodeURIComponent(clientHost)}`, + ShareLinkResponseSchema + ).pipe(Effect.map((r) => r.link)) + +export const createProjectShareLink = ( + projectKey: string, + ttlMs?: number +): Effect.Effect<{ readonly link: ShareLinkInfo; readonly url: string }, string> => + requestJson( + "POST", + `/projects/by-key/${encodeURIComponent(projectKey)}/share-links`, + CreateShareLinkResponseSchema, + ttlMs !== undefined ? { ttlMs } : {} + ).pipe(Effect.map(({ link, url }) => ({ link, url }))) + +export const listProjectShareLinks = ( + projectKey: string +): Effect.Effect, string> => + requestJson( + "GET", + `/projects/by-key/${encodeURIComponent(projectKey)}/share-links`, + ShareLinksListResponseSchema + ).pipe(Effect.map((r) => r.links)) + +export const deleteProjectShareLink = ( + projectKey: string, + token: string +): Effect.Effect => + requestJson( + "DELETE", + `/projects/by-key/${encodeURIComponent(projectKey)}/share-links/${encodeURIComponent(token)}`, + OkResponseSchema + ).pipe(Effect.asVoid) diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index d362fc63..bbdf4583 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -61,6 +61,7 @@ const ShareScreen = (props: MainPanelsProps): JSX.Element => ( onRefresh={props.onRefreshPanelShareTunnel} onStart={props.onStartPanelShareTunnel} onStop={props.onStopPanelShareTunnel} + selectedProjectKey={props.selectedProjectSummary?.projectKey ?? null} tunnel={props.panelCloudflareTunnel} /> diff --git a/packages/app/src/web/app-share-link.tsx b/packages/app/src/web/app-share-link.tsx new file mode 100644 index 00000000..6742a3b2 --- /dev/null +++ b/packages/app/src/web/app-share-link.tsx @@ -0,0 +1,394 @@ +import { Effect, Match } from "effect" +import { type CSSProperties, type Dispatch, type JSX, type SetStateAction, useEffect, useState } from "react" + +import { createProjectTerminalSession, deleteTerminalSessionByPath } from "./api.js" +import type { ShareLinkInfo } from "./api-share-links.js" +import { loadShareLink } from "./api-share-links.js" +import { TerminalPanel } from "./panel-terminal.js" +import { buildProjectActiveTerminalSession, type ActiveTerminalSession } from "./terminal.js" +import type { ViewportLayout } from "./viewport-layout.js" + +// CHANGE: standalone share link page – validates token, shows SSH config and web terminal +// WHY: share links must work without dashboard state; token provides the authorization +// QUOTE(ТЗ): "принимает ссылку на любой IP который стоит у пользователя в URL" +// REF: issue-428 +// PURITY: SHELL (React component with effects) +// INVARIANT: token is only accepted when it matches the 16-hex share format + +type ShareLinkState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Error"; readonly message: string } + | { readonly _tag: "Info"; readonly info: ShareLinkInfo } + | { readonly _tag: "Connecting"; readonly info: ShareLinkInfo } + | { readonly _tag: "Terminal"; readonly info: ShareLinkInfo; readonly session: ActiveTerminalSession; readonly message: string | null } + | { readonly _tag: "Closed"; readonly info: ShareLinkInfo; readonly closedMessage: string } + +export type AppShareLinkProps = { + readonly projectKey: string + readonly shareToken: string + readonly viewport: ViewportLayout +} + +const containerStyle: CSSProperties = { + display: "flex", + flexDirection: "column", + height: "100%", + overflow: "hidden", + padding: "8px" +} + +const headerStyle: CSSProperties = { + background: "#101419", + border: "1px solid #3a4652", + borderRadius: "4px", + flexShrink: 0, + marginBottom: "8px", + overflowY: "auto", + padding: "8px" +} + +const terminalAreaStyle: CSSProperties = { + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, + overflow: "hidden" +} + +const buttonStyle: CSSProperties = { + background: "transparent", + border: "none", + color: "#56f39a", + cursor: "pointer", + font: "inherit", + fontWeight: "bold", + padding: "2px 6px" +} + +const codeBlockStyle: CSSProperties = { + background: "#0b1017", + border: "1px solid #2a3640", + borderRadius: "2px", + color: "#a8c8f0", + display: "block", + fontFamily: "inherit", + fontSize: "0.85em", + marginBottom: "4px", + marginTop: "4px", + overflowX: "auto", + padding: "6px 8px", + whiteSpace: "pre" +} + +const copyText = (text: string): void => { + void navigator.clipboard.writeText(text).catch(() => {}) +} + +const openUri = (uri: string): void => { + window.location.href = uri +} + +const SshConfigBlock = ( + { label, snippet }: { readonly label: string; readonly snippet: string } +): JSX.Element => ( +
+
{label}
+ {snippet} + +
+) + +const InfoHeader = ( + { + info, + isConnecting, + onConnect + }: { + readonly info: ShareLinkInfo + readonly isConnecting: boolean + readonly onConnect: () => void + } +): JSX.Element => ( +
+
+
{info.displayName}
+
+ + {info.cfVscodeUri !== null && ( + + )} + +
+
+ + {info.cfSshConfigSnippet !== null && ( + + )} +
+ expires {new Date(info.expiresAt).toLocaleString()} +
+
+) + +const TerminalView = ( + { + info, + message, + session, + setState, + viewport + }: { + readonly info: ShareLinkInfo + readonly message: string | null + readonly session: ActiveTerminalSession + readonly setState: Dispatch> + readonly viewport: ViewportLayout + } +): JSX.Element => ( +
+ {message !== null && ( +
+ {message} +
+ )} + { + setState((current) => + current._tag === "Terminal" + ? { _tag: "Closed", closedMessage: "Terminal attach failed.", info: current.info } + : current + ) + }} + onDetach={() => { + setState((current) => + current._tag === "Terminal" + ? { _tag: "Info", info: current.info } + : current + ) + }} + onKill={() => { + void Effect.runPromise( + deleteTerminalSessionByPath(session.closePath).pipe(Effect.either, Effect.asVoid) + ) + setState((current) => + current._tag === "Terminal" + ? { _tag: "Info", info: current.info } + : current + ) + }} + onMessage={(msg) => { + setState((current) => + current._tag === "Terminal" ? { ...current, message: msg } : current + ) + }} + session={session} + /> +
+) + +const PlaceholderArea = ({ children }: { readonly children: JSX.Element }): JSX.Element => ( +
+ {children} +
+) + +const centeredBoxStyle: CSSProperties = { + border: "1px solid #3a4652", + borderRadius: "4px", + color: "#d6e5f7", + padding: "16px 24px", + textAlign: "center" +} + +const connectTerminalSession = ( + projectKey: string, + info: ShareLinkInfo, + setState: Dispatch> +): void => { + void Effect.runPromise( + createProjectTerminalSession(projectKey).pipe( + Effect.map(({ session }) => { + const activeSession = buildProjectActiveTerminalSession({ + onExit: () => { + setState((current) => + current._tag === "Terminal" + ? { _tag: "Info", info: current.info } + : current + ) + }, + projectDisplayName: info.displayName, + projectId: session.projectId, + projectKey, + session + }) + setState((current) => + current._tag === "Connecting" + ? { _tag: "Terminal", info: current.info, message: null, session: activeSession } + : current + ) + }), + Effect.catchAll((error) => + Effect.sync(() => { + setState((current) => + current._tag === "Connecting" + ? { _tag: "Closed", closedMessage: String(error), info: current.info } + : current + ) + }) + ) + ) + ) +} + +const renderState = ( + state: ShareLinkState, + setState: Dispatch>, + projectKey: string, + viewport: ViewportLayout +): JSX.Element => + Match.value(state).pipe( + Match.when({ _tag: "Loading" }, () => ( + +
+
Share link
+
Validating token…
+
+
+ )), + Match.when({ _tag: "Error" }, ({ message }) => ( + +
+
Share link unavailable
+
{message}
+
+
+ )), + Match.when({ _tag: "Info" }, ({ info }) => ( +
+ { + setState({ _tag: "Connecting", info }) + connectTerminalSession(projectKey, info, setState) + }} + /> + +
+
Add the SSH config above to ~/.ssh/config
+
+ then click open in VS Code to connect +
+
+
+
+ )), + Match.when({ _tag: "Connecting" }, ({ info }) => ( +
+ {}} + /> + +
+
Starting SSH terminal session…
+
+
+
+ )), + Match.when({ _tag: "Terminal" }, ({ info, message, session }) => ( +
+ {}} + /> + +
+ )), + Match.when({ _tag: "Closed" }, ({ closedMessage, info }) => ( +
+ { + setState({ _tag: "Connecting", info }) + connectTerminalSession(projectKey, info, setState) + }} + /> + +
+
Session ended
+
{closedMessage}
+
+
+
+ )), + Match.exhaustive + ) + +export const AppShareLink = ( + { projectKey, shareToken, viewport }: AppShareLinkProps +): JSX.Element => { + const [state, setState] = useState({ _tag: "Loading" }) + + useEffect(() => { + let cancelled = false + const clientHost = `${window.location.hostname}${window.location.port !== "" ? `:${window.location.port}` : ""}` + void Effect.runPromise( + loadShareLink(shareToken, clientHost).pipe( + Effect.match({ + onFailure: (message) => { + if (!cancelled) { + setState({ _tag: "Error", message }) + } + }, + onSuccess: (info) => { + if (!cancelled) { + setState({ _tag: "Info", info }) + } + } + }) + ) + ) + return () => { + cancelled = true + } + }, [shareToken]) + + return renderState(state, setState, projectKey, viewport) +} diff --git a/packages/app/src/web/app-terminal-session-core.ts b/packages/app/src/web/app-terminal-session-core.ts index 2b3c8060..265a91a2 100644 --- a/packages/app/src/web/app-terminal-session-core.ts +++ b/packages/app/src/web/app-terminal-session-core.ts @@ -1,7 +1,9 @@ import type { ProjectTerminalSessionLookup } from "./api.js" import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" -export type WebAppRoute = { readonly tag: "Dashboard" } +export type WebAppRoute = + | { readonly tag: "Dashboard" } + | { readonly tag: "ShareLink"; readonly projectKey: string; readonly shareToken: string } const terminalSessionRoutePrefix = "/ssh/session/" @@ -15,7 +17,33 @@ export const readTerminalSessionRoute = (pathname: string): string | null => { return sessionId.length === 0 ? null : sessionId } -export const resolveWebAppRoute = (_pathname: string): WebAppRoute => { +// CHANGE: detect 16-hex share tokens in /ssh/:projectKey?t=:token URLs +// WHY: share tokens (16 hex chars) are distinct from terminal UUIDs (dashed UUID format) +// QUOTE(ТЗ): "принимает ссылку на любой IP который стоит у пользователя в URL" +// REF: issue-428 +// FORMAT THEOREM: ∀t: isShareToken(t) ↔ t ∈ [0-9a-f]{16} +// PURITY: CORE +// INVARIANT: UUID terminal IDs always have dashes — never match the hex-only pattern +// COMPLEXITY: O(1) +const isShareToken = (value: string): boolean => /^[0-9a-f]{16}$/u.test(value) + +const safeDecodeSegment = (value: string): string | null => { + try { + return decodeURIComponent(value) + } catch { + return null + } +} + +export const resolveWebAppRoute = (pathname: string, search: string = ""): WebAppRoute => { + if (pathname.startsWith("/ssh/")) { + const rawKey = pathname.slice("/ssh/".length).split("/")[0] ?? "" + const projectKey = safeDecodeSegment(rawKey)?.trim() ?? "" + const t = new URLSearchParams(search).get("t") ?? "" + if (projectKey.length > 0 && isShareToken(t)) { + return { tag: "ShareLink", projectKey, shareToken: t } + } + } return { tag: "Dashboard" } } diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index 17d4df06..fbb8aa9d 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -15,6 +15,7 @@ import { UiProvider } from "../ui/primitives.js" import { loadDashboard, resolveApiBaseUrl } from "./api.js" import { createDashboardRefreshReducer, type DashboardState } from "./app-dashboard-state.js" import { AppReady } from "./app-ready.js" +import { AppShareLink } from "./app-share-link.js" import { resolveWebAppRoute } from "./app-terminal-session-core.js" import { ErrorScreen, LoadingScreen } from "./panels.js" import { resolveViewportLayout, type ViewportLayout, type ViewportSize } from "./viewport-layout.js" @@ -221,12 +222,15 @@ const AppDashboard = ({ viewport }: { readonly viewport: ViewportLayout }): JSX. export const App = (): JSX.Element => { const viewport = useViewportMode() - const [route] = useState(() => resolveWebAppRoute(location.pathname)) + const [route] = useState(() => resolveWebAppRoute(location.pathname, location.search)) return ( {Match.value(route).pipe( Match.when({ tag: "Dashboard" }, () => ), + Match.when({ tag: "ShareLink" }, ({ projectKey, shareToken }) => ( + + )), Match.exhaustive )} diff --git a/packages/app/src/web/panel-share.tsx b/packages/app/src/web/panel-share.tsx index 8c2bc832..1f714e47 100644 --- a/packages/app/src/web/panel-share.tsx +++ b/packages/app/src/web/panel-share.tsx @@ -1,13 +1,17 @@ -import type { JSX } from "react" +import { Effect } from "effect" +import { type JSX, useEffect, useState } from "react" import { Box, Text } from "../ui/primitives.js" import type { PanelCloudflareTunnelSession } from "./api.js" +import type { ShareLinkInfo } from "./api-share-links.js" +import { createProjectShareLink, deleteProjectShareLink, listProjectShareLinks } from "./api-share-links.js" type SharePanelProps = { readonly onCopyPublicUrl: (publicUrl: string) => void readonly onRefresh: () => void readonly onStart: () => void readonly onStop: () => void + readonly selectedProjectKey: string | null readonly tunnel: PanelCloudflareTunnelSession | null } @@ -128,12 +132,116 @@ const MaybeTunnelError = ( const tunnelLogTail = (tunnel: PanelCloudflareTunnelSession | null): ReadonlyArray => tunnel === null ? [] : tunnel.logTail +type ShareLinksState = + | { readonly _tag: "Idle" } + | { readonly _tag: "Loading" } + | { readonly _tag: "Loaded"; readonly links: ReadonlyArray; readonly newUrl: string | null } + | { readonly _tag: "Error"; readonly message: string } + +const ContainerShareLinksSection = ( + { projectKey }: { readonly projectKey: string } +): JSX.Element => { + const [state, setState] = useState({ _tag: "Idle" }) + + const refresh = () => { + setState({ _tag: "Loading" }) + void Effect.runPromise( + listProjectShareLinks(projectKey).pipe( + Effect.match({ + onFailure: (msg) => { setState({ _tag: "Error", message: msg }) }, + onSuccess: (links) => { + setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) + } + }) + ) + ) + } + + const generate = () => { + void Effect.runPromise( + createProjectShareLink(projectKey).pipe( + Effect.flatMap(({ url }) => + listProjectShareLinks(projectKey).pipe( + Effect.map((links) => { setState({ _tag: "Loaded", links, newUrl: url }) }) + ) + ), + Effect.catchAll((msg) => + Effect.sync(() => { setState({ _tag: "Error", message: String(msg) }) }) + ) + ) + ) + } + + const revoke = (token: string) => { + void Effect.runPromise( + deleteProjectShareLink(projectKey, token).pipe( + Effect.flatMap(() => listProjectShareLinks(projectKey)), + Effect.match({ + onFailure: (msg) => { setState({ _tag: "Error", message: String(msg) }) }, + onSuccess: (links) => { + setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) + } + }) + ) + ) + } + + useEffect(() => { + refresh() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [projectKey]) + + return ( + + + Container share links + + + + + + + Share links give time-limited SSH and terminal access to this container. + + {state._tag === "Loading" && Loading…} + {state._tag === "Error" && {state.message}} + {state._tag === "Loaded" && state.newUrl !== null && ( + + New share link + {state.newUrl} + { + const url = state._tag === "Loaded" ? state.newUrl : null + if (url !== null) { + void navigator.clipboard.writeText(url).catch(() => {}) + } + }} + /> + + )} + {state._tag === "Loaded" && state.links.length === 0 && ( + No active share links. + )} + {state._tag === "Loaded" && state.links.map((link) => ( + + {link.token} + exp {new Date(link.expiresAt).toLocaleDateString()} + { revoke(link.token) }} /> + + ))} + + ) +} + export const SharePanel = ( { onCopyPublicUrl, onRefresh, onStart, onStop, + selectedProjectKey, tunnel }: SharePanelProps ): JSX.Element => { @@ -158,6 +266,7 @@ export const SharePanel = ( + {selectedProjectKey !== null && } ) } diff --git a/packages/lib/src/usecases/ssh-access.ts b/packages/lib/src/usecases/ssh-access.ts index 23fa225a..28d3bdb9 100644 --- a/packages/lib/src/usecases/ssh-access.ts +++ b/packages/lib/src/usecases/ssh-access.ts @@ -183,6 +183,74 @@ Add to ~/.ssh/config: ${access.configSnippet}${firstHopNote}` } +// CHANGE: build SSH config for share links using external client host and mapped SSH port +// WHY: share link SSH config must route through the host's external IP + mapped port, not container IP +// QUOTE(ТЗ): "принимает ссылку на любой IP который стоит у пользователя в URL" +// REF: issue-428 +// FORMAT THEOREM: ∀ item, host: buildShareLinkSshAccess(item, host) → configSnippet uses sshPort (not 22) +// PURITY: CORE +// INVARIANT: port is always item.sshPort (host-mapped), never 22 (container-direct) +// COMPLEXITY: O(n)/O(n) where n = |containerName| +export type ShareLinkSshAccess = { + readonly alias: string + readonly configSnippet: string + readonly cfConfigSnippet: string | null + readonly workspacePath: string + readonly vscodeUri: string + readonly cfVscodeUri: string | null +} + +export const buildShareLinkSshAccess = ( + containerName: string, + sshUser: string, + sshPort: number, + sshKeyPath: string | null, + targetDir: string, + clientHost: string, + cfPublicHostname: string | null +): ShareLinkSshAccess => { + const alias = sanitizeSshHostAlias(containerName) + const directLines = [ + `Host ${alias}`, + ` HostName ${clientHost}`, + ` User ${sshUser}`, + ` Port ${sshPort}`, + ` LogLevel ERROR`, + ` StrictHostKeyChecking no`, + ` UserKnownHostsFile /dev/null` + ] + if (sshKeyPath !== null) { + directLines.push(` IdentityFile ${sshKeyPath}`, ` IdentitiesOnly yes`) + } + + const cfAlias = `${alias}-cf` + const cfLines = cfPublicHostname === null + ? null + : [ + `Host ${cfAlias}`, + ` HostName ${cfPublicHostname}`, + ` User ${sshUser}`, + ` Port 22`, + ` ProxyCommand cloudflared access ssh --hostname %h`, + ` LogLevel ERROR`, + ` StrictHostKeyChecking no`, + ` UserKnownHostsFile /dev/null`, + ...(sshKeyPath !== null ? [` IdentityFile ${sshKeyPath}`, ` IdentitiesOnly yes`] : []) + ] + + const encodedFolder = encodeURIComponent(targetDir) + return { + alias, + configSnippet: directLines.join("\n"), + cfConfigSnippet: cfLines === null ? null : cfLines.join("\n"), + workspacePath: targetDir, + vscodeUri: `vscode://ms-vscode-remote.remote-ssh/open?ssh=${encodeURIComponent(alias)}&folder=${encodedFolder}`, + cfVscodeUri: cfLines === null + ? null + : `vscode://ms-vscode-remote.remote-ssh/open?ssh=${encodeURIComponent(cfAlias)}&folder=${encodedFolder}` + } +} + // 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" From 85508586dbe77d47fbfc0953fd3a8da941eb20de Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Fri, 19 Jun 2026 22:20:57 +0000 Subject: [PATCH 02/20] =?UTF-8?q?fix(app):=20VS=20Code=20open=20in=20share?= =?UTF-8?q?=20link=20=E2=80=94=20direct=20hostName=20URI=20+=20anchor=20ta?= =?UTF-8?q?g?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace openUri(window.location.href) with so the browser reliably triggers the vscode:// protocol handler without navigating away from the page. Replace ssh alias-based URI (?ssh=ALIAS) with direct connection format (?hostName=user@host:port) so VS Code Remote SSH connects immediately without requiring the user to add an entry to ~/.ssh/config first. VS Code docs: "You can enter a user@host or user@host:port connection string if you don't want to use an SSH config file entry." Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-share-link.tsx | 26 ++++++++++++------------- packages/lib/src/usecases/ssh-access.ts | 14 ++++++++++--- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/app/src/web/app-share-link.tsx b/packages/app/src/web/app-share-link.tsx index 6742a3b2..2f99c84c 100644 --- a/packages/app/src/web/app-share-link.tsx +++ b/packages/app/src/web/app-share-link.tsx @@ -84,8 +84,14 @@ const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } -const openUri = (uri: string): void => { - window.location.href = uri +const vscodeLinkStyle: CSSProperties = { + color: "#56f39a", + cursor: "pointer", + fontFamily: "inherit", + fontSize: "inherit", + fontWeight: "bold", + padding: "2px 6px", + textDecoration: "none" } const SshConfigBlock = ( @@ -119,21 +125,13 @@ const InfoHeader = (
{info.displayName}
- + {info.cfVscodeUri !== null && ( - + )}
) +const SshPasswordBlock = ( + { info }: { readonly info: ShareLinkInfo } +): JSX.Element | null => { + if (info.sshPassword === null) return null + const sshHostname = info.sshConfigSnippet.match(/HostName\s+(\S+)/)?.[1] ?? "host" + const sshPort = info.sshConfigSnippet.match(/Port\s+(\d+)/)?.[1] ?? "22" + const sshUser = info.sshConfigSnippet.match(/User\s+(\S+)/)?.[1] ?? "dev" + const directCmd = `ssh ${sshUser}@${sshHostname} -p ${sshPort}` + const cfCmd = info.cfSshConfigSnippet !== null + ? `ssh -o ProxyCommand="cloudflared access ssh --hostname %h" ${sshUser}@${info.sshConfigSnippet.match(/HostName\s+(\S+)/)?.[1] ?? "host"}` + : null + const cfHostname = info.cfSshConfigSnippet?.match(/HostName\s+(\S+)/)?.[1] + const cfDirectCmd = cfHostname !== null && cfHostname !== undefined + ? `ssh -o ProxyCommand="cloudflared access ssh --hostname %h" ${sshUser}@${cfHostname}` + : null + return ( +
+
SSH password access
+
+ Password: + {info.sshPassword as string} + +
+
Direct SSH command:
+ {directCmd} + + {cfDirectCmd !== null && ( + <> +
Via Cloudflare tunnel:
+ {cfDirectCmd} + + + )} +
+ No SSH key needed — use this password when prompted +
+
+ ) +} + const InfoHeader = ( { info, @@ -147,6 +187,7 @@ const InfoHeader = ( {info.cfSshConfigSnippet !== null && ( )} +
expires {new Date(info.expiresAt).toLocaleString()}
From 26c967d26caf13f4617ed05d81cd489e52bc210f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 08:55:51 +0000 Subject: [PATCH 05/20] =?UTF-8?q?feat(share-link):=20wildcard=20CF=20SSH?= =?UTF-8?q?=20setup=20=E2=80=94=20one=20config=20for=20all=20containers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-host CF SSH config snippet with a wildcard one-time setup block: Host *.trycloudflare.com ProxyCommand cloudflared access ssh --hostname %h Once added to ~/.ssh/config, any share link CF tunnel works automatically — `ssh dev@.trycloudflare.com` and VS Code (CF tunnel) need no per-container config update. Also simplifies the SSH password block: shows password + direct LAN command only (CF command is now implicit via the wildcard). Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-share-link.tsx | 61 +++++++++++++++---------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/packages/app/src/web/app-share-link.tsx b/packages/app/src/web/app-share-link.tsx index 2ff01fbc..e5fc593d 100644 --- a/packages/app/src/web/app-share-link.tsx +++ b/packages/app/src/web/app-share-link.tsx @@ -110,6 +110,33 @@ const SshConfigBlock = (
) +const WILDCARD_SSH_CONFIG = `Host *.trycloudflare.com + ProxyCommand cloudflared access ssh --hostname %h + StrictHostKeyChecking no + UserKnownHostsFile /dev/null` + +const CfTunnelSetupBlock = ( + { cfHostname }: { readonly cfHostname: string | null } +): JSX.Element | null => { + if (cfHostname === null) return null + return ( +
+
+
One-time setup
+
add once to ~/.ssh/config — works for all share links
+
+ {WILDCARD_SSH_CONFIG} + +
After setup, connect to any container:
+ {`ssh dev@${cfHostname}`} + +
+ Requires cloudflared installed on your machine +
+
+ ) +} + const SshPasswordBlock = ( { info }: { readonly info: ShareLinkInfo } ): JSX.Element | null => { @@ -118,34 +145,20 @@ const SshPasswordBlock = ( const sshPort = info.sshConfigSnippet.match(/Port\s+(\d+)/)?.[1] ?? "22" const sshUser = info.sshConfigSnippet.match(/User\s+(\S+)/)?.[1] ?? "dev" const directCmd = `ssh ${sshUser}@${sshHostname} -p ${sshPort}` - const cfCmd = info.cfSshConfigSnippet !== null - ? `ssh -o ProxyCommand="cloudflared access ssh --hostname %h" ${sshUser}@${info.sshConfigSnippet.match(/HostName\s+(\S+)/)?.[1] ?? "host"}` - : null - const cfHostname = info.cfSshConfigSnippet?.match(/HostName\s+(\S+)/)?.[1] - const cfDirectCmd = cfHostname !== null && cfHostname !== undefined - ? `ssh -o ProxyCommand="cloudflared access ssh --hostname %h" ${sshUser}@${cfHostname}` - : null return (
-
SSH password access
+
SSH password
- Password: {info.sshPassword as string}
-
Direct SSH command:
- {directCmd} - - {cfDirectCmd !== null && ( + {sshHostname !== "localhost" && ( <> -
Via Cloudflare tunnel:
- {cfDirectCmd} - +
Direct (LAN):
+ {directCmd} + )} -
- No SSH key needed — use this password when prompted -
) } @@ -183,11 +196,9 @@ const InfoHeader = ( - - {info.cfSshConfigSnippet !== null && ( - - )} + +
expires {new Date(info.expiresAt).toLocaleString()}
@@ -341,9 +352,9 @@ const renderState = ( />
-
Add the SSH config above to ~/.ssh/config
+
Add the one-time setup to ~/.ssh/config
- then click open in VS Code to connect + then click VS Code (CF tunnel) to connect from anywhere
From 49179e68ca3543562c0bb0d0459117cdbed98ed5 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 10:40:25 +0000 Subject: [PATCH 06/20] feat(app): per-container CF SSH tunnel for VS Code panel (#428) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When clicking the VS Code button on a terminal, automatically starts a dedicated Cloudflare quick tunnel for that container's SSH port and shows only the CF SSH command — no localhost config. - New service: ssh-project-tunnels.ts — per-projectKey cloudflared tunnel with idempotent start, keyed separately from share-link tunnels - New route: POST /projects/by-key/:key/ssh-tunnel — starts tunnel, returns { hostname } (blocks up to 15s for hostname resolution) - VsCodeAccessPanel shows: wildcard ~/.ssh/config setup, CF SSH command with copy, and "Open in VS Code (CF tunnel)" link - Loading / ready / failed states with Retry button on failure Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/http.ts | 14 + .../api/src/services/ssh-project-tunnels.ts | 272 ++++++++++++++++++ packages/app/src/web/api-share-links.ts | 11 + .../app/src/web/app-ready-terminal-pane.tsx | 191 +++++++++++- packages/app/src/web/app-share-link.tsx | 3 - .../src/web/panel-terminal-header.tsx | 6 + .../terminal/src/web/panel-terminal-types.ts | 1 + packages/terminal/src/web/panel-terminal.tsx | 2 + 8 files changed, 493 insertions(+), 7 deletions(-) create mode 100644 packages/api/src/services/ssh-project-tunnels.ts diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 03245a59..43fa9426 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -142,6 +142,7 @@ import { startSshShareLinkTunnel, stopSshShareLinkTunnel } from "./services/ssh-share-link-tunnels.js" +import { startSshProjectTunnel } from "./services/ssh-project-tunnels.js" import { disableContainerPasswordAuth, enableContainerPasswordAuth, @@ -1254,6 +1255,19 @@ export const makeRouter = () => { 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 hostname = yield* _( + startSshProjectTunnel(projectKey, project.sshPort).pipe( + Effect.orElse(() => Effect.succeed(null)) + ) + ) + return yield* _(jsonResponse({ hostname }, 200)) + }).pipe(Effect.catchAll(errorResponse)) ) ) diff --git a/packages/api/src/services/ssh-project-tunnels.ts b/packages/api/src/services/ssh-project-tunnels.ts new file mode 100644 index 00000000..8a8ebdf8 --- /dev/null +++ b/packages/api/src/services/ssh-project-tunnels.ts @@ -0,0 +1,272 @@ +// CHANGE: per-project SSH cloudflared tunnels for VS Code Remote SSH access +// WHY: share-link tunnels are tied to tokens and expire; containers need a +// persistent tunnel that lives as long as the container is running +// QUOTE(ТЗ): "запускать cloudflare tunnel под каждый контейнер" +// REF: issue-428 +// FORMAT THEOREM: ∀ projectKey: started(projectKey, port) → ∃ hostname: cfSsh(hostname, port) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: at most one tunnel record per projectKey is active at any time +// COMPLEXITY: O(1) lookup, O(startWaitAttempts * 250ms) start wait + +import { spawn, type ChildProcess } from "node:child_process" +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs" +import { join } from "node:path" +import { randomUUID } from "node:crypto" + +import { Duration, Effect, Fiber } from "effect" + +import { ApiInternalError } from "../api/errors.js" +import { parseTryCloudflareUrl } from "./panel-cloudflare-tunnel-core.js" +import { parseLinuxDefaultGatewayIp } from "./project-port-proxy-core.js" + +type SshTunnelRecord = { + readonly homeDir: string + process: ChildProcess | null + processClosed: boolean + hostname: string | null + stopping: boolean + stopFiber: Fiber.RuntimeFiber | null + stdoutRemainder: string + stderrRemainder: string +} + +const projectTunnelMap = new Map() +const projectTunnelLock = Effect.unsafeMakeSemaphore(1) +const startWaitAttempts = 60 + +const sshTunnelHomeDir = (id: string): string => join("/tmp", "docker-git-project-tunnels", id) + +const processEnv = (homeDir: string): Readonly> => ({ + HOME: homeDir, + NO_COLOR: "1", + PATH: process.env["PATH"], + SSL_CERT_DIR: process.env["SSL_CERT_DIR"], + SSL_CERT_FILE: process.env["SSL_CERT_FILE"] +}) + +const readDefaultGatewayIp = (): Effect.Effect => + Effect.try(() => parseLinuxDefaultGatewayIp(readFileSync("/proc/net/route", "utf8"))).pipe( + Effect.orElse(() => Effect.succeed(null)) + ) + +const defaultLocalhostHost = (): Effect.Effect => { + const configured = process.env["DOCKER_GIT_PANEL_TUNNEL_LOCALHOST_HOST"]?.trim() + if (configured !== undefined && configured.length > 0) { + return Effect.succeed(configured) + } + return existsSync("/.dockerenv") + ? readDefaultGatewayIp().pipe(Effect.map((ip) => ip ?? "172.17.0.1")) + : Effect.succeed("127.0.0.1") +} + +const appendLog = (record: SshTunnelRecord, text: string): void => { + if (record.hostname !== null) return + const url = parseTryCloudflareUrl(text) + if (url === null) return + try { + record.hostname = new URL(url).hostname + } catch { + // ignore malformed URL + } +} + +const consumeChunk = ( + record: SshTunnelRecord, + stream: "stderr" | "stdout", + chunk: Buffer +): void => { + const incoming = chunk.toString("utf8").replaceAll("\r", "\n") + const withRemainder = (stream === "stdout" ? record.stdoutRemainder : record.stderrRemainder) + incoming + const lines = withRemainder.split("\n") + const tail = lines.pop() ?? "" + for (const line of lines) { + appendLog(record, line) + } + if (stream === "stdout") { + record.stdoutRemainder = tail + } else { + record.stderrRemainder = tail + } +} + +const cleanupRecord = (record: SshTunnelRecord): void => { + try { + rmSync(record.homeDir, { force: true, recursive: true }) + } catch { + // best effort + } +} + +const waitForChildClose = ( + record: SshTunnelRecord, + child: ChildProcess +): Effect.Effect => { + if (record.processClosed) { + return Effect.void + } + return Effect.async((resume) => { + const alreadyExited = child.exitCode !== null || child.signalCode !== null + let completed = false + let killTimer: ReturnType | null = null + const complete = (): void => { + if (completed) return + completed = true + child.off("close", complete) + child.off("error", complete) + if (killTimer !== null) clearTimeout(killTimer) + resume(Effect.void) + } + child.once("close", complete) + child.once("error", complete) + if (!alreadyExited && !child.killed) { + try { + child.kill("SIGTERM") + } catch { + complete() + return + } + } + if (!alreadyExited) { + killTimer = setTimeout(() => { + try { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL") + } catch { + complete() + } + }, 2_000) + killTimer.unref() + } + }) +} + +const stopRecord = (record: SshTunnelRecord): Effect.Effect => { + if (record.stopFiber !== null) { + return Fiber.join(record.stopFiber).pipe(Effect.asVoid) + } + const child = record.process + record.stopping = true + const fiber = Effect.runFork( + (child === null ? Effect.void : waitForChildClose(record, child)).pipe( + Effect.tap(() => Effect.sync(() => { cleanupRecord(record) })) + ) + ) + record.stopFiber = fiber + return Fiber.join(fiber).pipe(Effect.asVoid) +} + +const attachHandlers = (record: SshTunnelRecord, child: ChildProcess): void => { + record.process = child + record.processClosed = false + child.stdout?.on("data", (chunk: Buffer) => { consumeChunk(record, "stdout", chunk) }) + child.stderr?.on("data", (chunk: Buffer) => { consumeChunk(record, "stderr", chunk) }) + child.on("close", () => { record.processClosed = true }) + child.on("error", () => { record.processClosed = true }) +} + +const waitForHostname = ( + record: SshTunnelRecord, + remainingAttempts: number +): Effect.Effect => + Effect.gen(function*(_) { + if (record.hostname !== null || record.stopping || remainingAttempts <= 0) { + return record.hostname + } + yield* _(Effect.sleep(Duration.millis(250))) + return yield* _(waitForHostname(record, remainingAttempts - 1)) + }) + +/** + * Starts a dedicated SSH cloudflared quick tunnel for the given project. + * Idempotent — returns existing hostname if a tunnel is already running. + * + * @param projectKey - Project key used as the map key (one tunnel per project). + * @param sshPort - Host-mapped SSH port for the container. + * @returns CF hostname (e.g. "abc.trycloudflare.com") or null if startup timed out. + * @pure false + * @effect Spawns cloudflared process, reads /proc/net/route, writes to /tmp. + * @invariant Only one active record per projectKey — prior record is stopped before restart. + * @precondition sshPort > 0 + * @postcondition On success, getSshProjectTunnelHostname(projectKey) returns the same hostname. + * @complexity O(startWaitAttempts * 250ms) time for startup wait. + * @throws Never - failures are typed as ApiInternalError in the Effect error channel. + */ +export const startSshProjectTunnel = ( + projectKey: string, + sshPort: number +): Effect.Effect => + Effect.gen(function*(_) { + const existing = projectTunnelMap.get(projectKey) + if (existing !== undefined && !existing.stopping && existing.hostname !== null) { + return existing.hostname + } + if (existing !== undefined) { + yield* _(stopRecord(existing).pipe(Effect.orElse(() => Effect.void))) + projectTunnelMap.delete(projectKey) + } + + const localhostHost = yield* _(defaultLocalhostHost()) + const sshUrl = `ssh://${localhostHost}:${sshPort}` + const homeDir = sshTunnelHomeDir(randomUUID()) + const record: SshTunnelRecord = { + homeDir, + hostname: null, + process: null, + processClosed: false, + stderrRemainder: "", + stdoutRemainder: "", + stopFiber: null, + stopping: false + } + projectTunnelMap.set(projectKey, record) + + yield* _( + Effect.try({ + catch: (cause) => new ApiInternalError({ message: "Failed to start project SSH cloudflared tunnel.", cause }), + try: () => { + mkdirSync(record.homeDir, { recursive: true }) + const child = spawn( + "cloudflared", + ["tunnel", "--no-autoupdate", "--url", sshUrl], + { + cwd: process.cwd(), + env: processEnv(record.homeDir), + stdio: ["ignore", "pipe", "pipe"] + } + ) + attachHandlers(record, child) + } + }) + ) + + return yield* _(waitForHostname(record, startWaitAttempts)) + }).pipe(projectTunnelLock.withPermits(1)) + +/** + * Stops and removes the SSH cloudflared tunnel for the given project key. + * + * @param projectKey - Project key whose tunnel should be stopped. + * @pure false + * @effect Sends SIGTERM/SIGKILL to cloudflared, removes tunnel home directory. + * @invariant No-op when no tunnel exists for the projectKey. + * @complexity O(process close timeout) time. + * @throws Never - this effect has no typed failure channel. + */ +export const stopSshProjectTunnel = (projectKey: string): Effect.Effect => + Effect.gen(function*(_) { + const record = projectTunnelMap.get(projectKey) + if (record === undefined) return + projectTunnelMap.delete(projectKey) + yield* _(stopRecord(record).pipe(Effect.orElse(() => Effect.void))) + }).pipe(projectTunnelLock.withPermits(1)) + +/** + * Returns the current CF hostname for the SSH tunnel associated with the given project key. + * + * @param projectKey - Project key to look up. + * @returns CF hostname string or null if tunnel not running / hostname not yet available. + * @pure true (read-only snapshot) + * @complexity O(1) + */ +export const getSshProjectTunnelHostname = (projectKey: string): string | null => + projectTunnelMap.get(projectKey)?.hostname ?? null diff --git a/packages/app/src/web/api-share-links.ts b/packages/app/src/web/api-share-links.ts index 5efbd39c..c49679ba 100644 --- a/packages/app/src/web/api-share-links.ts +++ b/packages/app/src/web/api-share-links.ts @@ -71,3 +71,14 @@ export const deleteProjectShareLink = ( `/projects/by-key/${encodeURIComponent(projectKey)}/share-links/${encodeURIComponent(token)}`, OkResponseSchema ).pipe(Effect.asVoid) + +const SshTunnelResponseSchema = Schema.Struct({ hostname: Schema.NullOr(Schema.String) }) + +export const startProjectSshTunnel = ( + projectKey: string +): Effect.Effect<{ readonly hostname: string | null }, string> => + requestJson( + "POST", + `/projects/by-key/${encodeURIComponent(projectKey)}/ssh-tunnel`, + SshTunnelResponseSchema + ) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 5e36b41f..49e9aa18 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -1,7 +1,8 @@ import { Effect } from "effect" -import type { CSSProperties, JSX } from "react" +import { type CSSProperties, type JSX, useEffect, useState } from "react" import { deleteTerminalSessionByPath } from "./api.js" +import { startProjectSshTunnel } from "./api-share-links.js" import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" import { TerminalTaskManagerBody } from "./app-ready-terminal-task-manager.js" import type { TerminalPaneProps } from "./app-ready-terminal-types.js" @@ -150,6 +151,134 @@ const handleTerminalKill = (props: TerminalPaneProps, runtime: TerminalPaneRunti ) } +type VsCodeAccessInfo = { + readonly sshUser: string + readonly targetDir: string +} + +const buildVsCodeAccessInfo = (project: TerminalPaneProps["project"]): VsCodeAccessInfo | null => { + if (project === null) return null + return { sshUser: project.sshUser, targetDir: project.targetDir } +} + +type CfTunnelState = + | { readonly tag: "idle" } + | { readonly tag: "loading" } + | { readonly tag: "ready"; readonly hostname: string } + | { readonly tag: "failed" } + +const WILDCARD_SSH_CONFIG = `Host *.trycloudflare.com + ProxyCommand cloudflared access ssh --hostname %h + StrictHostKeyChecking no + UserKnownHostsFile /dev/null` + +const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } + +const vsCodePanelCodeStyle: CSSProperties = { + background: "#0b1017", + border: "1px solid #2a3640", + borderRadius: "2px", + color: "#a8c8f0", + display: "block", + fontFamily: "inherit", + fontSize: "0.85em", + marginBottom: "4px", + marginTop: "4px", + overflowX: "auto", + padding: "6px 8px", + whiteSpace: "pre" +} + +const vsCodePanelCopyBtnStyle: CSSProperties = { + background: "transparent", + border: "none", + color: "#7fdfff", + cursor: "pointer", + font: "inherit", + fontSize: "0.85em", + fontWeight: "bold", + padding: "2px 6px" +} + +const VsCodeAccessPanel = ( + { + cfState, + info, + onClose, + onRetry + }: { + readonly cfState: CfTunnelState + readonly info: VsCodeAccessInfo + readonly onClose: () => void + readonly onRetry: () => void + } +): JSX.Element => { + const cfSshCommand = cfState.tag === "ready" + ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` + : null + const cfVscodeUri = cfState.tag === "ready" + ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` + : null + return ( +
+
+
VS Code / SSH access
+ +
+ + {cfState.tag === "loading" && ( +
Starting Cloudflare tunnel…
+ )} + + {cfState.tag === "failed" && ( +
+
Tunnel failed to start.
+ +
+ )} + + {cfState.tag === "ready" && ( + <> +
One-time setup
+
add once to ~/.ssh/config — works for all containers
+ {WILDCARD_SSH_CONFIG} + + +
Connect via SSH
+
requires cloudflared on your machine
+ {cfSshCommand} + + + {cfVscodeUri !== null && ( + <> +
Open in VS Code
+ + + )} + + )} +
+ ) +} + const openBrowserAction = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): (() => void) | undefined => { const projectId = runtime.browserProjectId return projectId === undefined || !runtime.canOpenBrowser @@ -187,8 +316,14 @@ const openTerminalAction = (props: TerminalPaneProps, runtime: TerminalPaneRunti } const TerminalPanelForPane = ( - { bodyContent, props, runtime }: { + { + bodyContent, + onOpenVsCode, + props, + runtime + }: { readonly bodyContent: JSX.Element | undefined + readonly onOpenVsCode: (() => void) | undefined readonly props: TerminalPaneProps readonly runtime: TerminalPaneRuntime } @@ -222,16 +357,64 @@ const TerminalPanelForPane = ( onOpenTaskManager={openTaskManagerAction(props, runtime)} onOpenTerminal={openTerminalAction(props, runtime)} onMessage={props.onTerminalMessage} + onOpenVsCode={onOpenVsCode} session={props.terminalSession} /> ) +const startTunnel = ( + projectKey: string, + setCfState: (s: CfTunnelState) => void +): void => { + setCfState({ tag: "loading" }) + void Effect.runPromise( + startProjectSshTunnel(projectKey).pipe( + Effect.match({ + onFailure: () => { setCfState({ tag: "failed" }) }, + onSuccess: ({ hostname }) => { + setCfState( + hostname !== null + ? { tag: "ready", hostname } + : { tag: "failed" } + ) + } + }) + ) + ) +} + export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { + const [vsCodePanelOpen, setVsCodePanelOpen] = useState(false) + const [cfState, setCfState] = useState({ tag: "idle" }) const runtime = resolveTerminalPaneRuntime(props) - const bodyContent = terminalBodyContent(props, runtime) + + useEffect(() => { + if (!vsCodePanelOpen || runtime.browserProjectKey === undefined) return + if (cfState.tag === "idle") { + startTunnel(runtime.browserProjectKey, setCfState) + } + }, [vsCodePanelOpen, runtime.browserProjectKey, cfState.tag]) + + const vsCodeInfo = buildVsCodeAccessInfo(props.project) + const onOpenVsCode = vsCodeInfo !== null ? () => { setVsCodePanelOpen(true) } : undefined + const vsCodeBodyContent = vsCodePanelOpen && vsCodeInfo !== null + ? ( + { setVsCodePanelOpen(false) }} + onRetry={() => { + if (runtime.browserProjectKey !== undefined) { + startTunnel(runtime.browserProjectKey, setCfState) + } + }} + /> + ) + : undefined + const bodyContent = vsCodeBodyContent ?? terminalBodyContent(props, runtime) return (
- +
) } diff --git a/packages/app/src/web/app-share-link.tsx b/packages/app/src/web/app-share-link.tsx index e5fc593d..d0a05bee 100644 --- a/packages/app/src/web/app-share-link.tsx +++ b/packages/app/src/web/app-share-link.tsx @@ -207,13 +207,11 @@ const InfoHeader = ( const TerminalView = ( { - info, message, session, setState, viewport }: { - readonly info: ShareLinkInfo readonly message: string | null readonly session: ActiveTerminalSession readonly setState: Dispatch> @@ -382,7 +380,6 @@ const renderState = ( onConnect={() => {}} /> & { @@ -168,6 +169,11 @@ const TerminalHeaderActions = (props: TerminalHeaderProps): JSX.Element => ( inlineImagePreviewsEnabled={props.inlineImagePreviewsEnabled} onToggleInlineImagePreviews={props.onToggleInlineImagePreviews} /> + {props.onOpenVsCode !== undefined && ( + + VS Code + + )} Detach diff --git a/packages/terminal/src/web/panel-terminal-types.ts b/packages/terminal/src/web/panel-terminal-types.ts index 6915d120..f8156e72 100644 --- a/packages/terminal/src/web/panel-terminal-types.ts +++ b/packages/terminal/src/web/panel-terminal-types.ts @@ -18,4 +18,5 @@ export type TerminalPanelProps = { readonly onOpenTerminal?: (() => void) | undefined readonly session: ActiveTerminalSession readonly bodyContent?: JSX.Element | undefined + readonly onOpenVsCode?: (() => void) | undefined } diff --git a/packages/terminal/src/web/panel-terminal.tsx b/packages/terminal/src/web/panel-terminal.tsx index 9b43f22e..0f672d35 100644 --- a/packages/terminal/src/web/panel-terminal.tsx +++ b/packages/terminal/src/web/panel-terminal.tsx @@ -61,6 +61,7 @@ type TerminalPanelLayoutProps = | "onOpenSkiller" | "onOpenTaskManager" | "onOpenTerminal" + | "onOpenVsCode" | "session" > & InlineImagePreviewState @@ -265,6 +266,7 @@ const TerminalPanelLayout = (props: TerminalPanelLayoutProps): JSX.Element => ( onOpenSkiller={props.onOpenSkiller} onOpenTaskManager={props.onOpenTaskManager} onOpenTerminal={props.onOpenTerminal} + onOpenVsCode={props.onOpenVsCode} onToggleInlineImagePreviews={props.toggleInlineImagePreviews} session={props.session} status={props.status} From 18aea42d3b196bd4a796e9367fa6b41ada34fdc7 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:08:38 +0000 Subject: [PATCH 07/20] feat(app): show SSH password in VS Code CF tunnel panel When clicking VS Code, the panel now: - enables password auth on the container (via chpasswd) - returns sshPassword from POST /ssh-tunnel - displays the password with a copy button Fixes: user was prompted for password without knowing it Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/http.ts | 8 +++++++- packages/app/src/web/api-share-links.ts | 7 +++++-- packages/app/src/web/app-ready-terminal-pane.tsx | 10 +++++++--- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 43fa9426..0f3b56fc 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -1261,12 +1261,18 @@ export const makeRouter = () => { Effect.gen(function*(_) { const { projectKey } = yield* _(projectKeyParams) const project = yield* _(getProjectItemByKey(projectKey)) + const sshPassword = generateSshPassword() + yield* _( + enableContainerPasswordAuth(project.containerName, sshPassword).pipe( + Effect.orElse(() => Effect.void) + ) + ) const hostname = yield* _( startSshProjectTunnel(projectKey, project.sshPort).pipe( Effect.orElse(() => Effect.succeed(null)) ) ) - return yield* _(jsonResponse({ hostname }, 200)) + return yield* _(jsonResponse({ hostname, sshPassword }, 200)) }).pipe(Effect.catchAll(errorResponse)) ) ) diff --git a/packages/app/src/web/api-share-links.ts b/packages/app/src/web/api-share-links.ts index c49679ba..26625997 100644 --- a/packages/app/src/web/api-share-links.ts +++ b/packages/app/src/web/api-share-links.ts @@ -72,11 +72,14 @@ export const deleteProjectShareLink = ( OkResponseSchema ).pipe(Effect.asVoid) -const SshTunnelResponseSchema = Schema.Struct({ hostname: Schema.NullOr(Schema.String) }) +const SshTunnelResponseSchema = Schema.Struct({ + hostname: Schema.NullOr(Schema.String), + sshPassword: Schema.String +}) export const startProjectSshTunnel = ( projectKey: string -): Effect.Effect<{ readonly hostname: string | null }, string> => +): Effect.Effect<{ readonly hostname: string | null; readonly sshPassword: string }, string> => requestJson( "POST", `/projects/by-key/${encodeURIComponent(projectKey)}/ssh-tunnel`, diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 49e9aa18..bf011c86 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -164,7 +164,7 @@ const buildVsCodeAccessInfo = (project: TerminalPaneProps["project"]): VsCodeAcc type CfTunnelState = | { readonly tag: "idle" } | { readonly tag: "loading" } - | { readonly tag: "ready"; readonly hostname: string } + | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } | { readonly tag: "failed" } const WILDCARD_SSH_CONFIG = `Host *.trycloudflare.com @@ -263,6 +263,10 @@ const VsCodeAccessPanel = ( {cfSshCommand} +
SSH password
+ {cfState.sshPassword} + + {cfVscodeUri !== null && ( <>
Open in VS Code
@@ -371,10 +375,10 @@ const startTunnel = ( startProjectSshTunnel(projectKey).pipe( Effect.match({ onFailure: () => { setCfState({ tag: "failed" }) }, - onSuccess: ({ hostname }) => { + onSuccess: ({ hostname, sshPassword }) => { setCfState( hostname !== null - ? { tag: "ready", hostname } + ? { tag: "ready", hostname, sshPassword } : { tag: "failed" } ) } From 85ddedb4aeee2d90ba4d3de94900a6cc54836e91 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:58:12 +0000 Subject: [PATCH 08/20] fix(app): SSH command opens targetDir on connect Adds -t and "cd DIR && exec \$SHELL" so the SSH session starts directly in the project folder instead of the default home. Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index bf011c86..cc267de8 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -214,7 +214,7 @@ const VsCodeAccessPanel = ( } ): JSX.Element => { const cfSshCommand = cfState.tag === "ready" - ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` + ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" -t ${info.sshUser}@${cfState.hostname} "cd ${info.targetDir} && exec \\$SHELL"` : null const cfVscodeUri = cfState.tag === "ready" ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` From 6a49c570d7a40a7435c45585ae5cb9214f107de8 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:07:05 +0000 Subject: [PATCH 09/20] fix(app): SSH config shows specific hostname, not wildcard Panel now shows a host entry with the actual hostname so it can be copy-pasted directly into ~/.ssh/config without editing. SSH command is simplified (no inline -o ProxyCommand since config handles it). Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/web/app-ready-terminal-pane.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index cc267de8..ec53c2fd 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -167,10 +167,8 @@ type CfTunnelState = | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } | { readonly tag: "failed" } -const WILDCARD_SSH_CONFIG = `Host *.trycloudflare.com - ProxyCommand cloudflared access ssh --hostname %h - StrictHostKeyChecking no - UserKnownHostsFile /dev/null` +const hostSshConfig = (hostname: string): string => + `Host ${hostname}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } @@ -213,8 +211,9 @@ const VsCodeAccessPanel = ( readonly onRetry: () => void } ): JSX.Element => { + const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname) : null const cfSshCommand = cfState.tag === "ready" - ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" -t ${info.sshUser}@${cfState.hostname} "cd ${info.targetDir} && exec \\$SHELL"` + ? `ssh -t ${info.sshUser}@${cfState.hostname} "cd ${info.targetDir} && exec \\$SHELL"` : null const cfVscodeUri = cfState.tag === "ready" ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` @@ -253,13 +252,12 @@ const VsCodeAccessPanel = ( {cfState.tag === "ready" && ( <> -
One-time setup
-
add once to ~/.ssh/config — works for all containers
- {WILDCARD_SSH_CONFIG} - +
Add to ~/.ssh/config
+
requires cloudflared installed on your machine
+ {cfSshConfig} +
Connect via SSH
-
requires cloudflared on your machine
{cfSshCommand} From 46f68809963c4d92d67f77bf6eedfb178e839649 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:10:45 +0000 Subject: [PATCH 10/20] fix(app): SSH config includes RemoteCommand for auto cd into project Adds RemoteCommand and RequestTTY yes to the host SSH config entry so plain `ssh dev@HOST` opens a shell directly in the project folder. Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index ec53c2fd..f6a7bbf1 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -167,8 +167,8 @@ type CfTunnelState = | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } | { readonly tag: "failed" } -const hostSshConfig = (hostname: string): string => - `Host ${hostname}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` +const hostSshConfig = (hostname: string, targetDir: string): string => + `Host ${hostname}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n RemoteCommand cd ${targetDir} && exec $SHELL\n RequestTTY yes` const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } @@ -211,9 +211,9 @@ const VsCodeAccessPanel = ( readonly onRetry: () => void } ): JSX.Element => { - const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname) : null + const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.targetDir) : null const cfSshCommand = cfState.tag === "ready" - ? `ssh -t ${info.sshUser}@${cfState.hostname} "cd ${info.targetDir} && exec \\$SHELL"` + ? `ssh ${info.sshUser}@${cfState.hostname}` : null const cfVscodeUri = cfState.tag === "ready" ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` From 99ddb611ec1cf888aa0f39766e120bcb8b418334 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:14:15 +0000 Subject: [PATCH 11/20] feat(app): auto-restart CF tunnel when process dies + refresh button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: don't return hostname of dead cloudflared process (check processClosed) - Frontend: poll every 30s when panel is open, auto-restart if tunnel died - Frontend: add ↻ refresh button in ready state for manual restart Co-Authored-By: Claude Sonnet 4.6 --- .../api/src/services/ssh-project-tunnels.ts | 2 +- .../app/src/web/app-ready-terminal-pane.tsx | 42 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/api/src/services/ssh-project-tunnels.ts b/packages/api/src/services/ssh-project-tunnels.ts index 8a8ebdf8..2dc6dc4b 100644 --- a/packages/api/src/services/ssh-project-tunnels.ts +++ b/packages/api/src/services/ssh-project-tunnels.ts @@ -197,7 +197,7 @@ export const startSshProjectTunnel = ( ): Effect.Effect => Effect.gen(function*(_) { const existing = projectTunnelMap.get(projectKey) - if (existing !== undefined && !existing.stopping && existing.hostname !== null) { + if (existing !== undefined && !existing.stopping && !existing.processClosed && existing.hostname !== null) { return existing.hostname } if (existing !== undefined) { diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index f6a7bbf1..6e131133 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -203,11 +203,13 @@ const VsCodeAccessPanel = ( cfState, info, onClose, + onRefresh, onRetry }: { readonly cfState: CfTunnelState readonly info: VsCodeAccessInfo readonly onClose: () => void + readonly onRefresh: () => void readonly onRetry: () => void } ): JSX.Element => { @@ -230,13 +232,12 @@ const VsCodeAccessPanel = ( }}>
VS Code / SSH access
- +
+ {cfState.tag === "ready" && ( + + )} + +
{cfState.tag === "loading" && ( @@ -397,6 +398,28 @@ export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { } }, [vsCodePanelOpen, runtime.browserProjectKey, cfState.tag]) + // Poll every 30s when panel is open: restart tunnel if process died + useEffect(() => { + if (!vsCodePanelOpen || cfState.tag !== "ready" || runtime.browserProjectKey === undefined) return + const projectKey = runtime.browserProjectKey + const id = setInterval(() => { + void Effect.runPromise( + startProjectSshTunnel(projectKey).pipe( + Effect.match({ + onFailure: () => { setCfState({ tag: "failed" }) }, + onSuccess: ({ hostname, sshPassword }) => { + if (hostname === null) { setCfState({ tag: "failed" }); return } + setCfState((prev) => + prev.tag === "ready" && prev.hostname === hostname ? prev : { tag: "ready", hostname, sshPassword } + ) + } + }) + ) + ) + }, 30_000) + return () => { clearInterval(id) } + }, [vsCodePanelOpen, cfState.tag, runtime.browserProjectKey]) + const vsCodeInfo = buildVsCodeAccessInfo(props.project) const onOpenVsCode = vsCodeInfo !== null ? () => { setVsCodePanelOpen(true) } : undefined const vsCodeBodyContent = vsCodePanelOpen && vsCodeInfo !== null @@ -405,6 +428,11 @@ export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { cfState={cfState} info={vsCodeInfo} onClose={() => { setVsCodePanelOpen(false) }} + onRefresh={() => { + if (runtime.browserProjectKey !== undefined) { + startTunnel(runtime.browserProjectKey, setCfState) + } + }} onRetry={() => { if (runtime.browserProjectKey !== undefined) { startTunnel(runtime.browserProjectKey, setCfState) From ef4b61f531fe54550cc54294b36b0521c3de7328 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:22:04 +0000 Subject: [PATCH 12/20] fix(api): store SSH password in tunnel record, return stable password Root cause: every POST /ssh-tunnel call regenerated a new password and set it on the container. Polling every 30s was invalidating the password shown in the panel while the old hostname stayed cached in the frontend. Fix: password is generated once per fresh tunnel start, stored in the SshTunnelRecord, and returned consistently while the tunnel is alive. A new password is only generated when the old tunnel actually dies. Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/http.ts | 14 ++++---------- packages/api/src/services/ssh-project-tunnels.ts | 16 ++++++++++++---- packages/app/src/web/app-ready-terminal-pane.tsx | 4 +--- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 0f3b56fc..79f326da 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -1261,18 +1261,12 @@ export const makeRouter = () => { Effect.gen(function*(_) { const { projectKey } = yield* _(projectKeyParams) const project = yield* _(getProjectItemByKey(projectKey)) - const sshPassword = generateSshPassword() - yield* _( - enableContainerPasswordAuth(project.containerName, sshPassword).pipe( - Effect.orElse(() => Effect.void) - ) - ) - const hostname = yield* _( - startSshProjectTunnel(projectKey, project.sshPort).pipe( - Effect.orElse(() => Effect.succeed(null)) + const result = yield* _( + startSshProjectTunnel(projectKey, project.sshPort, project.containerName).pipe( + Effect.orElse(() => Effect.succeed({ hostname: null, sshPassword: "" })) ) ) - return yield* _(jsonResponse({ hostname, sshPassword }, 200)) + return yield* _(jsonResponse(result, 200)) }).pipe(Effect.catchAll(errorResponse)) ) ) diff --git a/packages/api/src/services/ssh-project-tunnels.ts b/packages/api/src/services/ssh-project-tunnels.ts index 2dc6dc4b..00e7e4f8 100644 --- a/packages/api/src/services/ssh-project-tunnels.ts +++ b/packages/api/src/services/ssh-project-tunnels.ts @@ -19,12 +19,14 @@ import { Duration, Effect, Fiber } from "effect" import { ApiInternalError } from "../api/errors.js" import { parseTryCloudflareUrl } from "./panel-cloudflare-tunnel-core.js" import { parseLinuxDefaultGatewayIp } from "./project-port-proxy-core.js" +import { generateSshPassword, enableContainerPasswordAuth } from "./ssh-password-setup.js" type SshTunnelRecord = { readonly homeDir: string process: ChildProcess | null processClosed: boolean hostname: string | null + sshPassword: string stopping: boolean stopFiber: Fiber.RuntimeFiber | null stdoutRemainder: string @@ -193,18 +195,22 @@ const waitForHostname = ( */ export const startSshProjectTunnel = ( projectKey: string, - sshPort: number -): Effect.Effect => + sshPort: number, + containerName: string +): Effect.Effect<{ hostname: string | null; sshPassword: string }, ApiInternalError> => Effect.gen(function*(_) { const existing = projectTunnelMap.get(projectKey) if (existing !== undefined && !existing.stopping && !existing.processClosed && existing.hostname !== null) { - return existing.hostname + return { hostname: existing.hostname, sshPassword: existing.sshPassword } } if (existing !== undefined) { yield* _(stopRecord(existing).pipe(Effect.orElse(() => Effect.void))) projectTunnelMap.delete(projectKey) } + const sshPassword = generateSshPassword() + yield* _(enableContainerPasswordAuth(containerName, sshPassword).pipe(Effect.orElse(() => Effect.void))) + const localhostHost = yield* _(defaultLocalhostHost()) const sshUrl = `ssh://${localhostHost}:${sshPort}` const homeDir = sshTunnelHomeDir(randomUUID()) @@ -213,6 +219,7 @@ export const startSshProjectTunnel = ( hostname: null, process: null, processClosed: false, + sshPassword, stderrRemainder: "", stdoutRemainder: "", stopFiber: null, @@ -239,7 +246,8 @@ export const startSshProjectTunnel = ( }) ) - return yield* _(waitForHostname(record, startWaitAttempts)) + const hostname = yield* _(waitForHostname(record, startWaitAttempts)) + return { hostname, sshPassword } }).pipe(projectTunnelLock.withPermits(1)) /** diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 6e131133..56f49e0d 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -409,9 +409,7 @@ export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { onFailure: () => { setCfState({ tag: "failed" }) }, onSuccess: ({ hostname, sshPassword }) => { if (hostname === null) { setCfState({ tag: "failed" }); return } - setCfState((prev) => - prev.tag === "ready" && prev.hostname === hostname ? prev : { tag: "ready", hostname, sshPassword } - ) + setCfState({ tag: "ready", hostname, sshPassword }) } }) ) From 95d30e3e3ff46295f5cd1c9b18ba01e4d7d821ce Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:35:13 +0000 Subject: [PATCH 13/20] fix(app): restore inline ProxyCommand in SSH connect command Show self-contained command that works without ~/.ssh/config setup: ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" -t dev@HOST "cd /path && exec $SHELL" Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 56f49e0d..9180a31d 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -215,7 +215,7 @@ const VsCodeAccessPanel = ( ): JSX.Element => { const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.targetDir) : null const cfSshCommand = cfState.tag === "ready" - ? `ssh ${info.sshUser}@${cfState.hostname}` + ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" -t ${info.sshUser}@${cfState.hostname} "cd ${info.targetDir} && exec \\$SHELL"` : null const cfVscodeUri = cfState.tag === "ready" ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` From d45553509ff4c0c43ed669f58233f1a98739298f Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:39:50 +0000 Subject: [PATCH 14/20] =?UTF-8?q?fix(app):=20remove=20remote=20command=20f?= =?UTF-8?q?rom=20inline=20SSH=20=E2=80=94=20conflicts=20with=20config=20Re?= =?UTF-8?q?moteCommand?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows SSH errors "Cannot execute command-line and remote command" when both a RemoteCommand in ~/.ssh/config and a command argument are present. Inline command shows only the ProxyCommand option; the config block handles RemoteCommand for directory switching. Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 9180a31d..10f566e4 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -215,7 +215,7 @@ const VsCodeAccessPanel = ( ): JSX.Element => { const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.targetDir) : null const cfSshCommand = cfState.tag === "ready" - ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" -t ${info.sshUser}@${cfState.hostname} "cd ${info.targetDir} && exec \\$SHELL"` + ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` : null const cfVscodeUri = cfState.tag === "ready" ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` From eb1e0cf8216be3a67b1e9658d58313e48ef9cc9b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:46:09 +0000 Subject: [PATCH 15/20] =?UTF-8?q?fix(app):=20remove=20RemoteCommand=20from?= =?UTF-8?q?=20SSH=20config=20=E2=80=94=20breaks=20VS=20Code=20Remote=20SSH?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteCommand intercepts VS Code's own installer and server-start commands, causing "Cannot execute command-line and remote command" and failing to parse the remote port. VS Code needs a clean shell on connect to run vscode-server. The folder is already handled via the VS Code URI (folder=). Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 10f566e4..44035a4a 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -167,8 +167,8 @@ type CfTunnelState = | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } | { readonly tag: "failed" } -const hostSshConfig = (hostname: string, targetDir: string): string => - `Host ${hostname}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n RemoteCommand cd ${targetDir} && exec $SHELL\n RequestTTY yes` +const hostSshConfig = (hostname: string): string => + `Host ${hostname}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } @@ -213,7 +213,7 @@ const VsCodeAccessPanel = ( readonly onRetry: () => void } ): JSX.Element => { - const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.targetDir) : null + const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname) : null const cfSshCommand = cfState.tag === "ready" ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` : null From 02a5bd2cf71ad693d018e10dde84b31e38586541 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:51:31 +0000 Subject: [PATCH 16/20] =?UTF-8?q?fix(app):=20add=20User=20to=20SSH=20confi?= =?UTF-8?q?g=20block=20=E2=80=94=20VS=20Code=20used=20local=20Windows=20us?= =?UTF-8?q?ername?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without User directive VS Code Remote SSH falls back to the local OS username. Added User ${sshUser} so the config specifies dev@ explicitly. Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 44035a4a..86162479 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -167,8 +167,8 @@ type CfTunnelState = | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } | { readonly tag: "failed" } -const hostSshConfig = (hostname: string): string => - `Host ${hostname}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` +const hostSshConfig = (hostname: string, sshUser: string): string => + `Host ${hostname}\n User ${sshUser}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } @@ -213,7 +213,7 @@ const VsCodeAccessPanel = ( readonly onRetry: () => void } ): JSX.Element => { - const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname) : null + const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.sshUser) : null const cfSshCommand = cfState.tag === "ready" ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` : null From 658a11e18b90f75f75698eff15acd31dcb18f0d1 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:05:33 +0000 Subject: [PATCH 17/20] feat(app): direct SSH (local network) section in VS Code panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows IP:port SSH config without cloudflared — one port per container. Host IP taken from window.location.hostname, port from project.sshPort. - SSH config: Host IP-ssh / HostName IP / Port N / User dev - Command: ssh -p PORT dev@IP - VS Code URI using config alias (no cloudflared needed) Co-Authored-By: Claude Sonnet 4.6 --- .../app/src/web/app-ready-terminal-pane.tsx | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 86162479..c9f3a8da 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -154,11 +154,12 @@ const handleTerminalKill = (props: TerminalPaneProps, runtime: TerminalPaneRunti type VsCodeAccessInfo = { readonly sshUser: string readonly targetDir: string + readonly sshPort: number } const buildVsCodeAccessInfo = (project: TerminalPaneProps["project"]): VsCodeAccessInfo | null => { if (project === null) return null - return { sshUser: project.sshUser, targetDir: project.targetDir } + return { sshUser: project.sshUser, targetDir: project.targetDir, sshPort: project.sshPort } } type CfTunnelState = @@ -170,6 +171,9 @@ type CfTunnelState = const hostSshConfig = (hostname: string, sshUser: string): string => `Host ${hostname}\n User ${sshUser}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` +const directSshConfig = (host: string, sshPort: number, sshUser: string): string => + `Host ${host}-ssh\n HostName ${host}\n Port ${sshPort}\n User ${sshUser}\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` + const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } const vsCodePanelCodeStyle: CSSProperties = { @@ -220,6 +224,10 @@ const VsCodeAccessPanel = ( const cfVscodeUri = cfState.tag === "ready" ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` : null + const directHost = window.location.hostname + const directConfig = directSshConfig(directHost, info.sshPort, info.sshUser) + const directCommand = `ssh -p ${info.sshPort} ${info.sshUser}@${directHost}` + const directVscodeUri = `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${directHost}-ssh`)}&folder=${encodeURIComponent(info.targetDir)}` return (
)} + +
+
Direct SSH (local network)
+ +
Add to ~/.ssh/config
+
no cloudflared needed — works on same LAN
+ {directConfig} + + +
Connect via SSH
+ {directCommand} + + +
Open in VS Code
+
requires config entry above in ~/.ssh/config
+
) } From ef931d89facd3106dbc66c66fe020b56530d91a6 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:17:01 +0000 Subject: [PATCH 18/20] feat(app): show password + auto-cd in Direct SSH section - directCommand includes -t "cd /home/dev/app && exec $SHELL" - SSH password shown in Direct SSH section (shared with CF tunnel) Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/web/app-ready-terminal-pane.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index c9f3a8da..90dec14e 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -226,7 +226,7 @@ const VsCodeAccessPanel = ( : null const directHost = window.location.hostname const directConfig = directSshConfig(directHost, info.sshPort, info.sshUser) - const directCommand = `ssh -p ${info.sshPort} ${info.sshUser}@${directHost}` + const directCommand = `ssh -p ${info.sshPort} -t ${info.sshUser}@${directHost} "cd ${info.targetDir} && exec \\$SHELL"` const directVscodeUri = `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${directHost}-ssh`)}&folder=${encodeURIComponent(info.targetDir)}` return (
{directCommand} + {cfState.tag === "ready" && ( + <> +
SSH password
+ {cfState.sshPassword} + + + )} +
Open in VS Code
requires config entry above in ~/.ssh/config
From 9132fdcccbe1ff537a9b1d59c1f2cff979f7b3a7 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 19:14:02 +0000 Subject: [PATCH 19/20] fix(pr-434): address CodeRabbit review issues - contracts.ts: add sshPassword: string | null to ShareLinkInfo type - ssh-project-tunnels.ts: exit waitForHostname early when processClosed; propagate enableContainerPasswordAuth failure - ssh-password-setup.ts: pass password via SSHPW env var instead of shell interpolation to prevent injection - app-ready-terminal-pane.tsx: add cancelled flag to polling useEffect to prevent stale setState - panel-share.tsx: replace eslint-disable with useCallback + requestId guard for stale request prevention - app-share-link.tsx: use RegExp.exec() instead of String.match(); extract nested template literal; async copyText Co-Authored-By: Claude Sonnet 4.6 --- packages/api/src/api/contracts.ts | 1 + .../api/src/services/ssh-password-setup.ts | 13 +- .../api/src/services/ssh-project-tunnels.ts | 4 +- .../app/src/web/app-ready-terminal-pane.tsx | 203 ++++++++++++++---- packages/app/src/web/app-share-link.tsx | 145 ++++++++++--- packages/app/src/web/panel-share.tsx | 82 ++++--- 6 files changed, 334 insertions(+), 114 deletions(-) diff --git a/packages/api/src/api/contracts.ts b/packages/api/src/api/contracts.ts index decb6b2e..efdc4b97 100644 --- a/packages/api/src/api/contracts.ts +++ b/packages/api/src/api/contracts.ts @@ -821,6 +821,7 @@ export type ShareLinkInfo = { readonly vscodeUri: string readonly cfVscodeUri: string | null readonly workspacePath: string + readonly sshPassword: string | null readonly createdAt: string readonly expiresAt: string } diff --git a/packages/api/src/services/ssh-password-setup.ts b/packages/api/src/services/ssh-password-setup.ts index 8650b9a8..da8e7f71 100644 --- a/packages/api/src/services/ssh-password-setup.ts +++ b/packages/api/src/services/ssh-password-setup.ts @@ -29,12 +29,14 @@ export const generateSshPassword = (): string => { const dockerExec = ( containerName: string, + env: Record, script: string ): Effect.Effect => Effect.tryPromise({ catch: (cause) => new ApiInternalError({ message: `docker exec ${containerName} failed`, cause }), try: async () => { - const { stdout } = await execFileAsync("docker", ["exec", containerName, "sh", "-c", script]) + const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]) + const { stdout } = await execFileAsync("docker", ["exec", ...envArgs, containerName, "sh", "-c", script]) return stdout } }) @@ -54,14 +56,11 @@ export const enableContainerPasswordAuth = ( password: string ): Effect.Effect => { const script = [ - // Enable password auth in docker-git's custom sshd config "sed -i 's/PasswordAuthentication no/PasswordAuthentication yes/' /etc/ssh/sshd_config.d/dev.conf", - // Set password for dev user (chpasswd reads user:password from stdin) - `echo 'dev:${password}' | chpasswd`, - // Reload sshd without dropping existing connections + "printf 'dev:%s' \"$SSHPW\" | chpasswd", "kill -HUP $(pgrep -xo sshd) 2>/dev/null || true" ].join(" && ") - return dockerExec(containerName, script).pipe(Effect.asVoid) + return dockerExec(containerName, { SSHPW: password }, script).pipe(Effect.asVoid) } /** @@ -82,7 +81,7 @@ export const disableContainerPasswordAuth = ( "passwd -l dev", "kill -HUP $(pgrep -xo sshd) 2>/dev/null || true" ].join(" && ") - return dockerExec(containerName, script).pipe( + return dockerExec(containerName, {}, script).pipe( Effect.asVoid, Effect.orElse(() => Effect.void) ) diff --git a/packages/api/src/services/ssh-project-tunnels.ts b/packages/api/src/services/ssh-project-tunnels.ts index 00e7e4f8..c2212ef8 100644 --- a/packages/api/src/services/ssh-project-tunnels.ts +++ b/packages/api/src/services/ssh-project-tunnels.ts @@ -171,7 +171,7 @@ const waitForHostname = ( remainingAttempts: number ): Effect.Effect => Effect.gen(function*(_) { - if (record.hostname !== null || record.stopping || remainingAttempts <= 0) { + if (record.hostname !== null || record.stopping || record.processClosed || remainingAttempts <= 0) { return record.hostname } yield* _(Effect.sleep(Duration.millis(250))) @@ -209,7 +209,7 @@ export const startSshProjectTunnel = ( } const sshPassword = generateSshPassword() - yield* _(enableContainerPasswordAuth(containerName, sshPassword).pipe(Effect.orElse(() => Effect.void))) + yield* _(enableContainerPasswordAuth(containerName, sshPassword)) const localhostHost = yield* _(defaultLocalhostHost()) const sshUrl = `ssh://${localhostHost}:${sshPort}` diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 90dec14e..987c9470 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -1,8 +1,8 @@ import { Effect } from "effect" import { type CSSProperties, type JSX, useEffect, useState } from "react" -import { deleteTerminalSessionByPath } from "./api.js" import { startProjectSshTunnel } from "./api-share-links.js" +import { deleteTerminalSessionByPath } from "./api.js" import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" import { TerminalTaskManagerBody } from "./app-ready-terminal-task-manager.js" import type { TerminalPaneProps } from "./app-ready-terminal-types.js" @@ -174,7 +174,13 @@ const hostSshConfig = (hostname: string, sshUser: string): string => const directSshConfig = (host: string, sshPort: number, sshUser: string): string => `Host ${host}-ssh\n HostName ${host}\n Port ${sshPort}\n User ${sshUser}\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` -const copyText = (text: string): void => { void navigator.clipboard.writeText(text).catch(() => {}) } +const copyText = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text) + } catch { + // ignore clipboard errors + } +} const vsCodePanelCodeStyle: CSSProperties = { background: "#0b1017", @@ -222,29 +228,40 @@ const VsCodeAccessPanel = ( ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` : null const cfVscodeUri = cfState.tag === "ready" - ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${info.sshUser}@${cfState.hostname}`)}&folder=${encodeURIComponent(info.targetDir)}` + ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ + encodeURIComponent(`${info.sshUser}@${cfState.hostname}`) + }&folder=${encodeURIComponent(info.targetDir)}` : null - const directHost = window.location.hostname + const directHost = location.hostname const directConfig = directSshConfig(directHost, info.sshPort, info.sshUser) - const directCommand = `ssh -p ${info.sshPort} -t ${info.sshUser}@${directHost} "cd ${info.targetDir} && exec \\$SHELL"` - const directVscodeUri = `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(`${directHost}-ssh`)}&folder=${encodeURIComponent(info.targetDir)}` + const directCommand = String + .raw`ssh -p ${info.sshPort} -t ${info.sshUser}@${directHost} "cd ${info.targetDir} && exec \$SHELL"` + const directVscodeUri = `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ + encodeURIComponent(`${directHost}-ssh`) + }&folder=${encodeURIComponent(info.targetDir)}` return ( -
+
VS Code / SSH access
{cfState.tag === "ready" && ( - + )} - +
@@ -255,30 +272,76 @@ const VsCodeAccessPanel = ( {cfState.tag === "failed" && (
Tunnel failed to start.
- +
)} {cfState.tag === "ready" && ( <>
Add to ~/.ssh/config
-
requires cloudflared installed on your machine
+
+ requires cloudflared installed on your machine +
{cfSshConfig} - - -
Connect via SSH
+ + +
+ Connect via SSH +
{cfSshCommand} - +
SSH password
{cfState.sshPassword} - + {cfVscodeUri !== null && ( <> -
Open in VS Code
+
+ Open in VS Code +
@@ -293,24 +356,58 @@ const VsCodeAccessPanel = (
Add to ~/.ssh/config
no cloudflared needed — works on same LAN
{directConfig} - +
Connect via SSH
{directCommand} - + {cfState.tag === "ready" && ( <>
SSH password
{cfState.sshPassword} - + )}
Open in VS Code
requires config entry above in ~/.ssh/config
@@ -409,12 +506,14 @@ const startTunnel = ( void Effect.runPromise( startProjectSshTunnel(projectKey).pipe( Effect.match({ - onFailure: () => { setCfState({ tag: "failed" }) }, + onFailure: () => { + setCfState({ tag: "failed" }) + }, onSuccess: ({ hostname, sshPassword }) => { setCfState( - hostname !== null - ? { tag: "ready", hostname, sshPassword } - : { tag: "failed" } + hostname === null + ? { tag: "failed" } + : { tag: "ready", hostname, sshPassword } ) } }) @@ -423,45 +522,59 @@ const startTunnel = ( } export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { - const [vsCodePanelOpen, setVsCodePanelOpen] = useState(false) + const [isVsCodePanelOpen, setVsCodePanelOpen] = useState(false) const [cfState, setCfState] = useState({ tag: "idle" }) const runtime = resolveTerminalPaneRuntime(props) useEffect(() => { - if (!vsCodePanelOpen || runtime.browserProjectKey === undefined) return + if (!isVsCodePanelOpen || runtime.browserProjectKey === undefined) return if (cfState.tag === "idle") { startTunnel(runtime.browserProjectKey, setCfState) } - }, [vsCodePanelOpen, runtime.browserProjectKey, cfState.tag]) + }, [isVsCodePanelOpen, runtime.browserProjectKey, cfState.tag]) // Poll every 30s when panel is open: restart tunnel if process died useEffect(() => { - if (!vsCodePanelOpen || cfState.tag !== "ready" || runtime.browserProjectKey === undefined) return + if (!isVsCodePanelOpen || cfState.tag !== "ready" || runtime.browserProjectKey === undefined) return const projectKey = runtime.browserProjectKey + let isCancelled = false const id = setInterval(() => { void Effect.runPromise( startProjectSshTunnel(projectKey).pipe( Effect.match({ - onFailure: () => { setCfState({ tag: "failed" }) }, + onFailure: () => { + if (!isCancelled) setCfState({ tag: "failed" }) + }, onSuccess: ({ hostname, sshPassword }) => { - if (hostname === null) { setCfState({ tag: "failed" }); return } + if (isCancelled) return + if (hostname === null) { + setCfState({ tag: "failed" }) + return + } setCfState({ tag: "ready", hostname, sshPassword }) } }) ) ) }, 30_000) - return () => { clearInterval(id) } - }, [vsCodePanelOpen, cfState.tag, runtime.browserProjectKey]) + return () => { + isCancelled = true + clearInterval(id) + } + }, [isVsCodePanelOpen, cfState.tag, runtime.browserProjectKey]) const vsCodeInfo = buildVsCodeAccessInfo(props.project) - const onOpenVsCode = vsCodeInfo !== null ? () => { setVsCodePanelOpen(true) } : undefined - const vsCodeBodyContent = vsCodePanelOpen && vsCodeInfo !== null + const onOpenVsCode = vsCodeInfo === null ? undefined : () => { + setVsCodePanelOpen(true) + } + const vsCodeBodyContent = isVsCodePanelOpen && vsCodeInfo !== null ? ( { setVsCodePanelOpen(false) }} + onClose={() => { + setVsCodePanelOpen(false) + }} onRefresh={() => { if (runtime.browserProjectKey !== undefined) { startTunnel(runtime.browserProjectKey, setCfState) diff --git a/packages/app/src/web/app-share-link.tsx b/packages/app/src/web/app-share-link.tsx index d0a05bee..642b21a8 100644 --- a/packages/app/src/web/app-share-link.tsx +++ b/packages/app/src/web/app-share-link.tsx @@ -1,11 +1,11 @@ import { Effect, Match } from "effect" import { type CSSProperties, type Dispatch, type JSX, type SetStateAction, useEffect, useState } from "react" -import { createProjectTerminalSession, deleteTerminalSessionByPath } from "./api.js" import type { ShareLinkInfo } from "./api-share-links.js" import { loadShareLink } from "./api-share-links.js" +import { createProjectTerminalSession, deleteTerminalSessionByPath } from "./api.js" import { TerminalPanel } from "./panel-terminal.js" -import { buildProjectActiveTerminalSession, type ActiveTerminalSession } from "./terminal.js" +import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" import type { ViewportLayout } from "./viewport-layout.js" // CHANGE: standalone share link page – validates token, shows SSH config and web terminal @@ -20,7 +20,12 @@ type ShareLinkState = | { readonly _tag: "Error"; readonly message: string } | { readonly _tag: "Info"; readonly info: ShareLinkInfo } | { readonly _tag: "Connecting"; readonly info: ShareLinkInfo } - | { readonly _tag: "Terminal"; readonly info: ShareLinkInfo; readonly session: ActiveTerminalSession; readonly message: string | null } + | { + readonly _tag: "Terminal" + readonly info: ShareLinkInfo + readonly session: ActiveTerminalSession + readonly message: string | null + } | { readonly _tag: "Closed"; readonly info: ShareLinkInfo; readonly closedMessage: string } export type AppShareLinkProps = { @@ -80,8 +85,12 @@ const codeBlockStyle: CSSProperties = { whiteSpace: "pre" } -const copyText = (text: string): void => { - void navigator.clipboard.writeText(text).catch(() => {}) +const copyText = async (text: string): Promise => { + try { + await navigator.clipboard.writeText(text) + } catch { + // ignore clipboard errors + } } const vscodeLinkStyle: CSSProperties = { @@ -101,7 +110,9 @@ const SshConfigBlock = (
{label}
{snippet} -
After setup, connect to any container:
+ +
+ After setup, connect to any container: +
{`ssh dev@${cfHostname}`} - +
- Requires cloudflared installed on your machine + Requires{" "} + + cloudflared + {" "} + installed on your machine
) @@ -141,22 +197,48 @@ const SshPasswordBlock = ( { info }: { readonly info: ShareLinkInfo } ): JSX.Element | null => { if (info.sshPassword === null) return null - const sshHostname = info.sshConfigSnippet.match(/HostName\s+(\S+)/)?.[1] ?? "host" - const sshPort = info.sshConfigSnippet.match(/Port\s+(\d+)/)?.[1] ?? "22" - const sshUser = info.sshConfigSnippet.match(/User\s+(\S+)/)?.[1] ?? "dev" + const sshHostname = /HostName\s+(\S+)/.exec(info.sshConfigSnippet)?.[1] ?? "host" + const sshPort = /Port\s+(\d+)/.exec(info.sshConfigSnippet)?.[1] ?? "22" + const sshUser = /User\s+(\S+)/.exec(info.sshConfigSnippet)?.[1] ?? "dev" const directCmd = `ssh ${sshUser}@${sshHostname} -p ${sshPort}` return ( -
+
SSH password
- {info.sshPassword as string} - + + {info.sshPassword} + +
{sshHostname !== "localhost" && ( <>
Direct (LAN):
{directCmd} - + )}
@@ -175,14 +257,16 @@ const InfoHeader = ( } ): JSX.Element => (
-
+
{info.displayName}
open in VS Code {info.cfVscodeUri !== null && ( - + VS Code (CF tunnel) )} @@ -252,9 +336,7 @@ const TerminalView = ( ) }} onMessage={(msg) => { - setState((current) => - current._tag === "Terminal" ? { ...current, message: msg } : current - ) + setState((current) => current._tag === "Terminal" ? { ...current, message: msg } : current) }} session={session} /> @@ -306,7 +388,7 @@ const connectTerminalSession = ( Effect.sync(() => { setState((current) => current._tag === "Connecting" - ? { _tag: "Closed", closedMessage: String(error), info: current.info } + ? { _tag: "Closed", closedMessage: error, info: current.info } : current ) }) @@ -414,18 +496,19 @@ export const AppShareLink = ( const [state, setState] = useState({ _tag: "Loading" }) useEffect(() => { - let cancelled = false - const clientHost = `${window.location.hostname}${window.location.port !== "" ? `:${window.location.port}` : ""}` + let isCancelled = false + const portSuffix = location.port === "" ? "" : `:${location.port}` + const clientHost = `${location.hostname}${portSuffix}` void Effect.runPromise( loadShareLink(shareToken, clientHost).pipe( Effect.match({ onFailure: (message) => { - if (!cancelled) { + if (!isCancelled) { setState({ _tag: "Error", message }) } }, onSuccess: (info) => { - if (!cancelled) { + if (!isCancelled) { setState({ _tag: "Info", info }) } } @@ -433,7 +516,7 @@ export const AppShareLink = ( ) ) return () => { - cancelled = true + isCancelled = true } }, [shareToken]) diff --git a/packages/app/src/web/panel-share.tsx b/packages/app/src/web/panel-share.tsx index 074cafe1..45d1e54f 100644 --- a/packages/app/src/web/panel-share.tsx +++ b/packages/app/src/web/panel-share.tsx @@ -1,10 +1,10 @@ import { Effect } from "effect" -import { type JSX, useEffect, useState } from "react" +import { type JSX, useCallback, useEffect, useRef, useState } from "react" import { Box, Text } from "../ui/primitives.js" -import type { PanelCloudflareTunnelSession } from "./api.js" import type { ShareLinkInfo } from "./api-share-links.js" import { createProjectShareLink, deleteProjectShareLink, listProjectShareLinks } from "./api-share-links.js" +import type { PanelCloudflareTunnelSession } from "./api.js" type SharePanelProps = { readonly onCopyPublicUrl: (publicUrl: string) => void @@ -142,54 +142,67 @@ const ContainerShareLinksSection = ( { projectKey }: { readonly projectKey: string } ): JSX.Element => { const [state, setState] = useState({ _tag: "Idle" }) + const requestIdRef = useRef(0) - const refresh = () => { + const refresh = useCallback(() => { + const id = ++requestIdRef.current setState({ _tag: "Loading" }) void Effect.runPromise( listProjectShareLinks(projectKey).pipe( Effect.match({ - onFailure: (msg) => { setState({ _tag: "Error", message: msg }) }, + onFailure: (msg) => { + if (requestIdRef.current === id) setState({ _tag: "Error", message: msg }) + }, onSuccess: (links) => { + if (requestIdRef.current !== id) return setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) } }) ) ) - } + }, [projectKey]) - const generate = () => { + const generate = useCallback(() => { + const id = ++requestIdRef.current void Effect.runPromise( createProjectShareLink(projectKey).pipe( Effect.flatMap(({ url }) => listProjectShareLinks(projectKey).pipe( - Effect.map((links) => { setState({ _tag: "Loaded", links, newUrl: url }) }) + Effect.map((links) => { + if (requestIdRef.current === id) setState({ _tag: "Loaded", links, newUrl: url }) + }) ) ), Effect.catchAll((msg) => - Effect.sync(() => { setState({ _tag: "Error", message: String(msg) }) }) + Effect.sync(() => { + if (requestIdRef.current === id) setState({ _tag: "Error", message: msg }) + }) ) ) ) - } + }, [projectKey]) - const revoke = (token: string) => { + const revoke = useCallback((token: string) => { + const id = ++requestIdRef.current void Effect.runPromise( deleteProjectShareLink(projectKey, token).pipe( Effect.flatMap(() => listProjectShareLinks(projectKey)), Effect.match({ - onFailure: (msg) => { setState({ _tag: "Error", message: String(msg) }) }, + onFailure: (msg) => { + if (requestIdRef.current === id) setState({ _tag: "Error", message: msg }) + }, onSuccess: (links) => { + if (requestIdRef.current !== id) return setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) } }) ) ) - } + }, [projectKey]) useEffect(() => { refresh() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [projectKey]) + }, [refresh]) return ( @@ -213,33 +226,44 @@ const ContainerShareLinksSection = ( { - const url = state._tag === "Loaded" ? state.newUrl : null + onClick={async () => { + const url = state.newUrl if (url !== null) { - void navigator.clipboard.writeText(url).catch(() => {}) + try { + await navigator.clipboard.writeText(url) + } catch { + // ignore clipboard errors + } } }} /> { - const url = state._tag === "Loaded" ? state.newUrl : null - if (url !== null) openUrl(url) + const url = state.newUrl + if (url !== null) { + openUrl(url) + } }} /> )} - {state._tag === "Loaded" && state.links.length === 0 && ( - No active share links. - )} - {state._tag === "Loaded" && state.links.map((link) => ( - - {link.token} - exp {new Date(link.expiresAt).toLocaleDateString()} - { revoke(link.token) }} /> - - ))} + {state._tag === "Loaded" && state.links.length === 0 && No active share links.} + {state._tag === "Loaded" && + state.links.map((link) => ( + + {link.token} + exp {new Date(link.expiresAt).toLocaleDateString()} + { + revoke(link.token) + }} + /> + + ))} ) } From 0847b71a38a1d552356364a7277739bb22db5ad8 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sat, 20 Jun 2026 21:13:33 +0000 Subject: [PATCH 20/20] fix(ci): resolve all ESLint max-lines, max-params, and complexity violations - Extract shared style constants to app-share-link-utils.ts (eliminates max-lines in app-share-link-sections.tsx) - Split panel-vscode-access.tsx into hooks file + visual panel-vscode-access-panel.tsx - Refactor SshPasswordBlock to parseSshSnippet helper (fixes complexity) - Extract tryResolveShareLink from resolveWebAppRoute (fixes complexity in app-terminal-session-core.ts) - Group buildShareLinkSshAccess 7 params into ShareLinkSshAccessInput type (fixes max-params in ssh-access.ts) Co-Authored-By: Claude Sonnet 4.6 --- bun.lock | 262 +++++++++- packages/api/package.json | 2 +- packages/api/src/http.ts | 28 +- packages/app/src/web/api-share-links.ts | 4 +- .../app/src/web/app-ready-terminal-pane.tsx | 353 +------------ .../app/src/web/app-share-link-sections.tsx | 289 +++++++++++ packages/app/src/web/app-share-link-utils.ts | 46 ++ packages/app/src/web/app-share-link.tsx | 472 +++--------------- .../app/src/web/app-terminal-session-core.ts | 33 +- packages/app/src/web/app.tsx | 9 +- packages/app/src/web/panel-share.tsx | 223 +++++---- .../app/src/web/panel-vscode-access-panel.tsx | 231 +++++++++ packages/app/src/web/panel-vscode-access.tsx | 88 ++++ packages/lib/src/usecases/ssh-access.ts | 85 ++-- .../terminal/src/web/panel-terminal-types.ts | 50 +- packages/terminal/src/web/panel-terminal.tsx | 56 +-- 16 files changed, 1249 insertions(+), 982 deletions(-) create mode 100644 packages/app/src/web/app-share-link-sections.tsx create mode 100644 packages/app/src/web/app-share-link-utils.ts create mode 100644 packages/app/src/web/panel-vscode-access-panel.tsx create mode 100644 packages/app/src/web/panel-vscode-access.tsx diff --git a/bun.lock b/bun.lock index edacee9f..2515b40d 100644 --- a/bun.lock +++ b/bun.lock @@ -38,12 +38,12 @@ "globals": "^17.6.0", "typescript": "^6.0.3", "typescript-eslint": "^8.61.1", - "vitest": "^4.1.9", + "vitest": "^3.2.0", }, }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.3.10", + "version": "1.3.12", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -154,7 +154,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.66", + "version": "1.0.68", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -714,6 +714,56 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0", "", {}, "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.2", "", { "os": "android", "cpu": "arm" }, "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.2", "", { "os": "linux", "cpu": "arm" }, "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.2", "", { "os": "linux", "cpu": "none" }, "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.2", "", { "os": "none", "cpu": "arm64" }, "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.2", "", { "os": "win32", "cpu": "x64" }, "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA=="], + "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], "@sinclair/typebox": ["@sinclair/typebox@0.27.8", "", {}, "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA=="], @@ -850,19 +900,19 @@ "@vitest/eslint-plugin": ["@vitest/eslint-plugin@1.6.20", "", { "dependencies": { "@typescript-eslint/scope-manager": "^8.58.0", "@typescript-eslint/utils": "^8.58.0" }, "peerDependencies": { "@typescript-eslint/eslint-plugin": "*", "eslint": ">=8.57.0", "typescript": ">=5.0.0", "vitest": "*" }, "optionalPeers": ["@typescript-eslint/eslint-plugin", "typescript", "vitest"] }, "sha512-xRwWHFG0Utp6hXtbGiWk4VdKXCGdExD8kbWrrmFEiG5dk8anOJ+vbWbeOa8EbkocKQRTsx7JAWETccZiBgFp/Q=="], - "@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + "@vitest/expect": ["@vitest/expect@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ=="], - "@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + "@vitest/mocker": ["@vitest/mocker@3.2.6", "", { "dependencies": { "@vitest/spy": "3.2.6", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + "@vitest/pretty-format": ["@vitest/pretty-format@3.2.6", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA=="], - "@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + "@vitest/runner": ["@vitest/runner@3.2.6", "", { "dependencies": { "@vitest/utils": "3.2.6", "pathe": "^2.0.3", "strip-literal": "^3.0.0" } }, "sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + "@vitest/snapshot": ["@vitest/snapshot@3.2.6", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "magic-string": "^0.30.17", "pathe": "^2.0.3" } }, "sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw=="], - "@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + "@vitest/spy": ["@vitest/spy@3.2.6", "", { "dependencies": { "tinyspy": "^4.0.3" } }, "sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg=="], - "@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + "@vitest/utils": ["@vitest/utils@3.2.6", "", { "dependencies": { "@vitest/pretty-format": "3.2.6", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -940,6 +990,8 @@ "bytestreamjs": ["bytestreamjs@2.0.1", "", {}, "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "1.0.2", "es-define-property": "1.0.1", "get-intrinsic": "1.3.0", "set-function-length": "1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "1.3.0", "function-bind": "1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -952,7 +1004,7 @@ "canonicalize": ["canonicalize@2.1.0", "", { "bin": { "canonicalize": "bin/canonicalize.js" } }, "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ=="], - "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="], "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "4.3.0", "supports-color": "7.2.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], @@ -968,6 +1020,8 @@ "chardet": ["chardet@2.1.1", "", {}, "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="], + "check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="], + "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "2.1.0", "dom-serializer": "2.0.0", "domhandler": "5.0.3", "domutils": "3.2.2", "encoding-sniffer": "0.2.1", "htmlparser2": "10.0.0", "parse5": "7.3.0", "parse5-htmlparser2-tree-adapter": "7.1.0", "parse5-parser-stream": "7.1.2", "undici": "7.16.0", "whatwg-mimetype": "4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "1.0.0", "css-select": "5.2.2", "css-what": "6.2.2", "domelementtype": "2.3.0", "domhandler": "5.0.3", "domutils": "3.2.2" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -1028,6 +1082,8 @@ "dedent": ["dedent@1.7.0", "", {}, "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ=="], + "deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "1.0.1", "es-errors": "1.3.0", "gopd": "1.2.0" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], @@ -1464,6 +1520,8 @@ "loop-controls": ["loop-controls@1.1.0", "", {}, "sha512-otnxF3ngIuLecg99p7On7nJF6ws1mT2kNOiGOPFykEHQfhJtdsjcQMxM4LEHsUi3LeMrm2Ic0hFdykJcG0N1YQ=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], @@ -1608,6 +1666,8 @@ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -1714,6 +1774,8 @@ "rolldown": ["rolldown@1.0.3", "", { "dependencies": { "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.3", "@rolldown/binding-darwin-arm64": "1.0.3", "@rolldown/binding-darwin-x64": "1.0.3", "@rolldown/binding-freebsd-x64": "1.0.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", "@rolldown/binding-linux-arm64-gnu": "1.0.3", "@rolldown/binding-linux-arm64-musl": "1.0.3", "@rolldown/binding-linux-ppc64-gnu": "1.0.3", "@rolldown/binding-linux-s390x-gnu": "1.0.3", "@rolldown/binding-linux-x64-gnu": "1.0.3", "@rolldown/binding-linux-x64-musl": "1.0.3", "@rolldown/binding-openharmony-arm64": "1.0.3", "@rolldown/binding-wasm32-wasi": "1.0.3", "@rolldown/binding-win32-arm64-msvc": "1.0.3", "@rolldown/binding-win32-x64-msvc": "1.0.3" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g=="], + "rollup": ["rollup@4.62.2", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.2", "@rollup/rollup-android-arm64": "4.62.2", "@rollup/rollup-darwin-arm64": "4.62.2", "@rollup/rollup-darwin-x64": "4.62.2", "@rollup/rollup-freebsd-arm64": "4.62.2", "@rollup/rollup-freebsd-x64": "4.62.2", "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", "@rollup/rollup-linux-arm-musleabihf": "4.62.2", "@rollup/rollup-linux-arm64-gnu": "4.62.2", "@rollup/rollup-linux-arm64-musl": "4.62.2", "@rollup/rollup-linux-loong64-gnu": "4.62.2", "@rollup/rollup-linux-loong64-musl": "4.62.2", "@rollup/rollup-linux-ppc64-gnu": "4.62.2", "@rollup/rollup-linux-ppc64-musl": "4.62.2", "@rollup/rollup-linux-riscv64-gnu": "4.62.2", "@rollup/rollup-linux-riscv64-musl": "4.62.2", "@rollup/rollup-linux-s390x-gnu": "4.62.2", "@rollup/rollup-linux-x64-gnu": "4.62.2", "@rollup/rollup-linux-x64-musl": "4.62.2", "@rollup/rollup-openbsd-x64": "4.62.2", "@rollup/rollup-openharmony-arm64": "4.62.2", "@rollup/rollup-win32-arm64-msvc": "4.62.2", "@rollup/rollup-win32-ia32-msvc": "4.62.2", "@rollup/rollup-win32-x64-gnu": "4.62.2", "@rollup/rollup-win32-x64-msvc": "4.62.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "1.2.3" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safe-array-concat": ["safe-array-concat@1.1.3", "", { "dependencies": { "call-bind": "1.0.8", "call-bound": "1.0.4", "get-intrinsic": "1.3.0", "has-symbols": "1.1.0", "isarray": "2.0.5" } }, "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q=="], @@ -1782,7 +1844,7 @@ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], - "std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "1.3.0", "internal-slot": "1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], @@ -1808,6 +1870,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], + "structured-field-values": ["structured-field-values@2.0.4", "", {}, "sha512-5zpJXYLPwW3WYUD/D58tQjIBs10l3Yx64jZfcKGs/RH79E2t9Xm/b9+ydwdMNVSksnsIY+HR/2IlQmgo0AcTAg=="], "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], @@ -1820,11 +1884,15 @@ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], + + "tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="], + + "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], @@ -1894,9 +1962,11 @@ "vite": ["vite@8.0.16", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw=="], + "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-tsconfig-paths": ["vite-tsconfig-paths@6.1.1", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" } }, "sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg=="], - "vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "vitest": ["vitest@3.2.6", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.6", "@vitest/mocker": "3.2.6", "@vitest/pretty-format": "^3.2.6", "@vitest/runner": "3.2.6", "@vitest/snapshot": "3.2.6", "@vitest/spy": "3.2.6", "@vitest/utils": "3.2.6", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.6", "@vitest/ui": "3.2.6", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw=="], "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], @@ -1972,6 +2042,8 @@ "@digitalbazaar/http-client/undici": ["undici@6.25.0", "", {}, "sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg=="], + "@effect-template/lib/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@effect/experimental/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], "@effect/experimental/effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], @@ -2044,6 +2116,14 @@ "@prover-coder-ai/dist-deps-prune/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "@prover-coder-ai/docker-git/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@prover-coder-ai/docker-git-container/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@prover-coder-ai/docker-git-session-sync/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + + "@prover-coder-ai/docker-git-terminal/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@prover-coder-ai/eslint-plugin-suggest-members/@effect/platform-node": ["@effect/platform-node@0.106.0", "", { "dependencies": { "@effect/platform-node-shared": "0.59.0", "mime": "3.0.0", "undici": "7.16.0", "ws": "8.18.3" }, "peerDependencies": { "@effect/cluster": "0.58.0", "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "effect": "3.21.0" } }, "sha512-mpsJK2jNLVd0jQAjHKBo8j3wdKWznSGvfnKBcAuG/9Rr4mb8bMRZFLXHHT9wUP7EvnZ0tDZJgEDxkC+j+ByRag=="], "@prover-coder-ai/eslint-plugin-suggest-members/@typescript-eslint/utils": ["@typescript-eslint/utils@8.57.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.57.2", "@typescript-eslint/types": "8.57.2", "@typescript-eslint/typescript-estree": "8.57.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-krRIbvPK1ju1WBKIefiX+bngPs+odIQUtR7kymzPfo1POVw3jlF+nLkmexdSSd4UCbDcQn+wMBATOOmpBbqgKg=="], @@ -2072,6 +2152,14 @@ "@types/ws/@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="], + "@vitest/coverage-v8/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@vitest/coverage-v8/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@vitest/coverage-v8/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "@vitest/coverage-v8/vitest": ["vitest@4.1.9", "", { "dependencies": { "@vitest/expect": "4.1.9", "@vitest/mocker": "4.1.9", "@vitest/pretty-format": "4.1.9", "@vitest/runner": "4.1.9", "@vitest/snapshot": "4.1.9", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.9", "@vitest/browser-preview": "4.1.9", "@vitest/browser-webdriverio": "4.1.9", "@vitest/coverage-istanbul": "4.1.9", "@vitest/coverage-v8": "4.1.9", "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ=="], + "@vitest/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0" } }, "sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA=="], "@vitest/eslint-plugin/@typescript-eslint/utils": ["@typescript-eslint/utils@8.61.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", "@typescript-eslint/typescript-estree": "8.61.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA=="], @@ -2170,10 +2258,20 @@ "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "1.0.10", "esprima": "4.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "rollup/@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "tsconfig-paths/json5": ["json5@1.0.2", "", { "dependencies": { "minimist": "1.2.8" }, "bin": { "json5": "lib/cli.js" } }, "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="], + "vite-node/es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "vite-node/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], + + "vitest/vite": ["vite@7.3.5", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww=="], + "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": "2.1.2" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], @@ -2194,6 +2292,26 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@effect-template/lib/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@effect-template/lib/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@effect-template/lib/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@effect-template/lib/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@effect-template/lib/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@effect-template/lib/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@effect-template/lib/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@effect-template/lib/vitest/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@effect-template/lib/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "@effect-template/lib/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "@effect/experimental/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], "@effect/experimental/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], @@ -2226,6 +2344,10 @@ "@effect/vitest/vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "@effect/vitest/vitest/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@effect/vitest/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "@effect/vitest/vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "6.5.0", "picomatch": "4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "@effect/vitest/vitest/tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], @@ -2302,6 +2424,86 @@ "@prover-coder-ai/dist-deps-prune/effect/fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "@prover-coder-ai/docker-git-container/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@prover-coder-ai/docker-git-container/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@prover-coder-ai/docker-git-container/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@prover-coder-ai/docker-git-container/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@prover-coder-ai/docker-git-container/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@prover-coder-ai/docker-git-container/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@prover-coder-ai/docker-git-container/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@prover-coder-ai/docker-git-container/vitest/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@prover-coder-ai/docker-git-container/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "@prover-coder-ai/docker-git-container/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@prover-coder-ai/docker-git-terminal/vitest/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@prover-coder-ai/docker-git-terminal/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "@prover-coder-ai/docker-git-terminal/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/utils": ["@vitest/utils@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA=="], + + "@prover-coder-ai/docker-git/vitest/std-env": ["std-env@4.0.0", "", {}, "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ=="], + + "@prover-coder-ai/docker-git/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "@prover-coder-ai/docker-git/vitest/tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "@prover-coder-ai/eslint-plugin-suggest-members/@effect/platform-node/@effect/cluster": ["@effect/cluster@0.58.0", "", { "dependencies": { "kubernetes-types": "1.30.0" }, "peerDependencies": { "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "@effect/sql": "0.51.0", "@effect/workflow": "0.18.0", "effect": "3.21.0" } }, "sha512-0Zog7s7XdntWcTqdqWPoj6nc7hPaWIzp0k0DsFUWyCynXNPK9dAtgFrSce04NhddNqqbhtZck/lhuqJwNBrprQ=="], "@prover-coder-ai/eslint-plugin-suggest-members/@effect/platform-node/@effect/platform": ["@effect/platform@0.96.0", "", { "dependencies": { "find-my-way-ts": "0.1.6", "msgpackr": "1.11.5", "multipasta": "0.2.7" }, "peerDependencies": { "effect": "3.21.0" } }, "sha512-U7PLhkVzg7zzrgFvyWATOzD6reL87KG/fcdOxgLWBQ/J5CCU6qdPAVG+0o6o+IxcsLoqGwxs+rFxaFzrdtDV1A=="], @@ -2342,6 +2544,22 @@ "@types/ws/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@vitest/coverage-v8/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@vitest/coverage-v8/vitest/@vitest/expect": ["@vitest/expect@4.1.9", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.9", "@vitest/utils": "4.1.9", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA=="], + + "@vitest/coverage-v8/vitest/@vitest/mocker": ["@vitest/mocker@4.1.9", "", { "dependencies": { "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw=="], + + "@vitest/coverage-v8/vitest/@vitest/pretty-format": ["@vitest/pretty-format@4.1.9", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A=="], + + "@vitest/coverage-v8/vitest/@vitest/runner": ["@vitest/runner@4.1.9", "", { "dependencies": { "@vitest/utils": "4.1.9", "pathe": "^2.0.3" } }, "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg=="], + + "@vitest/coverage-v8/vitest/@vitest/snapshot": ["@vitest/snapshot@4.1.9", "", { "dependencies": { "@vitest/pretty-format": "4.1.9", "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA=="], + + "@vitest/coverage-v8/vitest/@vitest/spy": ["@vitest/spy@4.1.9", "", {}, "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA=="], + + "@vitest/coverage-v8/vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + "@vitest/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/types": ["@typescript-eslint/types@8.61.0", "", {}, "sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg=="], "@vitest/eslint-plugin/@typescript-eslint/scope-manager/@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.61.0", "", { "dependencies": { "@typescript-eslint/types": "8.61.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ=="], @@ -2458,6 +2676,8 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "@effect-template/lib/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "@effect/experimental/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "@effect/printer-ansi/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], @@ -2470,6 +2690,8 @@ "@effect/vitest/vitest/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "@effect/vitest/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "@effect/vitest/vitest/vite/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "3.3.11", "picocolors": "1.1.1", "source-map-js": "1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "@effect/vitest/vitest/vite/rolldown": ["rolldown@1.0.0-rc.10", "", { "dependencies": { "@oxc-project/types": "0.120.0", "@rolldown/pluginutils": "1.0.0-rc.10" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", "@rolldown/binding-darwin-x64": "1.0.0-rc.10", "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA=="], @@ -2498,6 +2720,14 @@ "@prover-coder-ai/dist-deps-prune/effect/fast-check/pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "@prover-coder-ai/docker-git-container/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "@prover-coder-ai/docker-git-session-sync/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "@prover-coder-ai/docker-git-terminal/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "@prover-coder-ai/docker-git/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "@prover-coder-ai/eslint-plugin-suggest-members/@effect/platform-node/@effect/cluster/@effect/workflow": ["@effect/workflow@0.18.0", "", { "peerDependencies": { "@effect/experimental": "0.60.0", "@effect/platform": "0.96.0", "@effect/rpc": "0.75.0", "effect": "3.21.0" } }, "sha512-9Zp+x9ADtR0H6CRhU6wLyPcIRjO1PXjvSpUlFlBQ8piw7ldjPmnUWEY8YQuH6eExV2dalQ4z2LMiZ5Bd7XAJbA=="], "@prover-coder-ai/eslint-plugin-suggest-members/@effect/platform-node/@effect/platform/msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "3.0.3" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="], @@ -2526,6 +2756,8 @@ "@ton-ai-core/vibecode-linter/jscpd/fs-extra/universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "@vitest/coverage-v8/vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.61.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.61.0", "@typescript-eslint/types": "^8.61.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA=="], "@vitest/eslint-plugin/@typescript-eslint/utils/@typescript-eslint/typescript-estree/@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.61.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ=="], diff --git a/packages/api/package.json b/packages/api/package.json index d2b9f357..7c025c90 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" } } diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 79f326da..4d3d4212 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -1146,15 +1146,15 @@ export const makeRouter = () => { ?? resolvePortPublicHost(request) ?? "localhost" const sshCfHostname = getSshShareLinkTunnelHostname(link.token) - const sshAccess = buildShareLinkSshAccess( - project.containerName, - project.sshUser, - project.sshPort, - null, - project.targetDir, + 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, @@ -1194,15 +1194,15 @@ export const makeRouter = () => { Effect.orElse(() => Effect.succeed(null)) ) ) - const sshAccess = buildShareLinkSshAccess( - project.containerName, - project.sshUser, - project.sshPort, - null, - project.targetDir, + 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, diff --git a/packages/app/src/web/api-share-links.ts b/packages/app/src/web/api-share-links.ts index 26625997..2f689e18 100644 --- a/packages/app/src/web/api-share-links.ts +++ b/packages/app/src/web/api-share-links.ts @@ -1,5 +1,5 @@ -import { Effect } from "effect" import * as Schema from "@effect/schema/Schema" +import { Effect } from "effect" import { requestJson } from "./api-http.js" @@ -50,7 +50,7 @@ export const createProjectShareLink = ( "POST", `/projects/by-key/${encodeURIComponent(projectKey)}/share-links`, CreateShareLinkResponseSchema, - ttlMs !== undefined ? { ttlMs } : {} + ttlMs === undefined ? {} : { ttlMs } ).pipe(Effect.map(({ link, url }) => ({ link, url }))) export const listProjectShareLinks = ( diff --git a/packages/app/src/web/app-ready-terminal-pane.tsx b/packages/app/src/web/app-ready-terminal-pane.tsx index 987c9470..ae9dd216 100644 --- a/packages/app/src/web/app-ready-terminal-pane.tsx +++ b/packages/app/src/web/app-ready-terminal-pane.tsx @@ -1,12 +1,19 @@ import { Effect } from "effect" -import { type CSSProperties, type JSX, useEffect, useState } from "react" +import { type CSSProperties, type JSX, useState } from "react" -import { startProjectSshTunnel } from "./api-share-links.js" import { deleteTerminalSessionByPath } from "./api.js" import { canOpenProjectBrowser } from "./app-ready-browser-openable.js" import { TerminalTaskManagerBody } from "./app-ready-terminal-task-manager.js" import type { TerminalPaneProps } from "./app-ready-terminal-types.js" import { TerminalPanel } from "./panel-terminal.js" +import { VsCodeAccessPanel } from "./panel-vscode-access-panel.js" +import { + buildVsCodeAccessInfo, + type CfTunnelState, + startTunnel, + useTunnelAutoStart, + useTunnelPolling +} from "./panel-vscode-access.js" import { type BrowserScreen, projectPickerScreen } from "./screen.js" import type { TerminalExitInfo } from "./terminal-panel-runtime-types.js" import { terminalSessionId } from "./terminal-state.js" @@ -151,270 +158,6 @@ const handleTerminalKill = (props: TerminalPaneProps, runtime: TerminalPaneRunti ) } -type VsCodeAccessInfo = { - readonly sshUser: string - readonly targetDir: string - readonly sshPort: number -} - -const buildVsCodeAccessInfo = (project: TerminalPaneProps["project"]): VsCodeAccessInfo | null => { - if (project === null) return null - return { sshUser: project.sshUser, targetDir: project.targetDir, sshPort: project.sshPort } -} - -type CfTunnelState = - | { readonly tag: "idle" } - | { readonly tag: "loading" } - | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } - | { readonly tag: "failed" } - -const hostSshConfig = (hostname: string, sshUser: string): string => - `Host ${hostname}\n User ${sshUser}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` - -const directSshConfig = (host: string, sshPort: number, sshUser: string): string => - `Host ${host}-ssh\n HostName ${host}\n Port ${sshPort}\n User ${sshUser}\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` - -const copyText = async (text: string): Promise => { - try { - await navigator.clipboard.writeText(text) - } catch { - // ignore clipboard errors - } -} - -const vsCodePanelCodeStyle: CSSProperties = { - background: "#0b1017", - border: "1px solid #2a3640", - borderRadius: "2px", - color: "#a8c8f0", - display: "block", - fontFamily: "inherit", - fontSize: "0.85em", - marginBottom: "4px", - marginTop: "4px", - overflowX: "auto", - padding: "6px 8px", - whiteSpace: "pre" -} - -const vsCodePanelCopyBtnStyle: CSSProperties = { - background: "transparent", - border: "none", - color: "#7fdfff", - cursor: "pointer", - font: "inherit", - fontSize: "0.85em", - fontWeight: "bold", - padding: "2px 6px" -} - -const VsCodeAccessPanel = ( - { - cfState, - info, - onClose, - onRefresh, - onRetry - }: { - readonly cfState: CfTunnelState - readonly info: VsCodeAccessInfo - readonly onClose: () => void - readonly onRefresh: () => void - readonly onRetry: () => void - } -): JSX.Element => { - const cfSshConfig = cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.sshUser) : null - const cfSshCommand = cfState.tag === "ready" - ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` - : null - const cfVscodeUri = cfState.tag === "ready" - ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ - encodeURIComponent(`${info.sshUser}@${cfState.hostname}`) - }&folder=${encodeURIComponent(info.targetDir)}` - : null - const directHost = location.hostname - const directConfig = directSshConfig(directHost, info.sshPort, info.sshUser) - const directCommand = String - .raw`ssh -p ${info.sshPort} -t ${info.sshUser}@${directHost} "cd ${info.targetDir} && exec \$SHELL"` - const directVscodeUri = `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ - encodeURIComponent(`${directHost}-ssh`) - }&folder=${encodeURIComponent(info.targetDir)}` - return ( -
-
-
VS Code / SSH access
-
- {cfState.tag === "ready" && ( - - )} - -
-
- - {cfState.tag === "loading" && ( -
Starting Cloudflare tunnel…
- )} - - {cfState.tag === "failed" && ( -
-
Tunnel failed to start.
- -
- )} - - {cfState.tag === "ready" && ( - <> -
Add to ~/.ssh/config
-
- requires cloudflared installed on your machine -
- {cfSshConfig} - - -
- Connect via SSH -
- {cfSshCommand} - - -
SSH password
- {cfState.sshPassword} - - - {cfVscodeUri !== null && ( - <> -
- Open in VS Code -
- - - )} - - )} - -
-
Direct SSH (local network)
- -
Add to ~/.ssh/config
-
no cloudflared needed — works on same LAN
- {directConfig} - - -
Connect via SSH
- {directCommand} - - - {cfState.tag === "ready" && ( - <> -
SSH password
- {cfState.sshPassword} - - - )} - -
Open in VS Code
-
requires config entry above in ~/.ssh/config
- -
- ) -} - const openBrowserAction = (props: TerminalPaneProps, runtime: TerminalPaneRuntime): (() => void) | undefined => { const projectId = runtime.browserProjectId return projectId === undefined || !runtime.canOpenBrowser @@ -498,75 +241,19 @@ const TerminalPanelForPane = ( /> ) -const startTunnel = ( - projectKey: string, - setCfState: (s: CfTunnelState) => void -): void => { - setCfState({ tag: "loading" }) - void Effect.runPromise( - startProjectSshTunnel(projectKey).pipe( - Effect.match({ - onFailure: () => { - setCfState({ tag: "failed" }) - }, - onSuccess: ({ hostname, sshPassword }) => { - setCfState( - hostname === null - ? { tag: "failed" } - : { tag: "ready", hostname, sshPassword } - ) - } - }) - ) - ) -} - export const TerminalPane = (props: TerminalPaneProps): JSX.Element => { const [isVsCodePanelOpen, setVsCodePanelOpen] = useState(false) const [cfState, setCfState] = useState({ tag: "idle" }) const runtime = resolveTerminalPaneRuntime(props) - - useEffect(() => { - if (!isVsCodePanelOpen || runtime.browserProjectKey === undefined) return - if (cfState.tag === "idle") { - startTunnel(runtime.browserProjectKey, setCfState) - } - }, [isVsCodePanelOpen, runtime.browserProjectKey, cfState.tag]) - - // Poll every 30s when panel is open: restart tunnel if process died - useEffect(() => { - if (!isVsCodePanelOpen || cfState.tag !== "ready" || runtime.browserProjectKey === undefined) return - const projectKey = runtime.browserProjectKey - let isCancelled = false - const id = setInterval(() => { - void Effect.runPromise( - startProjectSshTunnel(projectKey).pipe( - Effect.match({ - onFailure: () => { - if (!isCancelled) setCfState({ tag: "failed" }) - }, - onSuccess: ({ hostname, sshPassword }) => { - if (isCancelled) return - if (hostname === null) { - setCfState({ tag: "failed" }) - return - } - setCfState({ tag: "ready", hostname, sshPassword }) - } - }) - ) - ) - }, 30_000) - return () => { - isCancelled = true - clearInterval(id) - } - }, [isVsCodePanelOpen, cfState.tag, runtime.browserProjectKey]) - + useTunnelAutoStart(isVsCodePanelOpen, cfState, runtime.browserProjectKey, setCfState) + useTunnelPolling(isVsCodePanelOpen, cfState, runtime.browserProjectKey, setCfState) const vsCodeInfo = buildVsCodeAccessInfo(props.project) const onOpenVsCode = vsCodeInfo === null ? undefined : () => { setVsCodePanelOpen(true) } + const refresh = (): void => { + if (runtime.browserProjectKey !== undefined) startTunnel(runtime.browserProjectKey, setCfState) + } const vsCodeBodyContent = isVsCodePanelOpen && vsCodeInfo !== null ? ( { onClose={() => { setVsCodePanelOpen(false) }} - onRefresh={() => { - if (runtime.browserProjectKey !== undefined) { - startTunnel(runtime.browserProjectKey, setCfState) - } - }} - onRetry={() => { - if (runtime.browserProjectKey !== undefined) { - startTunnel(runtime.browserProjectKey, setCfState) - } - }} + onRefresh={refresh} + onRetry={refresh} /> ) : undefined diff --git a/packages/app/src/web/app-share-link-sections.tsx b/packages/app/src/web/app-share-link-sections.tsx new file mode 100644 index 00000000..3053556b --- /dev/null +++ b/packages/app/src/web/app-share-link-sections.tsx @@ -0,0 +1,289 @@ +import { Effect } from "effect" +import { type CSSProperties, type Dispatch, type JSX, type SetStateAction } from "react" + +import type { ShareLinkInfo } from "./api-share-links.js" +import { deleteTerminalSessionByPath } from "./api.js" +import { buttonStyle, codeBlockStyle, copyText, vscodeLinkStyle } from "./app-share-link-utils.js" +import { TerminalPanel } from "./panel-terminal.js" +import { type ActiveTerminalSession } from "./terminal.js" +import type { ViewportLayout } from "./viewport-layout.js" + +export { buttonStyle, centeredBoxStyle, codeBlockStyle, copyText, vscodeLinkStyle } from "./app-share-link-utils.js" + +export type ShareLinkState = + | { readonly _tag: "Loading" } + | { readonly _tag: "Error"; readonly message: string } + | { readonly _tag: "Info"; readonly info: ShareLinkInfo } + | { readonly _tag: "Connecting"; readonly info: ShareLinkInfo } + | { + readonly _tag: "Terminal" + readonly info: ShareLinkInfo + readonly session: ActiveTerminalSession + readonly message: string | null + } + | { readonly _tag: "Closed"; readonly info: ShareLinkInfo; readonly closedMessage: string } + +const sshPasswordBlockStyle: CSSProperties = { + background: "#0d1a14", + border: "1px solid #2a5a38", + borderRadius: "3px", + marginTop: "8px", + padding: "8px" +} + +const terminalAreaStyle: CSSProperties = { + display: "flex", + flex: 1, + flexDirection: "column", + minHeight: 0, + overflow: "hidden" +} + +export const SshConfigBlock = ( + { label, snippet }: { readonly label: string; readonly snippet: string } +): JSX.Element => ( +
+
{label}
+ {snippet} + +
+) + +const WILDCARD_SSH_CONFIG = `Host *.trycloudflare.com + ProxyCommand cloudflared access ssh --hostname %h + StrictHostKeyChecking no + UserKnownHostsFile /dev/null` + +const CfSshConnectSection = ( + { cfHostname }: { readonly cfHostname: string } +): JSX.Element => ( + <> +
After setup, connect to any container:
+ {`ssh dev@${cfHostname}`} + +
+ Requires{" "} + + cloudflared + {" "} + installed on your machine +
+ +) + +export const CfTunnelSetupBlock = ( + { cfHostname }: { readonly cfHostname: string | null } +): JSX.Element | null => { + if (cfHostname === null) return null + return ( +
+
+
One-time setup
+
+ add once to ~/.ssh/config — works for all share links +
+
+ {WILDCARD_SSH_CONFIG} + + +
+ ) +} + +const SshDirectLanSection = ( + { directCmd }: { readonly directCmd: string } +): JSX.Element | null => + directCmd === "" + ? null + : ( + <> +
Direct (LAN):
+ {directCmd} + + + ) + +const parseSshSnippet = (snippet: string): { hostname: string; port: string; user: string } => ({ + hostname: /HostName\s+(\S+)/.exec(snippet)?.[1] ?? "host", + port: /Port\s+(\d+)/.exec(snippet)?.[1] ?? "22", + user: /User\s+(\S+)/.exec(snippet)?.[1] ?? "dev" +}) + +export const SshPasswordBlock = ( + { info }: { readonly info: ShareLinkInfo } +): JSX.Element | null => { + if (info.sshPassword === null) return null + const { hostname, port, user } = parseSshSnippet(info.sshConfigSnippet) + const directCmd = hostname === "localhost" ? "" : `ssh ${user}@${hostname} -p ${port}` + return ( +
+
SSH password
+
+ {info.sshPassword} + +
+ +
+ ) +} + +export const InfoHeader = ( + { + info, + isConnecting, + onConnect + }: { + readonly info: ShareLinkInfo + readonly isConnecting: boolean + readonly onConnect: () => void + } +): JSX.Element => ( +
+
+
{info.displayName}
+
+ open in VS Code + {info.cfVscodeUri !== null && ( + VS Code (CF tunnel) + )} + +
+
+ + + +
+ expires {new Date(info.expiresAt).toLocaleString()} +
+
+) + +export const TerminalView = ( + { + message, + session, + setState, + viewport + }: { + readonly message: string | null + readonly session: ActiveTerminalSession + readonly setState: Dispatch> + readonly viewport: ViewportLayout + } +): JSX.Element => ( +
+ {message !== null && ( +
{message}
+ )} + { + setState((current) => + current._tag === "Terminal" + ? { _tag: "Closed", closedMessage: "Terminal attach failed.", info: current.info } + : current + ) + }} + onDetach={() => { + setState((current) => current._tag === "Terminal" ? { _tag: "Info", info: current.info } : current) + }} + onKill={() => { + void Effect.runPromise( + deleteTerminalSessionByPath(session.closePath).pipe(Effect.either, Effect.asVoid) + ) + setState((current) => current._tag === "Terminal" ? { _tag: "Info", info: current.info } : current) + }} + onMessage={(msg) => { + setState((current) => current._tag === "Terminal" ? { ...current, message: msg } : current) + }} + session={session} + /> +
+) + +export const PlaceholderArea = ({ children }: { readonly children: JSX.Element }): JSX.Element => ( +
+ {children} +
+) diff --git a/packages/app/src/web/app-share-link-utils.ts b/packages/app/src/web/app-share-link-utils.ts new file mode 100644 index 00000000..0885c894 --- /dev/null +++ b/packages/app/src/web/app-share-link-utils.ts @@ -0,0 +1,46 @@ +import { type CSSProperties } from "react" + +export const codeBlockStyle: CSSProperties = { + background: "#0b1017", + border: "1px solid #2a3640", + borderRadius: "2px", + color: "#a8c8f0", + display: "block", + fontFamily: "inherit", + fontSize: "0.85em", + marginBottom: "4px", + marginTop: "4px", + overflowX: "auto", + padding: "6px 8px", + whiteSpace: "pre" +} + +export const buttonStyle: CSSProperties = { + background: "transparent", + border: "none", + color: "#56f39a", + cursor: "pointer", + font: "inherit", + fontWeight: "bold", + padding: "2px 6px" +} + +export const vscodeLinkStyle: CSSProperties = { + color: "#56f39a", + cursor: "pointer", + fontFamily: "inherit", + fontSize: "inherit", + fontWeight: "bold", + padding: "2px 6px", + textDecoration: "none" +} + +export const centeredBoxStyle: CSSProperties = { + border: "1px solid #3a4652", + borderRadius: "4px", + color: "#d6e5f7", + padding: "16px 24px", + textAlign: "center" +} + +export const copyText = (text: string): void => void navigator.clipboard.writeText(text) diff --git a/packages/app/src/web/app-share-link.tsx b/packages/app/src/web/app-share-link.tsx index 642b21a8..edabe9e0 100644 --- a/packages/app/src/web/app-share-link.tsx +++ b/packages/app/src/web/app-share-link.tsx @@ -1,33 +1,19 @@ import { Effect, Match } from "effect" import { type CSSProperties, type Dispatch, type JSX, type SetStateAction, useEffect, useState } from "react" -import type { ShareLinkInfo } from "./api-share-links.js" import { loadShareLink } from "./api-share-links.js" -import { createProjectTerminalSession, deleteTerminalSessionByPath } from "./api.js" -import { TerminalPanel } from "./panel-terminal.js" -import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" +import type { ShareLinkInfo } from "./api-share-links.js" +import { createProjectTerminalSession } from "./api.js" +import { + centeredBoxStyle, + InfoHeader, + PlaceholderArea, + type ShareLinkState, + TerminalView +} from "./app-share-link-sections.js" +import { buildProjectActiveTerminalSession } from "./terminal.js" import type { ViewportLayout } from "./viewport-layout.js" -// CHANGE: standalone share link page – validates token, shows SSH config and web terminal -// WHY: share links must work without dashboard state; token provides the authorization -// QUOTE(ТЗ): "принимает ссылку на любой IP который стоит у пользователя в URL" -// REF: issue-428 -// PURITY: SHELL (React component with effects) -// INVARIANT: token is only accepted when it matches the 16-hex share format - -type ShareLinkState = - | { readonly _tag: "Loading" } - | { readonly _tag: "Error"; readonly message: string } - | { readonly _tag: "Info"; readonly info: ShareLinkInfo } - | { readonly _tag: "Connecting"; readonly info: ShareLinkInfo } - | { - readonly _tag: "Terminal" - readonly info: ShareLinkInfo - readonly session: ActiveTerminalSession - readonly message: string | null - } - | { readonly _tag: "Closed"; readonly info: ShareLinkInfo; readonly closedMessage: string } - export type AppShareLinkProps = { readonly projectKey: string readonly shareToken: string @@ -42,321 +28,6 @@ const containerStyle: CSSProperties = { padding: "8px" } -const headerStyle: CSSProperties = { - background: "#101419", - border: "1px solid #3a4652", - borderRadius: "4px", - flexShrink: 0, - marginBottom: "8px", - overflowY: "auto", - padding: "8px" -} - -const terminalAreaStyle: CSSProperties = { - display: "flex", - flex: 1, - flexDirection: "column", - minHeight: 0, - overflow: "hidden" -} - -const buttonStyle: CSSProperties = { - background: "transparent", - border: "none", - color: "#56f39a", - cursor: "pointer", - font: "inherit", - fontWeight: "bold", - padding: "2px 6px" -} - -const codeBlockStyle: CSSProperties = { - background: "#0b1017", - border: "1px solid #2a3640", - borderRadius: "2px", - color: "#a8c8f0", - display: "block", - fontFamily: "inherit", - fontSize: "0.85em", - marginBottom: "4px", - marginTop: "4px", - overflowX: "auto", - padding: "6px 8px", - whiteSpace: "pre" -} - -const copyText = async (text: string): Promise => { - try { - await navigator.clipboard.writeText(text) - } catch { - // ignore clipboard errors - } -} - -const vscodeLinkStyle: CSSProperties = { - color: "#56f39a", - cursor: "pointer", - fontFamily: "inherit", - fontSize: "inherit", - fontWeight: "bold", - padding: "2px 6px", - textDecoration: "none" -} - -const SshConfigBlock = ( - { label, snippet }: { readonly label: string; readonly snippet: string } -): JSX.Element => ( -
-
{label}
- {snippet} - -
-) - -const WILDCARD_SSH_CONFIG = `Host *.trycloudflare.com - ProxyCommand cloudflared access ssh --hostname %h - StrictHostKeyChecking no - UserKnownHostsFile /dev/null` - -const CfTunnelSetupBlock = ( - { cfHostname }: { readonly cfHostname: string | null } -): JSX.Element | null => { - if (cfHostname === null) return null - return ( -
-
-
One-time setup
-
- add once to ~/.ssh/config — works for all share links -
-
- {WILDCARD_SSH_CONFIG} - -
- After setup, connect to any container: -
- {`ssh dev@${cfHostname}`} - -
- Requires{" "} - - cloudflared - {" "} - installed on your machine -
-
- ) -} - -const SshPasswordBlock = ( - { info }: { readonly info: ShareLinkInfo } -): JSX.Element | null => { - if (info.sshPassword === null) return null - const sshHostname = /HostName\s+(\S+)/.exec(info.sshConfigSnippet)?.[1] ?? "host" - const sshPort = /Port\s+(\d+)/.exec(info.sshConfigSnippet)?.[1] ?? "22" - const sshUser = /User\s+(\S+)/.exec(info.sshConfigSnippet)?.[1] ?? "dev" - const directCmd = `ssh ${sshUser}@${sshHostname} -p ${sshPort}` - return ( -
-
SSH password
-
- - {info.sshPassword} - - -
- {sshHostname !== "localhost" && ( - <> -
Direct (LAN):
- {directCmd} - - - )} -
- ) -} - -const InfoHeader = ( - { - info, - isConnecting, - onConnect - }: { - readonly info: ShareLinkInfo - readonly isConnecting: boolean - readonly onConnect: () => void - } -): JSX.Element => ( -
-
-
{info.displayName}
-
- - open in VS Code - - {info.cfVscodeUri !== null && ( - - VS Code (CF tunnel) - - )} - -
-
- - - -
- expires {new Date(info.expiresAt).toLocaleString()} -
-
-) - -const TerminalView = ( - { - message, - session, - setState, - viewport - }: { - readonly message: string | null - readonly session: ActiveTerminalSession - readonly setState: Dispatch> - readonly viewport: ViewportLayout - } -): JSX.Element => ( -
- {message !== null && ( -
- {message} -
- )} - { - setState((current) => - current._tag === "Terminal" - ? { _tag: "Closed", closedMessage: "Terminal attach failed.", info: current.info } - : current - ) - }} - onDetach={() => { - setState((current) => - current._tag === "Terminal" - ? { _tag: "Info", info: current.info } - : current - ) - }} - onKill={() => { - void Effect.runPromise( - deleteTerminalSessionByPath(session.closePath).pipe(Effect.either, Effect.asVoid) - ) - setState((current) => - current._tag === "Terminal" - ? { _tag: "Info", info: current.info } - : current - ) - }} - onMessage={(msg) => { - setState((current) => current._tag === "Terminal" ? { ...current, message: msg } : current) - }} - session={session} - /> -
-) - -const PlaceholderArea = ({ children }: { readonly children: JSX.Element }): JSX.Element => ( -
- {children} -
-) - -const centeredBoxStyle: CSSProperties = { - border: "1px solid #3a4652", - borderRadius: "4px", - color: "#d6e5f7", - padding: "16px 24px", - textAlign: "center" -} - const connectTerminalSession = ( projectKey: string, info: ShareLinkInfo, @@ -367,11 +38,7 @@ const connectTerminalSession = ( Effect.map(({ session }) => { const activeSession = buildProjectActiveTerminalSession({ onExit: () => { - setState((current) => - current._tag === "Terminal" - ? { _tag: "Info", info: current.info } - : current - ) + setState((current) => current._tag === "Terminal" ? { _tag: "Info", info: current.info } : current) }, projectDisplayName: info.displayName, projectId: session.projectId, @@ -397,6 +64,55 @@ const connectTerminalSession = ( ) } +const renderInfoCase = ( + info: ShareLinkInfo, + projectKey: string, + setState: Dispatch> +): JSX.Element => ( +
+ { + setState({ _tag: "Connecting", info }) + connectTerminalSession(projectKey, info, setState) + }} + /> + +
+
Add the one-time setup to ~/.ssh/config
+
+ then click VS Code (CF tunnel) to connect from anywhere +
+
+
+
+) + +const renderClosedCase = ( + info: ShareLinkInfo, + closedMessage: string, + projectKey: string, + setState: Dispatch> +): JSX.Element => ( +
+ { + setState({ _tag: "Connecting", info }) + connectTerminalSession(projectKey, info, setState) + }} + /> + +
+
Session ended
+
{closedMessage}
+
+
+
+) + const renderState = ( state: ShareLinkState, setState: Dispatch>, @@ -420,33 +136,10 @@ const renderState = (
)), - Match.when({ _tag: "Info" }, ({ info }) => ( -
- { - setState({ _tag: "Connecting", info }) - connectTerminalSession(projectKey, info, setState) - }} - /> - -
-
Add the one-time setup to ~/.ssh/config
-
- then click VS Code (CF tunnel) to connect from anywhere -
-
-
-
- )), + Match.when({ _tag: "Info" }, ({ info }) => renderInfoCase(info, projectKey, setState)), Match.when({ _tag: "Connecting" }, ({ info }) => (
- {}} - /> + {}} />
Starting SSH terminal session…
@@ -456,37 +149,12 @@ const renderState = ( )), Match.when({ _tag: "Terminal" }, ({ info, message, session }) => (
- {}} - /> - -
- )), - Match.when({ _tag: "Closed" }, ({ closedMessage, info }) => ( -
- { - setState({ _tag: "Connecting", info }) - connectTerminalSession(projectKey, info, setState) - }} - /> - -
-
Session ended
-
{closedMessage}
-
-
+ {}} /> +
)), + Match.when({ _tag: "Closed" }, ({ closedMessage, info }) => + renderClosedCase(info, closedMessage, projectKey, setState)), Match.exhaustive ) @@ -503,14 +171,10 @@ export const AppShareLink = ( loadShareLink(shareToken, clientHost).pipe( Effect.match({ onFailure: (message) => { - if (!isCancelled) { - setState({ _tag: "Error", message }) - } + if (!isCancelled) setState({ _tag: "Error", message }) }, onSuccess: (info) => { - if (!isCancelled) { - setState({ _tag: "Info", info }) - } + if (!isCancelled) setState({ _tag: "Info", info }) } }) ) diff --git a/packages/app/src/web/app-terminal-session-core.ts b/packages/app/src/web/app-terminal-session-core.ts index 265a91a2..21cc79cc 100644 --- a/packages/app/src/web/app-terminal-session-core.ts +++ b/packages/app/src/web/app-terminal-session-core.ts @@ -1,3 +1,5 @@ +import { Effect } from "effect" + import type { ProjectTerminalSessionLookup } from "./api.js" import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" @@ -27,24 +29,23 @@ export const readTerminalSessionRoute = (pathname: string): string | null => { // COMPLEXITY: O(1) const isShareToken = (value: string): boolean => /^[0-9a-f]{16}$/u.test(value) -const safeDecodeSegment = (value: string): string | null => { - try { - return decodeURIComponent(value) - } catch { - return null - } +const safeDecodeSegment = (value: string): string | null => + Effect.runSync( + Effect.try(() => decodeURIComponent(value)).pipe( + Effect.catchAll(() => Effect.succeed(null)) + ) + ) + +const tryResolveShareLink = (pathname: string, t: string): WebAppRoute | null => { + const rawKey = pathname.slice("/ssh/".length).split("/", 1)[0] ?? "" + const projectKey = safeDecodeSegment(rawKey)?.trim() ?? "" + return projectKey.length > 0 && isShareToken(t) ? { tag: "ShareLink", projectKey, shareToken: t } : null } -export const resolveWebAppRoute = (pathname: string, search: string = ""): WebAppRoute => { - if (pathname.startsWith("/ssh/")) { - const rawKey = pathname.slice("/ssh/".length).split("/")[0] ?? "" - const projectKey = safeDecodeSegment(rawKey)?.trim() ?? "" - const t = new URLSearchParams(search).get("t") ?? "" - if (projectKey.length > 0 && isShareToken(t)) { - return { tag: "ShareLink", projectKey, shareToken: t } - } - } - return { tag: "Dashboard" } +export const resolveWebAppRoute = (pathname: string, search = ""): WebAppRoute => { + if (!pathname.startsWith("/ssh/")) return { tag: "Dashboard" } + const t = new URLSearchParams(search).get("t") ?? "" + return tryResolveShareLink(pathname, t) ?? { tag: "Dashboard" } } export const buildTerminalOnlyActiveSession = ( diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index fbb8aa9d..19eaec4f 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -228,9 +228,12 @@ export const App = (): JSX.Element => { {Match.value(route).pipe( Match.when({ tag: "Dashboard" }, () => ), - Match.when({ tag: "ShareLink" }, ({ projectKey, shareToken }) => ( - - )), + Match.when( + { tag: "ShareLink" }, + ({ projectKey, shareToken }) => ( + + ) + ), Match.exhaustive )} diff --git a/packages/app/src/web/panel-share.tsx b/packages/app/src/web/panel-share.tsx index 45d1e54f..17c04ff2 100644 --- a/packages/app/src/web/panel-share.tsx +++ b/packages/app/src/web/panel-share.tsx @@ -1,5 +1,5 @@ import { Effect } from "effect" -import { type JSX, useCallback, useEffect, useRef, useState } from "react" +import { type Dispatch, type JSX, type SetStateAction, useCallback, useEffect, useRef, useState } from "react" import { Box, Text } from "../ui/primitives.js" import type { ShareLinkInfo } from "./api-share-links.js" @@ -129,81 +129,138 @@ const MaybeTunnelError = ( ? null : {tunnel.error} -const tunnelLogTail = (tunnel: PanelCloudflareTunnelSession | null): ReadonlyArray => - tunnel === null ? [] : tunnel.logTail - type ShareLinksState = | { readonly _tag: "Idle" } | { readonly _tag: "Loading" } | { readonly _tag: "Loaded"; readonly links: ReadonlyArray; readonly newUrl: string | null } | { readonly _tag: "Error"; readonly message: string } +const runRefreshLinks = ( + projectKey: string, + id: number, + idRef: { readonly current: number }, + setState: Dispatch> +): void => { + setState({ _tag: "Loading" }) + void Effect.runPromise( + listProjectShareLinks(projectKey).pipe( + Effect.match({ + onFailure: (msg) => { + if (idRef.current === id) setState({ _tag: "Error", message: msg }) + }, + onSuccess: (links) => { + if (idRef.current !== id) return + setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) + } + }) + ) + ) +} + +const runGenerateLink = ( + projectKey: string, + id: number, + idRef: { readonly current: number }, + setState: Dispatch> +): void => { + void Effect.runPromise( + createProjectShareLink(projectKey).pipe( + Effect.flatMap(({ url }) => + listProjectShareLinks(projectKey).pipe( + Effect.map((links) => { + if (idRef.current === id) setState({ _tag: "Loaded", links, newUrl: url }) + }) + ) + ), + Effect.catchAll((msg) => + Effect.sync(() => { + if (idRef.current === id) setState({ _tag: "Error", message: msg }) + }) + ) + ) + ) +} + +const runRevokeLink = ( + projectKey: string, + token: string, + id: number, + idRef: { readonly current: number }, + setState: Dispatch> +): void => { + void Effect.runPromise( + deleteProjectShareLink(projectKey, token).pipe( + Effect.flatMap(() => listProjectShareLinks(projectKey)), + Effect.match({ + onFailure: (msg) => { + if (idRef.current === id) setState({ _tag: "Error", message: msg }) + }, + onSuccess: (links) => { + if (idRef.current !== id) return + setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) + } + }) + ) + ) +} + +const ShareLinkNewBanner = ( + { newUrl }: { readonly newUrl: string } +): JSX.Element => ( + + New share link + {newUrl} + + { + void navigator.clipboard.writeText(newUrl) + }} + /> + { + openUrl(newUrl) + }} + /> + + +) + +const ShareLinkRow = ( + { link, onRevoke }: { readonly link: ShareLinkInfo; readonly onRevoke: (token: string) => void } +): JSX.Element => ( + + {link.token} + exp {new Date(link.expiresAt).toLocaleDateString()} + { + onRevoke(link.token) + }} + /> + +) + const ContainerShareLinksSection = ( { projectKey }: { readonly projectKey: string } ): JSX.Element => { const [state, setState] = useState({ _tag: "Idle" }) - const requestIdRef = useRef(0) - + const idRef = useRef(0) const refresh = useCallback(() => { - const id = ++requestIdRef.current - setState({ _tag: "Loading" }) - void Effect.runPromise( - listProjectShareLinks(projectKey).pipe( - Effect.match({ - onFailure: (msg) => { - if (requestIdRef.current === id) setState({ _tag: "Error", message: msg }) - }, - onSuccess: (links) => { - if (requestIdRef.current !== id) return - setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) - } - }) - ) - ) + runRefreshLinks(projectKey, ++idRef.current, idRef, setState) }, [projectKey]) - const generate = useCallback(() => { - const id = ++requestIdRef.current - void Effect.runPromise( - createProjectShareLink(projectKey).pipe( - Effect.flatMap(({ url }) => - listProjectShareLinks(projectKey).pipe( - Effect.map((links) => { - if (requestIdRef.current === id) setState({ _tag: "Loaded", links, newUrl: url }) - }) - ) - ), - Effect.catchAll((msg) => - Effect.sync(() => { - if (requestIdRef.current === id) setState({ _tag: "Error", message: msg }) - }) - ) - ) - ) + runGenerateLink(projectKey, ++idRef.current, idRef, setState) }, [projectKey]) - const revoke = useCallback((token: string) => { - const id = ++requestIdRef.current - void Effect.runPromise( - deleteProjectShareLink(projectKey, token).pipe( - Effect.flatMap(() => listProjectShareLinks(projectKey)), - Effect.match({ - onFailure: (msg) => { - if (requestIdRef.current === id) setState({ _tag: "Error", message: msg }) - }, - onSuccess: (links) => { - if (requestIdRef.current !== id) return - setState((s) => ({ _tag: "Loaded", links, newUrl: s._tag === "Loaded" ? s.newUrl : null })) - } - }) - ) - ) + runRevokeLink(projectKey, token, ++idRef.current, idRef, setState) }, [projectKey]) - useEffect(() => { refresh() }, [refresh]) - return ( @@ -213,57 +270,13 @@ const ContainerShareLinksSection = ( - - Share links give time-limited SSH and terminal access to this container. - + Share links give time-limited SSH and terminal access to this container. {state._tag === "Loading" && Loading…} {state._tag === "Error" && {state.message}} - {state._tag === "Loaded" && state.newUrl !== null && ( - - New share link - {state.newUrl} - - { - const url = state.newUrl - if (url !== null) { - try { - await navigator.clipboard.writeText(url) - } catch { - // ignore clipboard errors - } - } - }} - /> - { - const url = state.newUrl - if (url !== null) { - openUrl(url) - } - }} - /> - - - )} + {state._tag === "Loaded" && state.newUrl !== null && } {state._tag === "Loaded" && state.links.length === 0 && No active share links.} {state._tag === "Loaded" && - state.links.map((link) => ( - - {link.token} - exp {new Date(link.expiresAt).toLocaleDateString()} - { - revoke(link.token) - }} - /> - - ))} + state.links.map((link) => )} ) } @@ -298,7 +311,7 @@ export const SharePanel = ( - + {selectedProjectKey !== null && } ) diff --git a/packages/app/src/web/panel-vscode-access-panel.tsx b/packages/app/src/web/panel-vscode-access-panel.tsx new file mode 100644 index 00000000..c02f480f --- /dev/null +++ b/packages/app/src/web/panel-vscode-access-panel.tsx @@ -0,0 +1,231 @@ +import { type CSSProperties, type JSX } from "react" + +import type { CfTunnelState, VsCodeAccessInfo } from "./panel-vscode-access.js" + +type VsCodeAccessPanelProps = { + readonly cfState: CfTunnelState + readonly info: VsCodeAccessInfo + readonly onClose: () => void + readonly onRefresh: () => void + readonly onRetry: () => void +} + +type CfReadyDetailsProps = { + readonly cfSshConfig: string + readonly cfSshCommand: string + readonly cfVscodeUri: string | null + readonly sshPassword: string +} + +type DirectSshSectionProps = { + readonly cfReadyPassword: string | null + readonly directCommand: string + readonly directConfig: string + readonly directVscodeUri: string +} + +type VsCodeAccessValues = { + readonly cfSshConfig: string | null + readonly cfSshCommand: string | null + readonly cfVscodeUri: string | null + readonly directConfig: string + readonly directCommand: string + readonly directVscodeUri: string +} + +const hostSshConfig = (hostname: string, sshUser: string): string => + `Host ${hostname}\n User ${sshUser}\n ProxyCommand cloudflared access ssh --hostname %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` + +const directSshConfig = (host: string, sshPort: number, sshUser: string): string => + `Host ${host}-ssh\n HostName ${host}\n Port ${sshPort}\n User ${sshUser}\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null` + +const copyText = (text: string): void => { + void navigator.clipboard.writeText(text) +} + +const codeStyle: CSSProperties = { + background: "#0b1017", + border: "1px solid #2a3640", + borderRadius: "2px", + color: "#a8c8f0", + display: "block", + fontFamily: "inherit", + fontSize: "0.85em", + marginBottom: "4px", + marginTop: "4px", + overflowX: "auto", + padding: "6px 8px", + whiteSpace: "pre" +} + +const copyBtnStyle: CSSProperties = { + background: "transparent", + border: "none", + color: "#7fdfff", + cursor: "pointer", + font: "inherit", + fontSize: "0.85em", + fontWeight: "bold", + padding: "2px 6px" +} + +const linkStyle: CSSProperties = { + color: "#56f39a", + cursor: "pointer", + fontFamily: "inherit", + fontSize: "inherit", + fontWeight: "bold", + textDecoration: "none" +} + +const panelOuterStyle: CSSProperties = { + background: "#0d1520", + border: "1px solid #2a4060", + borderRadius: "4px", + boxSizing: "border-box", + height: "100%", + overflowY: "auto", + padding: "12px 16px" +} + +const panelHeaderStyle: CSSProperties = { + alignItems: "center", + display: "flex", + justifyContent: "space-between", + marginBottom: "10px" +} + +const buildVsCodeAccessValues = (cfState: CfTunnelState, info: VsCodeAccessInfo): VsCodeAccessValues => { + const directHost = location.hostname + const cfSshCommand = cfState.tag === "ready" + ? `ssh -o "ProxyCommand=cloudflared access ssh --hostname %h" ${info.sshUser}@${cfState.hostname}` + : null + const cfVscodeUri = cfState.tag === "ready" + ? `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ + encodeURIComponent(`${info.sshUser}@${cfState.hostname}`) + }&folder=${encodeURIComponent(info.targetDir)}` + : null + return { + cfSshConfig: cfState.tag === "ready" ? hostSshConfig(cfState.hostname, info.sshUser) : null, + cfSshCommand, + cfVscodeUri, + directConfig: directSshConfig(directHost, info.sshPort, info.sshUser), + directCommand: String + .raw`ssh -p ${info.sshPort} -t ${info.sshUser}@${directHost} "cd ${info.targetDir} && exec \$SHELL"`, + directVscodeUri: `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ + encodeURIComponent(`${directHost}-ssh`) + }&folder=${encodeURIComponent(info.targetDir)}` + } +} + +const CodeCopyRow = ({ text }: { readonly text: string }): JSX.Element => ( + <> + {text} + + +) + +const CfTunnelFailedSection = ({ onRetry }: { readonly onRetry: () => void }): JSX.Element => ( +
+
Tunnel failed to start.
+ +
+) + +const CfReadyDetails = ( + { cfSshCommand, cfSshConfig, cfVscodeUri, sshPassword }: CfReadyDetailsProps +): JSX.Element => ( + <> +
Add to ~/.ssh/config
+
+ requires cloudflared installed on your machine +
+ +
Connect via SSH
+ +
SSH password
+ + {cfVscodeUri !== null && ( + <> +
+ Open in VS Code +
+ + + )} + +) + +const DirectSshSection = ( + { cfReadyPassword, directCommand, directConfig, directVscodeUri }: DirectSshSectionProps +): JSX.Element => ( + <> +
+
Direct SSH (local network)
+
Add to ~/.ssh/config
+
no cloudflared needed — works on same LAN
+ +
Connect via SSH
+ + {cfReadyPassword !== null && ( + <> +
SSH password
+ + + )} +
Open in VS Code
+
requires config entry above in ~/.ssh/config
+ + +) + +export const VsCodeAccessPanel = ( + { cfState, info, onClose, onRefresh, onRetry }: VsCodeAccessPanelProps +): JSX.Element => { + const vals = buildVsCodeAccessValues(cfState, info) + return ( +
+
+
VS Code / SSH access
+
+ {cfState.tag === "ready" && ( + + )} + +
+
+ {cfState.tag === "loading" && ( +
Starting Cloudflare tunnel…
+ )} + {cfState.tag === "failed" && } + {cfState.tag === "ready" && vals.cfSshConfig !== null && vals.cfSshCommand !== null && ( + + )} + +
+ ) +} diff --git a/packages/app/src/web/panel-vscode-access.tsx b/packages/app/src/web/panel-vscode-access.tsx new file mode 100644 index 00000000..17b13b32 --- /dev/null +++ b/packages/app/src/web/panel-vscode-access.tsx @@ -0,0 +1,88 @@ +import { Effect } from "effect" +import { useEffect } from "react" + +import { startProjectSshTunnel } from "./api-share-links.js" +import type { TerminalPaneProps } from "./app-ready-terminal-types.js" + +export type VsCodeAccessInfo = { + readonly sshUser: string + readonly targetDir: string + readonly sshPort: number +} + +export type CfTunnelState = + | { readonly tag: "idle" } + | { readonly tag: "loading" } + | { readonly tag: "ready"; readonly hostname: string; readonly sshPassword: string } + | { readonly tag: "failed" } + +export const buildVsCodeAccessInfo = (project: TerminalPaneProps["project"]): VsCodeAccessInfo | null => { + if (project === null) return null + return { sshUser: project.sshUser, targetDir: project.targetDir, sshPort: project.sshPort } +} + +export const startTunnel = ( + projectKey: string, + setCfState: (s: CfTunnelState) => void +): void => { + setCfState({ tag: "loading" }) + void Effect.runPromise( + startProjectSshTunnel(projectKey).pipe( + Effect.match({ + onFailure: () => { + setCfState({ tag: "failed" }) + }, + onSuccess: ({ hostname, sshPassword }) => { + setCfState( + hostname === null + ? { tag: "failed" } + : { tag: "ready", hostname, sshPassword } + ) + } + }) + ) + ) +} + +export const useTunnelAutoStart = ( + isOpen: boolean, + cfState: CfTunnelState, + projectKey: string | undefined, + setCfState: (s: CfTunnelState) => void +): void => { + useEffect(() => { + if (!isOpen || projectKey === undefined || cfState.tag !== "idle") return + startTunnel(projectKey, setCfState) + }, [isOpen, projectKey, cfState.tag, setCfState]) +} + +export const useTunnelPolling = ( + isOpen: boolean, + cfState: CfTunnelState, + projectKey: string | undefined, + setCfState: (s: CfTunnelState) => void +): void => { + useEffect(() => { + if (!isOpen || cfState.tag !== "ready" || projectKey === undefined) return + let isCancelled = false + const id = setInterval(() => { + void Effect.runPromise( + startProjectSshTunnel(projectKey).pipe( + Effect.match({ + onFailure: () => { + if (!isCancelled) setCfState({ tag: "failed" }) + }, + onSuccess: ({ hostname, sshPassword }) => { + if (isCancelled) return + setCfState(hostname === null ? { tag: "failed" } : { tag: "ready", hostname, sshPassword }) + } + }) + ) + ) + }, 30_000) + return () => { + isCancelled = true + clearInterval(id) + } + }, [isOpen, cfState.tag, projectKey, setCfState]) +} diff --git a/packages/lib/src/usecases/ssh-access.ts b/packages/lib/src/usecases/ssh-access.ts index 26151df9..a599ad20 100644 --- a/packages/lib/src/usecases/ssh-access.ts +++ b/packages/lib/src/usecases/ssh-access.ts @@ -200,20 +200,24 @@ export type ShareLinkSshAccess = { readonly cfVscodeUri: string | null } -export const buildShareLinkSshAccess = ( - containerName: string, +export type ShareLinkSshAccessInput = { + readonly containerName: string + readonly sshUser: string + readonly sshPort: number + readonly sshKeyPath: string | null + readonly targetDir: string + readonly clientHost: string + readonly sshCfHostname: string | null +} + +const buildDirectSshLines = ( + alias: string, + sshHostname: string, sshUser: string, sshPort: number, - sshKeyPath: string | null, - targetDir: string, - clientHost: string, - sshCfHostname: string | null -): ShareLinkSshAccess => { - const alias = sanitizeSshHostAlias(containerName) - // clientHost may carry a web port suffix (e.g. "192.168.0.206:4174") — strip it. - // SSH port is provided separately via sshPort; HostName must be a bare hostname. - const sshHostname = clientHost.includes(":") ? clientHost.slice(0, clientHost.lastIndexOf(":")) : clientHost - const directLines = [ + sshKeyPath: string | null +): Array => { + const lines = [ `Host ${alias}`, ` HostName ${sshHostname}`, ` User ${sshUser}`, @@ -222,40 +226,49 @@ export const buildShareLinkSshAccess = ( ` StrictHostKeyChecking no`, ` UserKnownHostsFile /dev/null` ] - if (sshKeyPath !== null) { - directLines.push(` IdentityFile ${sshKeyPath}`, ` IdentitiesOnly yes`) - } + if (sshKeyPath !== null) lines.push(` IdentityFile ${sshKeyPath}`, ` IdentitiesOnly yes`) + return lines +} - const cfAlias = `${alias}-cf` - const cfLines = sshCfHostname === null - ? null - : [ - `Host ${cfAlias}`, - ` HostName ${sshCfHostname}`, - ` User ${sshUser}`, - ` Port 22`, - ` ProxyCommand cloudflared access ssh --hostname %h`, - ` LogLevel ERROR`, - ` StrictHostKeyChecking no`, - ` UserKnownHostsFile /dev/null`, - ...(sshKeyPath !== null ? [` IdentityFile ${sshKeyPath}`, ` IdentitiesOnly yes`] : []) - ] +const buildCfSshLines = ( + cfAlias: string, + cfHostname: string, + sshUser: string, + sshKeyPath: string | null +): Array => [ + `Host ${cfAlias}`, + ` HostName ${cfHostname}`, + ` User ${sshUser}`, + ` Port 22`, + ` ProxyCommand cloudflared access ssh --hostname %h`, + ` LogLevel ERROR`, + ` StrictHostKeyChecking no`, + ` UserKnownHostsFile /dev/null`, + ...(sshKeyPath === null ? [] : [` IdentityFile ${sshKeyPath}`, ` IdentitiesOnly yes`]) +] +export const buildShareLinkSshAccess = ( + { clientHost, containerName, sshCfHostname, sshKeyPath, sshPort, sshUser, targetDir }: ShareLinkSshAccessInput +): ShareLinkSshAccess => { + const alias = sanitizeSshHostAlias(containerName) + // clientHost may carry a web port suffix (e.g. "192.168.0.206:4174") — strip it. + const sshHostname = clientHost.includes(":") ? clientHost.slice(0, clientHost.lastIndexOf(":")) : clientHost + const cfAlias = `${alias}-cf` + const cfLines = sshCfHostname === null ? null : buildCfSshLines(cfAlias, sshCfHostname, sshUser, sshKeyPath) const encodedFolder = encodeURIComponent(targetDir) // CHANGE: use hostName=user@host:port so VS Code Remote SSH connects directly - // WHY: alias-based URIs require the user to manually add SSH config — direct hostName format - // works without ~/.ssh/config because VS Code accepts user@host:port in Connect to Host // SOURCE: https://code.visualstudio.com/docs/remote/ssh#_connect-to-a-remote-host - // "You can also enter a user@host or user@host:port connection string if you don't want - // to use an SSH config file entry." + // "You can also enter a user@host or user@host:port connection string" const directHostName = `${sshUser}@${sshHostname}:${sshPort}` - const cfHostName = sshCfHostname !== null ? `${sshUser}@${sshCfHostname}` : null + const cfHostName = sshCfHostname === null ? null : `${sshUser}@${sshCfHostname}` return { alias, - configSnippet: directLines.join("\n"), + configSnippet: buildDirectSshLines(alias, sshHostname, sshUser, sshPort, sshKeyPath).join("\n"), cfConfigSnippet: cfLines === null ? null : cfLines.join("\n"), workspacePath: targetDir, - vscodeUri: `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(directHostName)}&folder=${encodedFolder}`, + vscodeUri: `vscode://ms-vscode-remote.remote-ssh/open?hostName=${ + encodeURIComponent(directHostName) + }&folder=${encodedFolder}`, cfVscodeUri: cfHostName === null ? null : `vscode://ms-vscode-remote.remote-ssh/open?hostName=${encodeURIComponent(cfHostName)}&folder=${encodedFolder}` diff --git a/packages/terminal/src/web/panel-terminal-types.ts b/packages/terminal/src/web/panel-terminal-types.ts index f8156e72..0c3e8033 100644 --- a/packages/terminal/src/web/panel-terminal-types.ts +++ b/packages/terminal/src/web/panel-terminal-types.ts @@ -1,8 +1,31 @@ import type { JSX } from "react" -import type { TerminalExitInfo } from "./terminal-panel-runtime.js" +import type { MobileTerminalKey } from "./terminal-mobile-controls.js" +import type { TerminalExitInfo, TerminalStatus } from "./terminal-panel-runtime.js" import type { ActiveTerminalSession } from "./terminal.js" +export type RefState = { current: T } + +export type TerminalNotificationHandlers = { + readonly notifyAttachFailure: () => void + readonly notifyExit: (info: TerminalExitInfo) => void + readonly notifyMessage: (message: string) => void +} + +export type InlineImagePreviewState = { + readonly inlineImagePreviewsEnabled: boolean + readonly inlineImagePreviewsEnabledRef: RefState + readonly toggleInlineImagePreviews: () => void +} + +export type MobileTerminalControlState = { + readonly handleMobileKeyPress: (key: MobileTerminalKey) => void + readonly mobileControlsCollapsed: boolean + readonly mobileCtrlArmed: boolean + readonly toggleMobileControls: () => void + readonly toggleMobileCtrl: () => void +} + export type TerminalPanelProps = { readonly keyboardOpen: boolean readonly mobileMode: boolean @@ -20,3 +43,28 @@ export type TerminalPanelProps = { readonly bodyContent?: JSX.Element | undefined readonly onOpenVsCode?: (() => void) | undefined } + +export type TerminalPanelLayoutProps = + & Pick< + TerminalPanelProps, + | "bodyContent" + | "keyboardOpen" + | "mobileMode" + | "onApplyProject" + | "onOpenBrowser" + | "onOpenSkiller" + | "onOpenTaskManager" + | "onOpenTerminal" + | "onOpenVsCode" + | "session" + > + & InlineImagePreviewState + & MobileTerminalControlState + & { + readonly compactHeaderMode: boolean + readonly compactTypingMode: boolean + readonly handleDetach: () => void + readonly handleKill: () => void + readonly hostRef: RefState + readonly status: TerminalStatus + } diff --git a/packages/terminal/src/web/panel-terminal.tsx b/packages/terminal/src/web/panel-terminal.tsx index 0f672d35..02c36335 100644 --- a/packages/terminal/src/web/panel-terminal.tsx +++ b/packages/terminal/src/web/panel-terminal.tsx @@ -16,7 +16,14 @@ import { terminalHostStyle, terminalPanelStyle } from "./panel-terminal-styles.js" -import type { TerminalPanelProps } from "./panel-terminal-types.js" +import { + type InlineImagePreviewState, + type MobileTerminalControlState, + type RefState, + type TerminalNotificationHandlers, + type TerminalPanelLayoutProps, + type TerminalPanelProps +} from "./panel-terminal-types.js" import type { MobileTerminalKey } from "./terminal-mobile-controls.js" import { isTerminalCompactHeaderMode, isTerminalTypingMode } from "./terminal-mobile-layout.js" import { @@ -28,53 +35,6 @@ import { } from "./terminal-panel-runtime.js" import { type ActiveTerminalSession, isPendingActiveTerminalSession } from "./terminal.js" -type RefState = { current: T } - -type TerminalNotificationHandlers = { - readonly notifyAttachFailure: () => void - readonly notifyExit: (info: TerminalExitInfo) => void - readonly notifyMessage: (message: string) => void -} - -type InlineImagePreviewState = { - readonly inlineImagePreviewsEnabled: boolean - readonly inlineImagePreviewsEnabledRef: RefState - readonly toggleInlineImagePreviews: () => void -} - -type MobileTerminalControlState = { - readonly handleMobileKeyPress: (key: MobileTerminalKey) => void - readonly mobileControlsCollapsed: boolean - readonly mobileCtrlArmed: boolean - readonly toggleMobileControls: () => void - readonly toggleMobileCtrl: () => void -} - -type TerminalPanelLayoutProps = - & Pick< - TerminalPanelProps, - | "bodyContent" - | "keyboardOpen" - | "mobileMode" - | "onApplyProject" - | "onOpenBrowser" - | "onOpenSkiller" - | "onOpenTaskManager" - | "onOpenTerminal" - | "onOpenVsCode" - | "session" - > - & InlineImagePreviewState - & MobileTerminalControlState - & { - readonly compactHeaderMode: boolean - readonly compactTypingMode: boolean - readonly handleDetach: () => void - readonly handleKill: () => void - readonly hostRef: RefState - readonly status: TerminalStatus - } - const resolveInitialTerminalStatus = (session: ActiveTerminalSession): TerminalStatus => isPendingActiveTerminalSession(session) && session.pendingConnection.phase === "error" ? "error" : "connecting"