diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 7b324603..9827612d 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -23,6 +23,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Build (terminal package) + run: bun run --cwd packages/terminal build - name: Build (docker-git package) run: bun run --cwd packages/app build - name: Build (session sync package) @@ -58,6 +60,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Typecheck (terminal) + run: bun run --cwd packages/terminal typecheck - name: Typecheck (app) run: bun run --cwd packages/app check - name: Typecheck (session sync) @@ -80,6 +84,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Lint (terminal) + run: bun run --cwd packages/terminal lint - name: Lint (app) run: bun run --cwd packages/app lint - name: Lint (session sync) @@ -103,6 +109,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Test (terminal) + run: bun run --cwd packages/terminal test - name: Test (app) run: bun run --cwd packages/app test - name: Test (session sync) @@ -125,6 +133,8 @@ jobs: - uses: actions/checkout@v6 - name: Install dependencies uses: ./.github/actions/setup + - name: Lint Effect-TS (terminal) + run: bun run --cwd packages/terminal lint:effect - name: Lint Effect-TS (app) run: bun run --cwd packages/app lint:effect - name: Lint Effect-TS (session sync) diff --git a/bun.lock b/bun.lock index 2e6e5962..2840fd95 100644 --- a/bun.lock +++ b/bun.lock @@ -20,6 +20,7 @@ "@effect/schema": "^0.75.5", "@fedify/fedify": "^2.2.3", "@fedify/vocab": "^2.2.3", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "effect": "^3.21.2", "node-pty": "^1.1.0", "ws": "^8.21.0", @@ -40,7 +41,7 @@ }, "packages/app": { "name": "@prover-coder-ai/docker-git", - "version": "1.1.40", + "version": "1.1.41", "bin": { "docker-git": "dist/src/docker-git/main.js", }, @@ -78,6 +79,7 @@ "@eslint/compat": "2.1.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "10.0.1", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.1", @@ -110,7 +112,7 @@ }, "packages/docker-git-session-sync": { "name": "@prover-coder-ai/docker-git-session-sync", - "version": "1.0.43", + "version": "1.0.44", "bin": { "docker-git-session-sync": "dist/docker-git-session-sync.js", }, @@ -176,6 +178,30 @@ "vitest": "^4.1.7", }, }, + "packages/terminal": { + "name": "@prover-coder-ai/docker-git-terminal", + "version": "0.1.0", + "dependencies": { + "@effect/platform": "^0.96.1", + "@effect/platform-node": "^0.106.0", + "@effect/schema": "^0.75.5", + "effect": "^3.21.2", + }, + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@effect/vitest": "^0.29.0", + "@eslint/js": "10.0.1", + "@ton-ai-core/vibecode-linter": "^1.0.11", + "@types/node": "^25.9.1", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^10.4.1", + "globals": "^17.6.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vitest": "^4.1.7", + }, + }, }, "trustedDependencies": [ "node-pty", @@ -546,6 +572,8 @@ "@prover-coder-ai/docker-git-session-sync": ["@prover-coder-ai/docker-git-session-sync@workspace:packages/docker-git-session-sync"], + "@prover-coder-ai/docker-git-terminal": ["@prover-coder-ai/docker-git-terminal@workspace:packages/terminal"], + "@prover-coder-ai/eslint-plugin-suggest-members": ["@prover-coder-ai/eslint-plugin-suggest-members@0.0.26", "", { "dependencies": { "@effect/platform": "^0.96.0", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", "@typescript-eslint/utils": "8.57.2", "effect": "^3.21.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <7.0.0" } }, "sha512-RWl1jYZTMK1p0L6GA7VXvTrtiNkbQyjkgk3mvz0Vv7ImTrctDOLFfNIRoJmhU+e5irj1u5uK2p9QoZtRzi4ILQ=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], diff --git a/package.json b/package.json index f2993609..36f45516 100644 --- a/package.json +++ b/package.json @@ -8,17 +8,18 @@ "packages/api", "packages/app", "packages/docker-git-session-sync", - "packages/lib" + "packages/lib", + "packages/terminal" ], "scripts": { "setup:pre-commit-hook": "bun scripts/setup-pre-commit-hook.js", - "build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git build", + "build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git-terminal build && bun run --filter @prover-coder-ai/docker-git build", "api:build": "bun run --filter @effect-template/api build", "api:start": "bun run --filter @effect-template/api start", "api:dev": "bun run --filter @effect-template/api dev", "api:test": "bun run --filter @effect-template/api test", "api:typecheck": "bun run --filter @effect-template/api typecheck", - "check": "bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", + "check": "bun run --filter @prover-coder-ai/docker-git-session-sync check && bun run --filter @prover-coder-ai/docker-git-terminal check && bun run --filter @prover-coder-ai/docker-git check && bun run --filter @effect-template/lib typecheck", "check:dist-deps-prune": "bun node_modules/@prover-coder-ai/dist-deps-prune/dist/main.js scan --package ./packages/app/package.json --prune-dev true --silent", "changeset": "changeset", "changeset-publish": "bun -e \"if (!process.env.NPM_TOKEN) { console.log('Skipping publish: NPM_TOKEN is not set'); process.exit(0); }\" && changeset publish", @@ -42,11 +43,11 @@ "web:build": "bun run --cwd packages/app build:web", "web:preview": "bun run --cwd packages/app preview:web", "web:serve": "bun run --cwd packages/app serve:web", - "lint": "bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint", + "lint": "bun run --filter @prover-coder-ai/docker-git-terminal lint && bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint", "lint:tests": "bun run --filter @prover-coder-ai/docker-git lint:tests", - "lint:effect": "bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", - "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", - "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", + "lint:effect": "bun run --filter @prover-coder-ai/docker-git-terminal lint:effect && bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect", + "test": "bun run --filter @prover-coder-ai/docker-git-session-sync test && bun run --filter @prover-coder-ai/docker-git-terminal test && bun run --filter @prover-coder-ai/docker-git test && bun run --filter @effect-template/lib test", + "typecheck": "bun run --filter @prover-coder-ai/docker-git-session-sync typecheck && bun run --filter @prover-coder-ai/docker-git-terminal typecheck && bun run --filter @prover-coder-ai/docker-git typecheck && bun run --filter @effect-template/lib typecheck", "start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js" }, "devDependencies": { diff --git a/packages/api/Dockerfile b/packages/api/Dockerfile index 74af1b7d..df11fd31 100644 --- a/packages/api/Dockerfile +++ b/packages/api/Dockerfile @@ -55,11 +55,12 @@ RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ FROM controller-base AS workspace-deps COPY package.json bun.lock bunfig.toml tsconfig.base.json tsconfig.json ./ -RUN mkdir -p packages/api packages/app packages/docker-git-session-sync packages/lib +RUN mkdir -p packages/api packages/app packages/docker-git-session-sync packages/lib packages/terminal COPY packages/api/package.json ./packages/api/package.json COPY packages/app/package.json ./packages/app/package.json COPY packages/docker-git-session-sync/package.json ./packages/docker-git-session-sync/package.json COPY packages/lib/package.json ./packages/lib/package.json +COPY packages/terminal/package.json ./packages/terminal/package.json RUN set -eu; \ for attempt in 1 2 3 4 5; do \ @@ -68,6 +69,7 @@ RUN set -eu; \ --silent \ --filter @effect-template/api \ --filter @effect-template/lib \ + --filter @prover-coder-ai/docker-git-terminal \ --filter @prover-coder-ai/docker-git-session-sync; then \ exit 0; \ fi; \ @@ -84,8 +86,10 @@ COPY patches ./patches COPY scripts ./scripts COPY packages/docker-git-session-sync ./packages/docker-git-session-sync COPY packages/lib ./packages/lib +COPY packages/terminal ./packages/terminal RUN bun run --cwd packages/docker-git-session-sync build +RUN bun run --cwd packages/terminal build RUN bun run --cwd packages/lib build FROM controller-base AS skiller-build diff --git a/packages/api/package.json b/packages/api/package.json index 547f9d09..5c054bc5 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -7,19 +7,20 @@ "type": "module", "packageManager": "bun@1.3.11", "scripts": { - "prebuild": "bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../terminal build && bun run --cwd ../lib build", "build": "tsc -p tsconfig.json", "dev": "tsc -p tsconfig.json --watch", "prestart": "bun run build", "start": "bun dist/src/main.js", - "pretypecheck": "bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../terminal build && bun run --cwd ../lib build", "typecheck": "tsc --noEmit -p tsconfig.json", "lint": "eslint .", - "pretest": "bun run --cwd ../lib build", + "pretest": "bun run --cwd ../terminal build && bun run --cwd ../lib build", "test": "vitest run" }, "dependencies": { "@effect-template/lib": "workspace:*", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "@effect/platform": "^0.96.1", "@effect/platform-node": "^0.106.0", "@effect/schema": "^0.75.5", diff --git a/packages/api/src/services/auth-terminal-sessions.ts b/packages/api/src/services/auth-terminal-sessions.ts index 4580f9b7..1d4113d5 100644 --- a/packages/api/src/services/auth-terminal-sessions.ts +++ b/packages/api/src/services/auth-terminal-sessions.ts @@ -1,5 +1,12 @@ import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" +import { + appendTerminalOutput, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + type TerminalOutputBuffer +} from "@prover-coder-ai/docker-git-terminal/core" +import type { TerminalServerMessage } from "@prover-coder-ai/docker-git-terminal/contracts" import { Either, Effect, Match } from "effect" import { randomUUID } from "node:crypto" import { fileURLToPath } from "node:url" @@ -10,12 +17,6 @@ import { WebSocket, WebSocketServer, type RawData } from "ws" import type { AuthTerminalFlow, AuthTerminalSessionRequest, TerminalSession, TerminalSessionStatus } from "../api/contracts.js" import { ApiConflictError, ApiNotFoundError, describeUnknown } from "../api/errors.js" import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js" -import { - appendTerminalOutput, - emptyTerminalOutputBuffer, - renderTerminalOutputBuffer, - type TerminalOutputBuffer -} from "./terminal-output-buffer.js" import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js" type TerminalClientMessage = @@ -23,12 +24,6 @@ type TerminalClientMessage = | { readonly type: "resize"; readonly cols: number; readonly rows: number } | { readonly type: "close" } -type TerminalServerMessage = - | { readonly type: "ready"; readonly session: TerminalSession } - | { readonly type: "output"; readonly data: string } - | { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null } - | { readonly type: "error"; readonly message: string } - type AuthTerminalRecord = { attachTimeout: ReturnType | null args: ReadonlyArray diff --git a/packages/api/src/services/terminal-image-fetch-core.ts b/packages/api/src/services/terminal-image-fetch-core.ts index b68d17ec..9e2dd5f6 100644 --- a/packages/api/src/services/terminal-image-fetch-core.ts +++ b/packages/api/src/services/terminal-image-fetch-core.ts @@ -1,155 +1,6 @@ -import { fileURLToPath } from "node:url" - -export type TerminalImageFetchPlan = - | { - readonly _tag: "InvalidTerminalImageFetch" - readonly message: string - } - | { - readonly _tag: "ValidTerminalImageFetch" - readonly containerPath: string - readonly mediaType: string - } - -export const terminalImageFetchMaxBytes = 10 * 1024 * 1024 - -const supportedExtensionMediaTypes = new Map([ - ["gif", "image/gif"], - ["jpeg", "image/jpeg"], - ["jpg", "image/jpeg"], - ["png", "image/png"], - ["webp", "image/webp"] -]) - -const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}` -const deleteChar = String.fromCodePoint(0x7F) -const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u") -const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u -const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u -const fileUrlPattern = /^file:\/\//iu -const encodedPathSeparatorPattern = /%(?:2f|5c)/iu -const fileUrlBackslashPattern = /\\/u -const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu - -type TerminalImagePathNormalization = - | { - readonly _tag: "InvalidTerminalImagePath" - readonly message: string - } - | { - readonly _tag: "ValidTerminalImagePath" - readonly path: string - } - -const lowercaseExtension = (path: string): string | null => { - const lastDot = path.lastIndexOf(".") - if (lastDot < 0 || lastDot === path.length - 1) { - return null - } - return path.slice(lastDot + 1).toLowerCase() -} - -const rawFileUrlPathname = (path: string): string => { - const withoutScheme = path.slice("file://".length) - const pathStart = withoutScheme.indexOf("/") - if (pathStart < 0) { - return "" - } - const pathAndSuffix = withoutScheme.slice(pathStart) - const queryStart = pathAndSuffix.indexOf("?") - const hashStart = pathAndSuffix.indexOf("#") - if (queryStart < 0 && hashStart < 0) { - return pathAndSuffix - } - if (queryStart < 0) { - return pathAndSuffix.slice(0, hashStart) - } - if (hashStart < 0) { - return pathAndSuffix.slice(0, queryStart) - } - return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)) -} - -const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => { - if (!urlSchemePattern.test(path)) { - return { _tag: "ValidTerminalImagePath", path } - } - if (!fileUrlPattern.test(path)) { - return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." } - } - - const rawPathname = rawFileUrlPathname(path) - if (fileUrlTraversalPattern.test(rawPathname)) { - return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." } - } - if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) { - return { - _tag: "InvalidTerminalImagePath", - message: "Image file URL must not contain encoded or backslash path separators." - } - } - - try { - const url = new URL(path) - if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) { - return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." } - } - if (url.search.length > 0 || url.hash.length > 0) { - return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." } - } - return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) } - } catch { - return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." } - } -} - -export type TerminalImageFetchOptions = { - readonly baseDir?: string -} - -const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/") - -const joinBaseDirAndRelativePath = (baseDir: string, relativePath: string): string => { - const trimmedBase = baseDir.replace(/\/+$/u, "") - return `${trimmedBase}/${relativePath}` -} - -export const planTerminalImageFetch = ( - path: string, - options: TerminalImageFetchOptions = {} -): TerminalImageFetchPlan => { - if (typeof path !== "string" || path.length === 0) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." } - } - const normalized = normalizeTerminalImagePath(path) - if (normalized._tag === "InvalidTerminalImagePath") { - return { _tag: "InvalidTerminalImageFetch", message: normalized.message } - } - const normalizedPath = normalized.path - let containerPath = normalizedPath - if (!isAbsolutePosixPath(containerPath)) { - const baseDir = options.baseDir - if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." } - } - if (invalidCharacterPattern.test(baseDir) || traversalPattern.test(baseDir)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image base directory is invalid." } - } - containerPath = joinBaseDirAndRelativePath(baseDir, containerPath) - } - if (invalidCharacterPattern.test(containerPath)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." } - } - if (traversalPattern.test(containerPath)) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." } - } - const extension = lowercaseExtension(containerPath) - if (extension === null) { - return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." } - } - const mediaType = supportedExtensionMediaTypes.get(extension) - if (mediaType === undefined) { - return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` } - } - return { _tag: "ValidTerminalImageFetch", containerPath, mediaType } -} +export { + planTerminalImageFetch, + terminalImageFetchMaxBytes, + type TerminalImageFetchOptions, + type TerminalImageFetchPlan +} from "@prover-coder-ai/docker-git-terminal/server" diff --git a/packages/api/src/services/terminal-image-paste-core.ts b/packages/api/src/services/terminal-image-paste-core.ts index a24914ce..e1487ec1 100644 --- a/packages/api/src/services/terminal-image-paste-core.ts +++ b/packages/api/src/services/terminal-image-paste-core.ts @@ -1,127 +1,9 @@ -export type TerminalImagePastePayload = { - readonly data: string - readonly mediaType: string - readonly name: string - readonly size: number -} - -export type TerminalImagePastePlan = - | { - readonly _tag: "InvalidTerminalImagePaste" - readonly message: string - } - | { - readonly _tag: "ValidTerminalImagePaste" - readonly containerPath: string - readonly decodedBytes: number - readonly normalizedBase64: string - } - -export const terminalImagePasteDirectory = "/home/dev/.docker-git/pasted-images" -export const terminalImagePasteMaxBytes = 10 * 1024 * 1024 - -const base64Pattern = /^(?:[+/0-9A-Za-z]{4})*(?:[+/0-9A-Za-z]{2}==|[+/0-9A-Za-z]{3}=)?$/u -const terminalImagePasteMaxBase64Length = Math.ceil(terminalImagePasteMaxBytes / 3) * 4 -const safeFileNameMaxLength = 72 - -const imageMediaTypeExtensions = new Map([ - ["image/gif", "gif"], - ["image/jpeg", "jpg"], - ["image/png", "png"], - ["image/webp", "webp"] -]) - -export const isSupportedTerminalImageMediaType = (mediaType: string): boolean => - imageMediaTypeExtensions.has(mediaType.toLowerCase()) - -const extensionForMediaType = (mediaType: string): string | null => - imageMediaTypeExtensions.get(mediaType.toLowerCase()) ?? null - -const normalizeBase64 = (data: string): string => data.replace(/\s+/gu, "") - -const decodedBase64Bytes = (data: string): number | null => { - if (data.length === 0 || data.length % 4 !== 0 || !base64Pattern.test(data)) { - return null - } - const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0 - return data.length / 4 * 3 - padding -} - -const lastPathSegment = (name: string): string => { - const segments = name.split(/[\\/]/u) - return segments.at(-1) ?? "" -} - -export const sanitizeTerminalImageBaseName = (name: string): string => { - const withoutExtension = lastPathSegment(name).replace(/\.[^.]*$/u, "") - const sanitized = withoutExtension - .replace(/[^0-9A-Za-z._-]+/gu, "-") - .replace(/^[.-]+/u, "") - .replace(/[.-]+$/u, "") - .slice(0, safeFileNameMaxLength) - return sanitized.length > 0 ? sanitized : "clipboard-image" -} - -const terminalImageFileName = ( - id: string, - name: string, - mediaType: string -): string | null => { - const extension = extensionForMediaType(mediaType) - if (extension === null) { - return null - } - return `${id}-${sanitizeTerminalImageBaseName(name)}.${extension}` -} - -export const createTerminalImagePastePlan = ( - payload: TerminalImagePastePayload, - id: string -): TerminalImagePastePlan => { - const mediaType = payload.mediaType.toLowerCase() - const fileName = terminalImageFileName(id, payload.name, mediaType) - if (fileName === null) { - return { - _tag: "InvalidTerminalImagePaste", - message: `Unsupported image type: ${payload.mediaType || "unknown"}.` - } - } - if (!Number.isFinite(payload.size) || payload.size <= 0) { - return { - _tag: "InvalidTerminalImagePaste", - message: "Image payload is empty." - } - } - if (payload.size > terminalImagePasteMaxBytes) { - return { - _tag: "InvalidTerminalImagePaste", - message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.` - } - } - const normalizedBase64 = normalizeBase64(payload.data) - if (normalizedBase64.length > terminalImagePasteMaxBase64Length) { - return { - _tag: "InvalidTerminalImagePaste", - message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.` - } - } - const decodedBytes = decodedBase64Bytes(normalizedBase64) - if (decodedBytes === null) { - return { - _tag: "InvalidTerminalImagePaste", - message: "Image payload is not valid base64." - } - } - if (decodedBytes !== payload.size) { - return { - _tag: "InvalidTerminalImagePaste", - message: "Image payload size does not match its base64 data." - } - } - return { - _tag: "ValidTerminalImagePaste", - containerPath: `${terminalImagePasteDirectory}/${fileName}`, - decodedBytes, - normalizedBase64 - } -} +export { + createTerminalImagePastePlan, + isSupportedTerminalImageMediaType, + sanitizeTerminalImageBaseName, + terminalImagePasteDirectory, + terminalImagePasteMaxBytes, + type TerminalImagePastePayload, + type TerminalImagePastePlan +} from "@prover-coder-ai/docker-git-terminal/core" diff --git a/packages/api/src/services/terminal-output-buffer.ts b/packages/api/src/services/terminal-output-buffer.ts index ac5ff94b..b1ff662d 100644 --- a/packages/api/src/services/terminal-output-buffer.ts +++ b/packages/api/src/services/terminal-output-buffer.ts @@ -1,68 +1,7 @@ -export type TerminalOutputBuffer = { - readonly charLength: number - readonly chunks: ReadonlyArray -} - -export const terminalOutputReplayMaxChars = 2 * 1024 * 1024 - -export const emptyTerminalOutputBuffer: TerminalOutputBuffer = { - charLength: 0, - chunks: [] -} - -const boundedMaxChars = (maxChars: number): number => - Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0 - -const trimTerminalOutputChunks = ( - chunks: ReadonlyArray, - overflow: number -): ReadonlyArray => { - const kept: Array = [] - let remainingOverflow = overflow - for (const chunk of chunks) { - if (remainingOverflow <= 0) { - kept.push(chunk) - continue - } - if (chunk.length <= remainingOverflow) { - remainingOverflow -= chunk.length - continue - } - kept.push(chunk.slice(remainingOverflow)) - remainingOverflow = 0 - } - return kept -} - -export const appendTerminalOutput = ( - buffer: TerminalOutputBuffer, - data: string, - maxChars = terminalOutputReplayMaxChars -): TerminalOutputBuffer => { - const boundedMax = boundedMaxChars(maxChars) - if (boundedMax === 0) { - return emptyTerminalOutputBuffer - } - if (data.length === 0) { - return buffer - } - if (data.length >= boundedMax) { - return { - charLength: boundedMax, - chunks: [data.slice(data.length - boundedMax)] - } - } - const charLength = buffer.charLength + data.length - const chunks = [...buffer.chunks, data] - if (charLength <= boundedMax) { - return { charLength, chunks } - } - const overflow = charLength - boundedMax - return { - charLength: boundedMax, - chunks: trimTerminalOutputChunks(chunks, overflow) - } -} - -export const renderTerminalOutputBuffer = (buffer: TerminalOutputBuffer): string => - buffer.chunks.join("") +export { + appendTerminalOutput, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + terminalOutputReplayMaxChars, + type TerminalOutputBuffer +} from "@prover-coder-ai/docker-git-terminal/core" diff --git a/packages/api/src/services/terminal-sessions.ts b/packages/api/src/services/terminal-sessions.ts index 8e2d162d..800df89b 100644 --- a/packages/api/src/services/terminal-sessions.ts +++ b/packages/api/src/services/terminal-sessions.ts @@ -18,6 +18,24 @@ import type * as PlatformPath from "@effect/platform/Path" import { NodeContext } from "@effect/platform-node" import * as ParseResult from "@effect/schema/ParseResult" import * as Schema from "@effect/schema/Schema" +import { + TerminalClientMessageSchema, + type TerminalClientMessage, + type TerminalServerMessage +} from "@prover-coder-ai/docker-git-terminal/contracts" +import { + appendTerminalOutput, + createTerminalImagePastePlan, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer, + terminalImagePasteDirectory, + type TerminalImagePastePayload, + type TerminalOutputBuffer +} from "@prover-coder-ai/docker-git-terminal/core" +import { + planTerminalImageFetch, + terminalImageFetchMaxBytes +} from "@prover-coder-ai/docker-git-terminal/server" import { Effect, Either } from "effect" import { Buffer } from "node:buffer" import { spawn } from "node:child_process" @@ -32,37 +50,10 @@ import { WebSocket, WebSocketServer, type RawData } from "ws" import type { TerminalSession, TerminalSessionStatus } from "../api/contracts.js" import { ApiBadRequestError, ApiConflictError, ApiInternalError, ApiNotFoundError, describeUnknown } from "../api/errors.js" import { emitProjectEvent, latestProjectCursor } from "./events.js" -import { - planTerminalImageFetch, - terminalImageFetchMaxBytes -} from "./terminal-image-fetch-core.js" -import { - createTerminalImagePastePlan, - terminalImagePasteDirectory, - type TerminalImagePastePayload -} from "./terminal-image-paste-core.js" -import { - appendTerminalOutput, - emptyTerminalOutputBuffer, - renderTerminalOutputBuffer, - type TerminalOutputBuffer -} from "./terminal-output-buffer.js" import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js" import { getProject, getProjectItemById, getProjectItemByKey, upProject } from "./projects.js" import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js" -type TerminalClientMessage = - | { readonly type: "input"; readonly data: string } - | { readonly type: "resize"; readonly cols: number; readonly rows: number } - | ({ readonly type: "image" } & TerminalImagePastePayload) - | { readonly type: "close" } - -type TerminalServerMessage = - | { readonly type: "ready"; readonly session: TerminalSession } - | { readonly type: "output"; readonly data: string } - | { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null } - | { readonly type: "error"; readonly message: string } - type TerminalRecord = { session: TerminalSession pty: PtyBridge | null @@ -120,30 +111,6 @@ const tmuxMissingMessage = "tmux is not available in this project container. Apply docker-git config or rebuild the project image so tmux is installed, then reopen this SSH terminal session." const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu -const TerminalClientMessageSchema = Schema.parseJson( - Schema.Union( - Schema.Struct({ - type: Schema.Literal("input"), - data: Schema.String - }), - Schema.Struct({ - type: Schema.Literal("resize"), - cols: Schema.Number, - rows: Schema.Number - }), - Schema.Struct({ - type: Schema.Literal("image"), - data: Schema.String, - mediaType: Schema.String, - name: Schema.String, - size: Schema.Number - }), - Schema.Struct({ - type: Schema.Literal("close") - }) - ) -) - const DurableTerminalSessionSchema = Schema.Struct({ id: Schema.String, projectId: Schema.String, diff --git a/packages/api/tests/terminal-image-fetch-core.test.ts b/packages/api/tests/terminal-image-fetch-core.test.ts index eebe5764..6aa8a4ac 100644 --- a/packages/api/tests/terminal-image-fetch-core.test.ts +++ b/packages/api/tests/terminal-image-fetch-core.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest" -import { planTerminalImageFetch } from "../src/services/terminal-image-fetch-core.js" +import { planTerminalImageFetch } from "@prover-coder-ai/docker-git-terminal/server" describe("terminal image fetch core", () => { it("continues to accept an absolute path with a supported image extension", () => { diff --git a/packages/api/tests/terminal-image-paste-core.test.ts b/packages/api/tests/terminal-image-paste-core.test.ts index d21acd6e..4b533492 100644 --- a/packages/api/tests/terminal-image-paste-core.test.ts +++ b/packages/api/tests/terminal-image-paste-core.test.ts @@ -5,7 +5,7 @@ import { sanitizeTerminalImageBaseName, terminalImagePasteDirectory, terminalImagePasteMaxBytes -} from "../src/services/terminal-image-paste-core.js" +} from "@prover-coder-ai/docker-git-terminal/core" describe("terminal image paste core", () => { it("creates a safe project-container path for supported images", () => { diff --git a/packages/api/tests/terminal-output-buffer.test.ts b/packages/api/tests/terminal-output-buffer.test.ts index 476d561e..6a71d5fe 100644 --- a/packages/api/tests/terminal-output-buffer.test.ts +++ b/packages/api/tests/terminal-output-buffer.test.ts @@ -4,7 +4,7 @@ import { appendTerminalOutput, emptyTerminalOutputBuffer, renderTerminalOutputBuffer -} from "../src/services/terminal-output-buffer.js" +} from "@prover-coder-ai/docker-git-terminal/core" describe("terminal output replay buffer", () => { it("replays appended terminal output in order", () => { diff --git a/packages/app/package.json b/packages/app/package.json index 9cd545d8..6ac2a0ac 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -13,7 +13,7 @@ "doc": "doc" }, "scripts": { - "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prebuild": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "build": "bun run build:app && bun run build:docker-git", "build:app": "vite build --ssr src/app/main.ts", "build:web": "vite build --config vite.web.config.ts", @@ -22,13 +22,13 @@ "dev": "vite build --watch --ssr src/app/main.ts", "dev:web": "vite --config vite.web.config.ts", "serve:web": "bun scripts/serve-dist-web.mjs", - "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prelint": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", "lint:tests": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter tests/", "lint:effect": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH eslint --config eslint.effect-ts-check.config.mjs .", - "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prebuild:docker-git": "bun install --cwd ../.. && bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "build:docker-git": "vite build --config vite.docker-git.config.ts", - "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "prebuild:docker-git:reuse-install": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "build:docker-git:reuse-install": "vite build --config vite.docker-git.config.ts", "check": "bun run typecheck", "clone": "bun run build:docker-git && bun dist/src/docker-git/main.js clone", @@ -37,9 +37,9 @@ "list": "bun run build:docker-git && bun dist/src/docker-git/main.js ps", "preview:web": "vite preview --config vite.web.config.ts", "start": "bun run build:docker-git && bun dist/src/docker-git/main.js", - "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "pretest": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "test": "bun run lint:tests && vitest run", - "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../lib build", + "pretypecheck": "bun run --cwd ../docker-git-session-sync build && bun run --cwd ../terminal build && bun run --cwd ../lib build", "typecheck": "tsc --noEmit" }, "repository": { @@ -97,6 +97,7 @@ "@eslint/compat": "2.1.0", "@eslint/eslintrc": "3.3.5", "@eslint/js": "10.0.1", + "@prover-coder-ai/docker-git-terminal": "workspace:*", "@prover-coder-ai/eslint-plugin-suggest-members": "^0.0.26", "@ton-ai-core/vibecode-linter": "^1.0.11", "@types/node": "^25.9.1", diff --git a/packages/app/src/docker-git/api-terminal-codec.ts b/packages/app/src/docker-git/api-terminal-codec.ts index c14aaab7..4e27c18e 100644 --- a/packages/app/src/docker-git/api-terminal-codec.ts +++ b/packages/app/src/docker-git/api-terminal-codec.ts @@ -1,4 +1,4 @@ -import type { TerminalSession } from "../shared/terminal-session-schema.js" +import type { TerminalSession } from "@prover-coder-ai/docker-git-terminal/contracts" import { asObject, asString, type JsonValue } from "./api-json.js" export type ApiTerminalSession = TerminalSession diff --git a/packages/app/src/docker-git/terminal-session-client.ts b/packages/app/src/docker-git/terminal-session-client.ts index f1e89e8b..bf69eefc 100644 --- a/packages/app/src/docker-git/terminal-session-client.ts +++ b/packages/app/src/docker-git/terminal-session-client.ts @@ -1,7 +1,7 @@ import * as ParseResult from "@effect/schema/ParseResult" +import { type TerminalServerMessage, TerminalServerMessageSchema } from "@prover-coder-ai/docker-git-terminal/contracts" import { Effect, Either } from "effect" -import { type TerminalServerMessage, TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" import type { ApiTerminalSession } from "./api-client.js" import { resolveApiBaseUrl } from "./controller.js" import { writeToTerminal } from "./menu-shared.js" diff --git a/packages/app/src/shared/terminal-session-schema.ts b/packages/app/src/shared/terminal-session-schema.ts index 8f29ab0a..a1c4716b 100644 --- a/packages/app/src/shared/terminal-session-schema.ts +++ b/packages/app/src/shared/terminal-session-schema.ts @@ -1,44 +1,11 @@ -import * as Schema from "@effect/schema/Schema" - -export const TerminalSessionSchema = Schema.Struct({ - id: Schema.String, - projectId: Schema.String, - sshCommand: Schema.String, - status: Schema.Union( - Schema.Literal("ready"), - Schema.Literal("attached"), - Schema.Literal("exited"), - Schema.Literal("failed") - ), - createdAt: Schema.String, - attachedClients: Schema.optional(Schema.Number), - startedAt: Schema.optional(Schema.String), - closedAt: Schema.optional(Schema.String), - exitCode: Schema.optional(Schema.Number), - signal: Schema.optional(Schema.Number) -}) - -const TerminalServerMessagePayloadSchema = Schema.Union( - Schema.Struct({ - type: Schema.Literal("ready"), - session: TerminalSessionSchema - }), - Schema.Struct({ - type: Schema.Literal("output"), - data: Schema.String - }), - Schema.Struct({ - type: Schema.Literal("exit"), - exitCode: Schema.NullOr(Schema.Number), - signal: Schema.NullOr(Schema.Number) - }), - Schema.Struct({ - type: Schema.Literal("error"), - message: Schema.String - }) -) - -export const TerminalServerMessageSchema = Schema.parseJson(TerminalServerMessagePayloadSchema) - -export type TerminalSession = Schema.Schema.Type -export type TerminalServerMessage = Schema.Schema.Type +export { + type TerminalClientMessage, + TerminalClientMessagePayloadSchema, + TerminalClientMessageSchema, + type TerminalServerMessage, + TerminalServerMessageSchema, + type TerminalSession, + TerminalSessionSchema, + type TerminalSessionStatus, + TerminalSessionStatusSchema +} from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/src/web/api-terminal-schema.ts b/packages/app/src/web/api-terminal-schema.ts index 1dbd085e..bc100bef 100644 --- a/packages/app/src/web/api-terminal-schema.ts +++ b/packages/app/src/web/api-terminal-schema.ts @@ -1,6 +1,6 @@ import * as Schema from "@effect/schema/Schema" +import { TerminalSessionSchema } from "@prover-coder-ai/docker-git-terminal/contracts" -import { TerminalSessionSchema } from "../shared/terminal-session-schema.js" import { ProjectDetailsSchema } from "./api-project-schema.js" export const TerminalSessionResponseSchema = Schema.Struct({ @@ -29,4 +29,4 @@ export const AuthTerminalSessionResponseSchema = Schema.Struct({ session: TerminalSessionSchema }) -export { TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" +export { TerminalServerMessageSchema } from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/src/web/api-types.ts b/packages/app/src/web/api-types.ts index 98b7677d..0d564832 100644 --- a/packages/app/src/web/api-types.ts +++ b/packages/app/src/web/api-types.ts @@ -96,4 +96,4 @@ export type ProjectAuthFlow = | "ProjectGrokConnect" | "ProjectGrokDisconnect" -export { type TerminalServerMessage, type TerminalSession } from "../shared/terminal-session-schema.js" +export { type TerminalServerMessage, type TerminalSession } from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/src/web/terminal.ts b/packages/app/src/web/terminal.ts index 3fcab31f..2aa21ade 100644 --- a/packages/app/src/web/terminal.ts +++ b/packages/app/src/web/terminal.ts @@ -1,8 +1,10 @@ import * as ParseResult from "@effect/schema/ParseResult" +import { + type TerminalServerMessage as ParsedTerminalServerMessage, + TerminalServerMessageSchema +} from "@prover-coder-ai/docker-git-terminal/contracts" import { Either } from "effect" -import { TerminalServerMessageSchema } from "../shared/terminal-session-schema.js" -import type { TerminalServerMessage as ParsedTerminalServerMessage } from "../shared/terminal-session-schema.js" import { resolveApiBaseUrl, trimTrailingSlash } from "./api-http.js" import type { TerminalSession } from "./api-schema.js" @@ -237,4 +239,4 @@ export const resolveTerminalWebSocketUrl = (websocketPath: string, cols: number, export const parseTerminalServerMessage = (value: string): ParsedTerminalServerMessage | null => Either.getOrNull(ParseResult.decodeUnknownEither(TerminalServerMessageSchema)(value)) -export { type TerminalServerMessage } from "../shared/terminal-session-schema.js" +export { type TerminalServerMessage } from "@prover-coder-ai/docker-git-terminal/contracts" diff --git a/packages/app/tests/docker-git/core-templates.test.ts b/packages/app/tests/docker-git/core-templates.test.ts index c8f7fbc0..904b036f 100644 --- a/packages/app/tests/docker-git/core-templates.test.ts +++ b/packages/app/tests/docker-git/core-templates.test.ts @@ -72,7 +72,9 @@ describe("app planFiles", () => { expect(entrypoint.contents).toContain("docker_git_stop_playwright_browser()") expect(entrypoint.contents).toContain("docker-git-browser-connection") expect(entrypoint.contents).toContain("stop --project \"$project_container\"") - expect(entrypoint.contents).toContain('command = "browser-connection"') - expect(entrypoint.contents).toContain('args = ["--project", "$DOCKER_GIT_BROWSER_PROJECT", "--network", "$DOCKER_GIT_BROWSER_NETWORK"]') + expect(entrypoint.contents).toContain("command = \"browser-connection\"") + expect(entrypoint.contents).toContain( + "args = [\"--project\", \"$DOCKER_GIT_BROWSER_PROJECT\", \"--network\", \"$DOCKER_GIT_BROWSER_NETWORK\"]" + ) }) }) diff --git a/packages/terminal/biome.json b/packages/terminal/biome.json new file mode 100644 index 00000000..27008332 --- /dev/null +++ b/packages/terminal/biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", + "vcs": { + "enabled": false, + "clientKind": "git", + "useIgnoreFile": false + }, + "files": { + "ignoreUnknown": false + }, + "assist": { + "enabled": false + }, + "formatter": { + "enabled": false, + "indentStyle": "tab" + }, + "linter": { + "enabled": false, + "rules": { + "recommended": false, + "suspicious": { + "noExplicitAny": "off" + } + } + }, + "javascript": { + "formatter": { + "enabled": false, + "quoteStyle": "double", + "semicolons": "asNeeded" + } + } +} diff --git a/packages/terminal/eslint.config.mjs b/packages/terminal/eslint.config.mjs new file mode 100644 index 00000000..9abe7b40 --- /dev/null +++ b/packages/terminal/eslint.config.mjs @@ -0,0 +1,27 @@ +import js from "@eslint/js" +import globals from "globals" +import tseslint from "typescript-eslint" + +export default tseslint.config( + { + ignores: ["dist/**"] + }, + js.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + { + files: ["**/*.ts"], + languageOptions: { + globals: { + ...globals.node + }, + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname + } + }, + rules: { + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-explicit-any": "error" + } + } +) diff --git a/packages/terminal/eslint.effect-ts-check.config.mjs b/packages/terminal/eslint.effect-ts-check.config.mjs new file mode 100644 index 00000000..428921bf --- /dev/null +++ b/packages/terminal/eslint.effect-ts-check.config.mjs @@ -0,0 +1,3 @@ +import baseConfig from "./eslint.config.mjs" + +export default baseConfig diff --git a/packages/terminal/package.json b/packages/terminal/package.json new file mode 100644 index 00000000..271e17c2 --- /dev/null +++ b/packages/terminal/package.json @@ -0,0 +1,74 @@ +{ + "name": "@prover-coder-ai/docker-git-terminal", + "version": "0.1.0", + "private": true, + "description": "Shared docker-git terminal contracts, core logic, and runtime adapters", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "type": "module", + "packageManager": "bun@1.3.11", + "scripts": { + "build": "tsc -p tsconfig.json", + "check": "bun run typecheck", + "dev": "tsc -p tsconfig.json --watch", + "lint": "NODE_OPTIONS=--max-old-space-size=4096 PATH=../../scripts:$PATH vibecode-linter src/", + "lint:effect": "eslint --config eslint.effect-ts-check.config.mjs src", + "test": "vitest run", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@effect/platform": "^0.96.1", + "@effect/platform-node": "^0.106.0", + "@effect/schema": "^0.75.5", + "effect": "^3.21.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.16", + "@effect/vitest": "^0.29.0", + "@eslint/js": "10.0.1", + "@ton-ai-core/vibecode-linter": "^1.0.11", + "@types/node": "^25.9.1", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^10.4.1", + "globals": "^17.6.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.60.0", + "vitest": "^4.1.7" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./contracts": { + "types": "./dist/contracts/index.d.ts", + "import": "./dist/contracts/index.js" + }, + "./core": { + "types": "./dist/core/index.d.ts", + "import": "./dist/core/index.js" + }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, + "./web": { + "types": "./dist/web/index.d.ts", + "import": "./dist/web/index.js" + }, + "./cli": { + "types": "./dist/cli/index.d.ts", + "import": "./dist/cli/index.js" + }, + "./shell": { + "types": "./dist/shell/index.d.ts", + "import": "./dist/shell/index.js" + } + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ProverCoderAI/docker-git.git" + }, + "license": "MIT" +} diff --git a/packages/terminal/src/app/main.ts b/packages/terminal/src/app/main.ts new file mode 100644 index 00000000..db22e2aa --- /dev/null +++ b/packages/terminal/src/app/main.ts @@ -0,0 +1,18 @@ +import { NodeContext, NodeRuntime } from "@effect/platform-node"; +import { Effect, pipe } from "effect"; + +import { program } from "./program.js"; + +// CHANGE: add a thin Node runtime entrypoint for the terminal package. +// WHY: follow the effect-template shape and keep runtime provision outside core/contracts. +// QUOTE(ТЗ): "Используй так же модуль" +// REF: issue-361-terminal-package +// SOURCE: https://github.com/ProverCoderAI/effect-template/tree/main/packages/app +// FORMAT THEOREM: main = provide(program, NodeContext) +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: NodeContext is provided only at the package runtime boundary. +// COMPLEXITY: O(1)/O(1) +const main = pipe(program, Effect.provide(NodeContext.layer)); + +NodeRuntime.runMain(main); diff --git a/packages/terminal/src/app/program.ts b/packages/terminal/src/app/program.ts new file mode 100644 index 00000000..a7767987 --- /dev/null +++ b/packages/terminal/src/app/program.ts @@ -0,0 +1,13 @@ +import { Effect } from "effect"; + +// CHANGE: define a reusable terminal package program entrypoint. +// WHY: mirror effect-template's thin app/program composition while keeping library exports as the primary surface. +// QUOTE(ТЗ): "Используй так же модуль: https://github.com/ProverCoderAI/effect-template/tree/main/packages/app" +// REF: issue-361-terminal-package +// SOURCE: https://github.com/ProverCoderAI/effect-template/tree/main/packages/app +// FORMAT THEOREM: run(program) -> no_op +// PURITY: SHELL +// EFFECT: Effect +// INVARIANT: terminal package entrypoint has no side effects until a concrete adapter composes it. +// COMPLEXITY: O(1)/O(1) +export const program = Effect.void; diff --git a/packages/terminal/src/cli/index.ts b/packages/terminal/src/cli/index.ts new file mode 100644 index 00000000..36754d74 --- /dev/null +++ b/packages/terminal/src/cli/index.ts @@ -0,0 +1,3 @@ +export type TerminalCliRuntimeBoundary = { + readonly _tag: "TerminalCliRuntimeBoundary"; +}; diff --git a/packages/terminal/src/contracts/index.ts b/packages/terminal/src/contracts/index.ts new file mode 100644 index 00000000..9df91886 --- /dev/null +++ b/packages/terminal/src/contracts/index.ts @@ -0,0 +1 @@ +export * from "./session.js"; diff --git a/packages/terminal/src/contracts/session.ts b/packages/terminal/src/contracts/session.ts new file mode 100644 index 00000000..57eec168 --- /dev/null +++ b/packages/terminal/src/contracts/session.ts @@ -0,0 +1,81 @@ +import * as Schema from "@effect/schema/Schema"; + +export const TerminalSessionStatusSchema = Schema.Union( + Schema.Literal("ready"), + Schema.Literal("attached"), + Schema.Literal("exited"), + Schema.Literal("failed"), +); + +export const TerminalSessionSchema = Schema.Struct({ + id: Schema.String, + projectId: Schema.String, + sshCommand: Schema.String, + status: TerminalSessionStatusSchema, + createdAt: Schema.String, + attachedClients: Schema.optional(Schema.Number), + startedAt: Schema.optional(Schema.String), + closedAt: Schema.optional(Schema.String), + exitCode: Schema.optional(Schema.Number), + signal: Schema.optional(Schema.Number), +}); + +export const TerminalClientMessagePayloadSchema = Schema.Union( + Schema.Struct({ + type: Schema.Literal("input"), + data: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("resize"), + cols: Schema.Number, + rows: Schema.Number, + }), + Schema.Struct({ + type: Schema.Literal("image"), + data: Schema.String, + mediaType: Schema.String, + name: Schema.String, + size: Schema.Number, + }), + Schema.Struct({ + type: Schema.Literal("close"), + }), +); + +const TerminalServerMessagePayloadSchema = Schema.Union( + Schema.Struct({ + type: Schema.Literal("ready"), + session: TerminalSessionSchema, + }), + Schema.Struct({ + type: Schema.Literal("output"), + data: Schema.String, + }), + Schema.Struct({ + type: Schema.Literal("exit"), + exitCode: Schema.NullOr(Schema.Number), + signal: Schema.NullOr(Schema.Number), + }), + Schema.Struct({ + type: Schema.Literal("error"), + message: Schema.String, + }), +); + +export const TerminalClientMessageSchema = Schema.parseJson( + TerminalClientMessagePayloadSchema, +); +export const TerminalServerMessageSchema = Schema.parseJson( + TerminalServerMessagePayloadSchema, +); + +export type TerminalSessionStatus = Schema.Schema.Type< + typeof TerminalSessionStatusSchema +>; +export type TerminalSession = Schema.Schema.Type; +export type TerminalClientMessage = Schema.Schema.Type< + typeof TerminalClientMessagePayloadSchema +>; +export type TerminalServerMessage = Schema.Schema.Type< + typeof TerminalServerMessagePayloadSchema +>; diff --git a/packages/terminal/src/core/image-paste.ts b/packages/terminal/src/core/image-paste.ts new file mode 100644 index 00000000..49134ca0 --- /dev/null +++ b/packages/terminal/src/core/image-paste.ts @@ -0,0 +1,130 @@ +export type TerminalImagePastePayload = { + readonly data: string; + readonly mediaType: string; + readonly name: string; + readonly size: number; +}; + +export type TerminalImagePastePlan = + | { + readonly _tag: "InvalidTerminalImagePaste"; + readonly message: string; + } + | { + readonly _tag: "ValidTerminalImagePaste"; + readonly containerPath: string; + readonly decodedBytes: number; + readonly normalizedBase64: string; + }; + +export const terminalImagePasteDirectory = + "/home/dev/.docker-git/pasted-images"; +export const terminalImagePasteMaxBytes = 10 * 1024 * 1024; + +const base64Pattern = + /^(?:[+/0-9A-Za-z]{4})*(?:[+/0-9A-Za-z]{2}==|[+/0-9A-Za-z]{3}=)?$/u; +const terminalImagePasteMaxBase64Length = + Math.ceil(terminalImagePasteMaxBytes / 3) * 4; +const safeFileNameMaxLength = 72; + +const imageMediaTypeExtensions = new Map([ + ["image/gif", "gif"], + ["image/jpeg", "jpg"], + ["image/png", "png"], + ["image/webp", "webp"], +]); + +export const isSupportedTerminalImageMediaType = (mediaType: string): boolean => + imageMediaTypeExtensions.has(mediaType.toLowerCase()); + +const extensionForMediaType = (mediaType: string): string | null => + imageMediaTypeExtensions.get(mediaType.toLowerCase()) ?? null; + +const normalizeBase64 = (data: string): string => data.replace(/\s+/gu, ""); + +const decodedBase64Bytes = (data: string): number | null => { + if (data.length === 0 || data.length % 4 !== 0 || !base64Pattern.test(data)) { + return null; + } + const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0; + return (data.length / 4) * 3 - padding; +}; + +const lastPathSegment = (name: string): string => { + const segments = name.split(/[\\/]/u); + return segments.at(-1) ?? ""; +}; + +export const sanitizeTerminalImageBaseName = (name: string): string => { + const withoutExtension = lastPathSegment(name).replace(/\.[^.]*$/u, ""); + const sanitized = withoutExtension + .replace(/[^0-9A-Za-z._-]+/gu, "-") + .replace(/^[.-]+/u, "") + .replace(/[.-]+$/u, "") + .slice(0, safeFileNameMaxLength); + return sanitized.length > 0 ? sanitized : "clipboard-image"; +}; + +const terminalImageFileName = ( + id: string, + name: string, + mediaType: string, +): string | null => { + const extension = extensionForMediaType(mediaType); + if (extension === null) { + return null; + } + return `${id}-${sanitizeTerminalImageBaseName(name)}.${extension}`; +}; + +export const createTerminalImagePastePlan = ( + payload: TerminalImagePastePayload, + id: string, +): TerminalImagePastePlan => { + const mediaType = payload.mediaType.toLowerCase(); + const fileName = terminalImageFileName(id, payload.name, mediaType); + if (fileName === null) { + return { + _tag: "InvalidTerminalImagePaste", + message: `Unsupported image type: ${payload.mediaType || "unknown"}.`, + }; + } + if (!Number.isFinite(payload.size) || payload.size <= 0) { + return { + _tag: "InvalidTerminalImagePaste", + message: "Image payload is empty.", + }; + } + if (payload.size > terminalImagePasteMaxBytes) { + return { + _tag: "InvalidTerminalImagePaste", + message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.`, + }; + } + const normalizedBase64 = normalizeBase64(payload.data); + if (normalizedBase64.length > terminalImagePasteMaxBase64Length) { + return { + _tag: "InvalidTerminalImagePaste", + message: `Image is too large. Max size is ${terminalImagePasteMaxBytes} bytes.`, + }; + } + const decodedBytes = decodedBase64Bytes(normalizedBase64); + if (decodedBytes === null) { + return { + _tag: "InvalidTerminalImagePaste", + message: "Image payload is not valid base64.", + }; + } + if (decodedBytes !== payload.size) { + return { + _tag: "InvalidTerminalImagePaste", + message: "Image payload size does not match its base64 data.", + }; + } + return { + _tag: "ValidTerminalImagePaste", + containerPath: `${terminalImagePasteDirectory}/${fileName}`, + decodedBytes, + normalizedBase64, + }; +}; diff --git a/packages/terminal/src/core/index.ts b/packages/terminal/src/core/index.ts new file mode 100644 index 00000000..460398c6 --- /dev/null +++ b/packages/terminal/src/core/index.ts @@ -0,0 +1,2 @@ +export * from "./image-paste.js"; +export * from "./output-buffer.js"; diff --git a/packages/terminal/src/core/output-buffer.ts b/packages/terminal/src/core/output-buffer.ts new file mode 100644 index 00000000..26d5d333 --- /dev/null +++ b/packages/terminal/src/core/output-buffer.ts @@ -0,0 +1,69 @@ +export type TerminalOutputBuffer = { + readonly charLength: number; + readonly chunks: ReadonlyArray; +}; + +export const terminalOutputReplayMaxChars = 2 * 1024 * 1024; + +export const emptyTerminalOutputBuffer: TerminalOutputBuffer = { + charLength: 0, + chunks: [], +}; + +const boundedMaxChars = (maxChars: number): number => + Number.isFinite(maxChars) ? Math.max(0, Math.floor(maxChars)) : 0; + +const trimTerminalOutputChunks = ( + chunks: ReadonlyArray, + overflow: number, +): ReadonlyArray => { + const kept: Array = []; + let remainingOverflow = overflow; + for (const chunk of chunks) { + if (remainingOverflow <= 0) { + kept.push(chunk); + continue; + } + if (chunk.length <= remainingOverflow) { + remainingOverflow -= chunk.length; + continue; + } + kept.push(chunk.slice(remainingOverflow)); + remainingOverflow = 0; + } + return kept; +}; + +export const appendTerminalOutput = ( + buffer: TerminalOutputBuffer, + data: string, + maxChars = terminalOutputReplayMaxChars, +): TerminalOutputBuffer => { + const boundedMax = boundedMaxChars(maxChars); + if (boundedMax === 0) { + return emptyTerminalOutputBuffer; + } + if (data.length === 0) { + return buffer; + } + if (data.length >= boundedMax) { + return { + charLength: boundedMax, + chunks: [data.slice(data.length - boundedMax)], + }; + } + const charLength = buffer.charLength + data.length; + const chunks = [...buffer.chunks, data]; + if (charLength <= boundedMax) { + return { charLength, chunks }; + } + const overflow = charLength - boundedMax; + return { + charLength: boundedMax, + chunks: trimTerminalOutputChunks(chunks, overflow), + }; +}; + +export const renderTerminalOutputBuffer = ( + buffer: TerminalOutputBuffer, +): string => buffer.chunks.join(""); diff --git a/packages/terminal/src/index.ts b/packages/terminal/src/index.ts new file mode 100644 index 00000000..15e95776 --- /dev/null +++ b/packages/terminal/src/index.ts @@ -0,0 +1,2 @@ +export * from "./contracts/index.js"; +export * from "./core/index.js"; diff --git a/packages/terminal/src/server/image-fetch.ts b/packages/terminal/src/server/image-fetch.ts new file mode 100644 index 00000000..0f079924 --- /dev/null +++ b/packages/terminal/src/server/image-fetch.ts @@ -0,0 +1,213 @@ +import { fileURLToPath } from "node:url"; + +export type TerminalImageFetchPlan = + | { + readonly _tag: "InvalidTerminalImageFetch"; + readonly message: string; + } + | { + readonly _tag: "ValidTerminalImageFetch"; + readonly containerPath: string; + readonly mediaType: string; + }; + +export const terminalImageFetchMaxBytes = 10 * 1024 * 1024; + +const supportedExtensionMediaTypes = new Map([ + ["gif", "image/gif"], + ["jpeg", "image/jpeg"], + ["jpg", "image/jpeg"], + ["png", "image/png"], + ["webp", "image/webp"], +]); + +const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1f)}`; +const deleteChar = String.fromCodePoint(0x7f); +const invalidCharacterPattern = new RegExp( + String.raw`[\s${controlCharRange}${deleteChar}]`, + "u", +); +const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u; +const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u; +const fileUrlPattern = /^file:\/\//iu; +const encodedPathSeparatorPattern = /%(?:2f|5c)/iu; +const fileUrlBackslashPattern = /\\/u; +const fileUrlTraversalPattern = + /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu; + +type TerminalImagePathNormalization = + | { + readonly _tag: "InvalidTerminalImagePath"; + readonly message: string; + } + | { + readonly _tag: "ValidTerminalImagePath"; + readonly path: string; + }; + +const lowercaseExtension = (path: string): string | null => { + const lastDot = path.lastIndexOf("."); + if (lastDot < 0 || lastDot === path.length - 1) { + return null; + } + return path.slice(lastDot + 1).toLowerCase(); +}; + +const rawFileUrlPathname = (path: string): string => { + const withoutScheme = path.slice("file://".length); + const pathStart = withoutScheme.indexOf("/"); + if (pathStart < 0) { + return ""; + } + const pathAndSuffix = withoutScheme.slice(pathStart); + const queryStart = pathAndSuffix.indexOf("?"); + const hashStart = pathAndSuffix.indexOf("#"); + if (queryStart < 0 && hashStart < 0) { + return pathAndSuffix; + } + if (queryStart < 0) { + return pathAndSuffix.slice(0, hashStart); + } + if (hashStart < 0) { + return pathAndSuffix.slice(0, queryStart); + } + return pathAndSuffix.slice(0, Math.min(queryStart, hashStart)); +}; + +const normalizeTerminalImagePath = ( + path: string, +): TerminalImagePathNormalization => { + if (!urlSchemePattern.test(path)) { + return { _tag: "ValidTerminalImagePath", path }; + } + if (!fileUrlPattern.test(path)) { + return { + _tag: "InvalidTerminalImagePath", + message: "Only file:// image URLs are supported.", + }; + } + + const rawPathname = rawFileUrlPathname(path); + if (fileUrlTraversalPattern.test(rawPathname)) { + return { + _tag: "InvalidTerminalImagePath", + message: "Image path must not contain '.' or '..' segments.", + }; + } + if ( + encodedPathSeparatorPattern.test(rawPathname) || + fileUrlBackslashPattern.test(rawPathname) + ) { + return { + _tag: "InvalidTerminalImagePath", + message: + "Image file URL must not contain encoded or backslash path separators.", + }; + } + + try { + const url = new URL(path); + if ( + url.protocol !== "file:" || + (url.hostname !== "" && url.hostname !== "localhost") + ) { + return { + _tag: "InvalidTerminalImagePath", + message: "Image file URL must point to a local path.", + }; + } + if (url.search.length > 0 || url.hash.length > 0) { + return { + _tag: "InvalidTerminalImagePath", + message: "Image file URL must not include query or fragment.", + }; + } + return { + _tag: "ValidTerminalImagePath", + path: fileURLToPath(url, { windows: false }), + }; + } catch { + return { + _tag: "InvalidTerminalImagePath", + message: "Image file URL is invalid.", + }; + } +}; + +export type TerminalImageFetchOptions = { + readonly baseDir?: string; +}; + +const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/"); + +const joinBaseDirAndRelativePath = ( + baseDir: string, + relativePath: string, +): string => { + const trimmedBase = baseDir.replace(/\/+$/u, ""); + return `${trimmedBase}/${relativePath}`; +}; + +export const planTerminalImageFetch = ( + path: string, + options: TerminalImageFetchOptions = {}, +): TerminalImageFetchPlan => { + if (path.length === 0) { + return { + _tag: "InvalidTerminalImageFetch", + message: "Image path is required.", + }; + } + const normalized = normalizeTerminalImagePath(path); + if (normalized._tag === "InvalidTerminalImagePath") { + return { _tag: "InvalidTerminalImageFetch", message: normalized.message }; + } + const normalizedPath = normalized.path; + let containerPath = normalizedPath; + if (!isAbsolutePosixPath(containerPath)) { + const baseDir = options.baseDir; + if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) { + return { + _tag: "InvalidTerminalImageFetch", + message: "Image path must be absolute.", + }; + } + if ( + invalidCharacterPattern.test(baseDir) || + traversalPattern.test(baseDir) + ) { + return { + _tag: "InvalidTerminalImageFetch", + message: "Image base directory is invalid.", + }; + } + containerPath = joinBaseDirAndRelativePath(baseDir, containerPath); + } + if (invalidCharacterPattern.test(containerPath)) { + return { + _tag: "InvalidTerminalImageFetch", + message: "Image path contains invalid characters.", + }; + } + if (traversalPattern.test(containerPath)) { + return { + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments.", + }; + } + const extension = lowercaseExtension(containerPath); + if (extension === null) { + return { + _tag: "InvalidTerminalImageFetch", + message: "Image path must include a file extension.", + }; + } + const mediaType = supportedExtensionMediaTypes.get(extension); + if (mediaType === undefined) { + return { + _tag: "InvalidTerminalImageFetch", + message: `Unsupported image extension: .${extension}`, + }; + } + return { _tag: "ValidTerminalImageFetch", containerPath, mediaType }; +}; diff --git a/packages/terminal/src/server/index.ts b/packages/terminal/src/server/index.ts new file mode 100644 index 00000000..34c1e16e --- /dev/null +++ b/packages/terminal/src/server/index.ts @@ -0,0 +1,51 @@ +import type { Effect } from "effect"; + +import type { TerminalSession } from "../contracts/index.js"; + +export * from "./image-fetch.js"; + +export type TerminalProjectRuntime = { + readonly emitEvent: ( + projectId: string, + event: TerminalSessionEvent, + ) => Effect.Effect; + readonly prepareShell: ( + project: Project, + ) => Effect.Effect; + readonly recordActivity: ( + projectId: string, + ) => Effect.Effect; + readonly resolveProject: ( + projectKey: string, + ) => Effect.Effect; +}; + +export type TerminalShellCommand = { + readonly command: string; + readonly cwd?: string | undefined; + readonly env?: Readonly> | undefined; +}; + +export type TerminalSessionEvent = + | { + readonly _tag: "Created"; + readonly requestId?: string | undefined; + readonly sessionId: string; + } + | { readonly _tag: "Ready"; readonly session: TerminalSession } + | { + readonly _tag: "Output"; + readonly data: string; + readonly sessionId: string; + } + | { + readonly _tag: "Exited"; + readonly exitCode: number | null; + readonly sessionId: string; + readonly signal: number | null; + } + | { + readonly _tag: "Failed"; + readonly message: string; + readonly sessionId: string; + }; diff --git a/packages/terminal/src/shell/index.ts b/packages/terminal/src/shell/index.ts new file mode 100644 index 00000000..d46db3a9 --- /dev/null +++ b/packages/terminal/src/shell/index.ts @@ -0,0 +1,3 @@ +export type TerminalShellRuntimeBoundary = { + readonly _tag: "TerminalShellRuntimeBoundary"; +}; diff --git a/packages/terminal/src/web/index.ts b/packages/terminal/src/web/index.ts new file mode 100644 index 00000000..3b8a7925 --- /dev/null +++ b/packages/terminal/src/web/index.ts @@ -0,0 +1,3 @@ +export type TerminalWebRuntimeBoundary = { + readonly _tag: "TerminalWebRuntimeBoundary"; +}; diff --git a/packages/terminal/tests/architecture/boundaries.test.ts b/packages/terminal/tests/architecture/boundaries.test.ts new file mode 100644 index 00000000..61e836f5 --- /dev/null +++ b/packages/terminal/tests/architecture/boundaries.test.ts @@ -0,0 +1,52 @@ +import { readFileSync, readdirSync } from "node:fs" +import { join, relative } from "node:path" + +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +const sourceRoot = new URL("../../src", import.meta.url) + +const sourceFiles = (directory: string): ReadonlyArray => { + const entries = readdirSync(directory, { withFileTypes: true }) + return entries.flatMap((entry) => { + const path = join(directory, entry.name) + if (entry.isDirectory()) { + return [...sourceFiles(path)] + } + return entry.isFile() && path.endsWith(".ts") ? [path] : [] + }) +} + +const relativeSourcePath = (path: string): string => + relative(sourceRoot.pathname, path) + +const importPattern = /(?:import|export)\s+(?:type\s+)?(?:[^"']*from\s+)?["']([^"']+)["']/gu + +const importsFrom = (path: string): ReadonlyArray => + [...readFileSync(path, "utf8").matchAll(importPattern)].map((match) => match[1] ?? "") + +const bannedCoreImport = (source: string): boolean => + source.includes("/shell") || + source.includes("/server") || + source.includes("/web") || + source.includes("/cli") || + source === "ws" || + source === "xterm" || + source.startsWith("node:") + +describe("terminal package boundaries", () => { + it.effect("keeps contracts and core free of runtime adapter imports", () => + Effect.sync(() => { + const files = [ + ...sourceFiles(new URL("../../src/contracts", import.meta.url).pathname), + ...sourceFiles(new URL("../../src/core", import.meta.url).pathname) + ] + const violations = files.flatMap((file) => + importsFrom(file) + .filter(bannedCoreImport) + .map((source) => `${relativeSourcePath(file)} -> ${source}`) + ) + + expect(violations).toEqual([]) + })) +}) diff --git a/packages/terminal/tests/contracts/session.test.ts b/packages/terminal/tests/contracts/session.test.ts new file mode 100644 index 00000000..03a1f537 --- /dev/null +++ b/packages/terminal/tests/contracts/session.test.ts @@ -0,0 +1,58 @@ +import * as ParseResult from "@effect/schema/ParseResult" +import { describe, expect, it } from "@effect/vitest" +import { Effect, Either } from "effect" + +import { + TerminalClientMessageSchema, + TerminalServerMessageSchema, + type TerminalServerMessage, + TerminalSessionSchema +} from "../../src/contracts/index.js" + +const readyMessage: TerminalServerMessage = { + session: { + createdAt: "2026-04-08T10:00:00.000Z", + id: "session-1", + projectId: "project-1", + sshCommand: "ssh dev@127.0.0.1", + status: "attached" + }, + type: "ready" +} + +describe("terminal contracts", () => { + it.effect("decodes terminal session payloads", () => + Effect.sync(() => { + const result = ParseResult.decodeUnknownEither(TerminalSessionSchema)(readyMessage.session) + + expect(Either.isRight(result)).toBe(true) + })) + + it.effect("decodes JSON server messages", () => + Effect.sync(() => { + const result = ParseResult.decodeUnknownEither(TerminalServerMessageSchema)(JSON.stringify(readyMessage)) + + expect(Either.getOrNull(result)).toEqual(readyMessage) + })) + + it.effect("rejects malformed server messages", () => + Effect.sync(() => { + const result = ParseResult.decodeUnknownEither(TerminalServerMessageSchema)("{\"type\":\"output\",\"data\":1}") + + expect(Either.isLeft(result)).toBe(true) + })) + + it.effect("decodes client input, resize, image, and close messages", () => + Effect.sync(() => { + const messages = [ + { type: "input", data: "ls\n" }, + { type: "resize", cols: 120, rows: 40 }, + { type: "image", data: "aGVsbG8=", mediaType: "image/png", name: "hello.png", size: 5 }, + { type: "close" } + ].map((message) => JSON.stringify(message)) + + expect(messages.every((message) => + Either.isRight(ParseResult.decodeUnknownEither(TerminalClientMessageSchema)(message)) + )).toBe(true) + })) +}) diff --git a/packages/terminal/tests/core/image-paste.test.ts b/packages/terminal/tests/core/image-paste.test.ts new file mode 100644 index 00000000..980c414c --- /dev/null +++ b/packages/terminal/tests/core/image-paste.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { + createTerminalImagePastePlan, + sanitizeTerminalImageBaseName, + terminalImagePasteDirectory +} from "../../src/core/index.js" + +describe("terminal image paste planning", () => { + it.effect("builds a valid paste plan for supported image payloads", () => + Effect.sync(() => { + const plan = createTerminalImagePastePlan( + { data: "aGVsbG8=", mediaType: "image/png", name: "../hello world.png", size: 5 }, + "paste-1" + ) + + expect(plan).toEqual({ + _tag: "ValidTerminalImagePaste", + containerPath: `${terminalImagePasteDirectory}/paste-1-hello-world.png`, + decodedBytes: 5, + normalizedBase64: "aGVsbG8=" + }) + })) + + it.effect("sanitizes unsafe image names", () => + Effect.sync(() => { + expect(sanitizeTerminalImageBaseName("../..//.bad name!!.png")).toBe("bad-name") + expect(sanitizeTerminalImageBaseName("////.png")).toBe("clipboard-image") + })) + + it.effect("rejects invalid base64 payloads", () => + Effect.sync(() => { + const plan = createTerminalImagePastePlan( + { data: "not base64", mediaType: "image/png", name: "bad.png", size: 10 }, + "paste-1" + ) + + expect(plan).toEqual({ + _tag: "InvalidTerminalImagePaste", + message: "Image payload is not valid base64." + }) + })) +}) diff --git a/packages/terminal/tests/core/output-buffer.test.ts b/packages/terminal/tests/core/output-buffer.test.ts new file mode 100644 index 00000000..d70df083 --- /dev/null +++ b/packages/terminal/tests/core/output-buffer.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { + appendTerminalOutput, + emptyTerminalOutputBuffer, + renderTerminalOutputBuffer +} from "../../src/core/index.js" + +describe("terminal output replay buffer", () => { + it.effect("replays appended terminal output in order", () => + Effect.sync(() => { + const buffer = appendTerminalOutput( + appendTerminalOutput(emptyTerminalOutputBuffer, "first\n", 100), + "second\n", + 100 + ) + + expect(renderTerminalOutputBuffer(buffer)).toBe("first\nsecond\n") + })) + + it.effect("keeps only the newest output when the replay budget is exceeded", () => + Effect.sync(() => { + const buffer = appendTerminalOutput( + appendTerminalOutput(emptyTerminalOutputBuffer, "abcdef", 8), + "ghij", + 8 + ) + + expect(buffer.charLength).toBe(8) + expect(renderTerminalOutputBuffer(buffer)).toBe("cdefghij") + })) + + it.effect("trims an oversized chunk to the replay budget", () => + Effect.sync(() => { + const buffer = appendTerminalOutput(emptyTerminalOutputBuffer, "0123456789", 4) + + expect(buffer.charLength).toBe(4) + expect(renderTerminalOutputBuffer(buffer)).toBe("6789") + })) +}) diff --git a/packages/terminal/tests/server/image-fetch.test.ts b/packages/terminal/tests/server/image-fetch.test.ts new file mode 100644 index 00000000..f816bea6 --- /dev/null +++ b/packages/terminal/tests/server/image-fetch.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +import { planTerminalImageFetch } from "../../src/server/index.js" + +describe("terminal image fetch planning", () => { + it.effect("accepts absolute supported image paths", () => + Effect.sync(() => { + expect(planTerminalImageFetch("/home/dev/image.png")).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/home/dev/image.png", + mediaType: "image/png" + }) + })) + + it.effect("joins relative paths to a valid base directory", () => + Effect.sync(() => { + expect(planTerminalImageFetch("screens/shot.webp", { baseDir: "/home/dev/project" })).toEqual({ + _tag: "ValidTerminalImageFetch", + containerPath: "/home/dev/project/screens/shot.webp", + mediaType: "image/webp" + }) + })) + + it.effect("rejects traversal paths", () => + Effect.sync(() => { + expect(planTerminalImageFetch("/home/dev/../secret.png")).toEqual({ + _tag: "InvalidTerminalImageFetch", + message: "Image path must not contain '.' or '..' segments." + }) + })) +}) diff --git a/packages/terminal/tsconfig.json b/packages/terminal/tsconfig.json new file mode 100644 index 00000000..788d2ea0 --- /dev/null +++ b/packages/terminal/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "types": ["vitest", "node"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/terminal/vitest.config.ts b/packages/terminal/vitest.config.ts new file mode 100644 index 00000000..7f8e5e5f --- /dev/null +++ b/packages/terminal/vitest.config.ts @@ -0,0 +1,23 @@ +import path from "node:path" +import { fileURLToPath } from "node:url" +import { defineConfig } from "vitest/config" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default defineConfig({ + test: { + clearMocks: true, + environment: "node", + exclude: ["node_modules", "dist", "dist-test"], + globals: false, + include: ["tests/**/*.{test,spec}.ts"], + mockReset: true, + restoreMocks: true + }, + resolve: { + alias: { + "@": path.resolve(__dirname, "src") + } + } +}) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 773eb65d..01e0510b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: - packages/app - packages/docker-git-session-sync - packages/lib + - packages/terminal diff --git a/scripts/e2e/_lib.sh b/scripts/e2e/_lib.sh index 4cfa6316..6bd3a7ba 100644 --- a/scripts/e2e/_lib.sh +++ b/scripts/e2e/_lib.sh @@ -157,15 +157,17 @@ dg_log_duration() { # The reuse fast path assumes Bun installed the current workspace layout: # root node_modules plus Vite/TypeScript bins for packages/app, packages/lib, -# and packages/docker-git-session-sync. If package names, locations, or the -# package manager change, this check should fail closed and print the missing -# path so CI falls back to a fresh install instead of silently using stale deps. +# packages/docker-git-session-sync, and packages/terminal. If package names, +# locations, or the package manager change, this check should fail closed and +# print the missing path so CI falls back to a fresh install instead of silently +# using stale deps. dg_workspace_install_ready() { local repo_root="$1" local required_bins=( "$repo_root/packages/app/node_modules/.bin/vite" "$repo_root/packages/lib/node_modules/.bin/tsc" "$repo_root/packages/docker-git-session-sync/node_modules/.bin/vite" + "$repo_root/packages/terminal/node_modules/.bin/tsc" ) local bin