Skip to content

Commit 000c86c

Browse files
committed
feat(terminal): add shared terminal package
1 parent b4f8e31 commit 000c86c

45 files changed

Lines changed: 1127 additions & 488 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/check.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ jobs:
2323
- uses: actions/checkout@v6
2424
- name: Install dependencies
2525
uses: ./.github/actions/setup
26+
- name: Build (terminal package)
27+
run: bun run --cwd packages/terminal build
2628
- name: Build (docker-git package)
2729
run: bun run --cwd packages/app build
2830
- name: Build (session sync package)
@@ -58,6 +60,8 @@ jobs:
5860
- uses: actions/checkout@v6
5961
- name: Install dependencies
6062
uses: ./.github/actions/setup
63+
- name: Typecheck (terminal)
64+
run: bun run --cwd packages/terminal typecheck
6165
- name: Typecheck (app)
6266
run: bun run --cwd packages/app check
6367
- name: Typecheck (session sync)
@@ -80,6 +84,8 @@ jobs:
8084
- uses: actions/checkout@v6
8185
- name: Install dependencies
8286
uses: ./.github/actions/setup
87+
- name: Lint (terminal)
88+
run: bun run --cwd packages/terminal lint
8389
- name: Lint (app)
8490
run: bun run --cwd packages/app lint
8591
- name: Lint (session sync)
@@ -103,6 +109,8 @@ jobs:
103109
- uses: actions/checkout@v6
104110
- name: Install dependencies
105111
uses: ./.github/actions/setup
112+
- name: Test (terminal)
113+
run: bun run --cwd packages/terminal test
106114
- name: Test (app)
107115
run: bun run --cwd packages/app test
108116
- name: Test (session sync)
@@ -125,6 +133,8 @@ jobs:
125133
- uses: actions/checkout@v6
126134
- name: Install dependencies
127135
uses: ./.github/actions/setup
136+
- name: Lint Effect-TS (terminal)
137+
run: bun run --cwd packages/terminal lint:effect
128138
- name: Lint Effect-TS (app)
129139
run: bun run --cwd packages/app lint:effect
130140
- name: Lint Effect-TS (session sync)

bun.lock

Lines changed: 30 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@
88
"packages/api",
99
"packages/app",
1010
"packages/docker-git-session-sync",
11-
"packages/lib"
11+
"packages/lib",
12+
"packages/terminal"
1213
],
1314
"scripts": {
1415
"setup:pre-commit-hook": "bun scripts/setup-pre-commit-hook.js",
15-
"build": "bun run --filter @prover-coder-ai/docker-git-session-sync build && bun run --filter @prover-coder-ai/docker-git build",
16+
"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",
1617
"api:build": "bun run --filter @effect-template/api build",
1718
"api:start": "bun run --filter @effect-template/api start",
1819
"api:dev": "bun run --filter @effect-template/api dev",
1920
"api:test": "bun run --filter @effect-template/api test",
2021
"api:typecheck": "bun run --filter @effect-template/api typecheck",
21-
"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",
22+
"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",
2223
"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",
2324
"changeset": "changeset",
2425
"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 @@
4243
"web:build": "bun run --cwd packages/app build:web",
4344
"web:preview": "bun run --cwd packages/app preview:web",
4445
"web:serve": "bun run --cwd packages/app serve:web",
45-
"lint": "bun run --filter @prover-coder-ai/docker-git lint && bun run --filter @effect-template/lib lint",
46+
"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",
4647
"lint:tests": "bun run --filter @prover-coder-ai/docker-git lint:tests",
47-
"lint:effect": "bun run --filter @prover-coder-ai/docker-git lint:effect && bun run --filter @effect-template/lib lint:effect",
48-
"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",
49-
"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",
48+
"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",
49+
"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",
50+
"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",
5051
"start": "bun run --cwd packages/app build:docker-git && bun ./packages/app/dist/src/docker-git/main.js"
5152
},
5253
"devDependencies": {

packages/api/package.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@
77
"type": "module",
88
"packageManager": "bun@1.3.11",
99
"scripts": {
10-
"prebuild": "bun run --cwd ../lib build",
10+
"prebuild": "bun run --cwd ../terminal build && bun run --cwd ../lib build",
1111
"build": "tsc -p tsconfig.json",
1212
"dev": "tsc -p tsconfig.json --watch",
1313
"prestart": "bun run build",
1414
"start": "bun dist/src/main.js",
15-
"pretypecheck": "bun run --cwd ../lib build",
15+
"pretypecheck": "bun run --cwd ../terminal build && bun run --cwd ../lib build",
1616
"typecheck": "tsc --noEmit -p tsconfig.json",
1717
"lint": "eslint .",
18-
"pretest": "bun run --cwd ../lib build",
18+
"pretest": "bun run --cwd ../terminal build && bun run --cwd ../lib build",
1919
"test": "vitest run"
2020
},
2121
"dependencies": {
2222
"@effect-template/lib": "workspace:*",
23+
"@prover-coder-ai/docker-git-terminal": "workspace:*",
2324
"@effect/platform": "^0.96.1",
2425
"@effect/platform-node": "^0.106.0",
2526
"@effect/schema": "^0.75.5",

packages/api/src/services/auth-terminal-sessions.ts

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import * as ParseResult from "@effect/schema/ParseResult"
22
import * as Schema from "@effect/schema/Schema"
3+
import {
4+
appendTerminalOutput,
5+
emptyTerminalOutputBuffer,
6+
renderTerminalOutputBuffer,
7+
type TerminalOutputBuffer
8+
} from "@prover-coder-ai/docker-git-terminal/core"
9+
import type { TerminalServerMessage } from "@prover-coder-ai/docker-git-terminal/contracts"
310
import { Either, Effect, Match } from "effect"
411
import { randomUUID } from "node:crypto"
512
import { fileURLToPath } from "node:url"
@@ -10,25 +17,13 @@ import { WebSocket, WebSocketServer, type RawData } from "ws"
1017
import type { AuthTerminalFlow, AuthTerminalSessionRequest, TerminalSession, TerminalSessionStatus } from "../api/contracts.js"
1118
import { ApiConflictError, ApiNotFoundError, describeUnknown } from "../api/errors.js"
1219
import { spawnPtyBridge, type PtyBridge } from "./pty-bridge.js"
13-
import {
14-
appendTerminalOutput,
15-
emptyTerminalOutputBuffer,
16-
renderTerminalOutputBuffer,
17-
type TerminalOutputBuffer
18-
} from "./terminal-output-buffer.js"
1920
import { attachWebSocketHeartbeat } from "./websocket-heartbeat.js"
2021

2122
type TerminalClientMessage =
2223
| { readonly type: "input"; readonly data: string }
2324
| { readonly type: "resize"; readonly cols: number; readonly rows: number }
2425
| { readonly type: "close" }
2526

26-
type TerminalServerMessage =
27-
| { readonly type: "ready"; readonly session: TerminalSession }
28-
| { readonly type: "output"; readonly data: string }
29-
| { readonly type: "exit"; readonly exitCode: number | null; readonly signal: number | null }
30-
| { readonly type: "error"; readonly message: string }
31-
3227
type AuthTerminalRecord = {
3328
attachTimeout: ReturnType<typeof setTimeout> | null
3429
args: ReadonlyArray<string>
Lines changed: 6 additions & 155 deletions
Original file line numberDiff line numberDiff line change
@@ -1,155 +1,6 @@
1-
import { fileURLToPath } from "node:url"
2-
3-
export type TerminalImageFetchPlan =
4-
| {
5-
readonly _tag: "InvalidTerminalImageFetch"
6-
readonly message: string
7-
}
8-
| {
9-
readonly _tag: "ValidTerminalImageFetch"
10-
readonly containerPath: string
11-
readonly mediaType: string
12-
}
13-
14-
export const terminalImageFetchMaxBytes = 10 * 1024 * 1024
15-
16-
const supportedExtensionMediaTypes = new Map<string, string>([
17-
["gif", "image/gif"],
18-
["jpeg", "image/jpeg"],
19-
["jpg", "image/jpeg"],
20-
["png", "image/png"],
21-
["webp", "image/webp"]
22-
])
23-
24-
const controlCharRange = `${String.fromCodePoint(0)}-${String.fromCodePoint(0x1F)}`
25-
const deleteChar = String.fromCodePoint(0x7F)
26-
const invalidCharacterPattern = new RegExp(String.raw`[\s${controlCharRange}${deleteChar}]`, "u")
27-
const traversalPattern = /(?:^|\/)(?:\.|\.\.)(?=\/|$)/u
28-
const urlSchemePattern = /^[A-Za-z][A-Za-z0-9+.-]*:/u
29-
const fileUrlPattern = /^file:\/\//iu
30-
const encodedPathSeparatorPattern = /%(?:2f|5c)/iu
31-
const fileUrlBackslashPattern = /\\/u
32-
const fileUrlTraversalPattern = /(?:^|[\\/])(?:\.|%2e)(?:(?:\.|%2e))?(?=[\\/]|$)/iu
33-
34-
type TerminalImagePathNormalization =
35-
| {
36-
readonly _tag: "InvalidTerminalImagePath"
37-
readonly message: string
38-
}
39-
| {
40-
readonly _tag: "ValidTerminalImagePath"
41-
readonly path: string
42-
}
43-
44-
const lowercaseExtension = (path: string): string | null => {
45-
const lastDot = path.lastIndexOf(".")
46-
if (lastDot < 0 || lastDot === path.length - 1) {
47-
return null
48-
}
49-
return path.slice(lastDot + 1).toLowerCase()
50-
}
51-
52-
const rawFileUrlPathname = (path: string): string => {
53-
const withoutScheme = path.slice("file://".length)
54-
const pathStart = withoutScheme.indexOf("/")
55-
if (pathStart < 0) {
56-
return ""
57-
}
58-
const pathAndSuffix = withoutScheme.slice(pathStart)
59-
const queryStart = pathAndSuffix.indexOf("?")
60-
const hashStart = pathAndSuffix.indexOf("#")
61-
if (queryStart < 0 && hashStart < 0) {
62-
return pathAndSuffix
63-
}
64-
if (queryStart < 0) {
65-
return pathAndSuffix.slice(0, hashStart)
66-
}
67-
if (hashStart < 0) {
68-
return pathAndSuffix.slice(0, queryStart)
69-
}
70-
return pathAndSuffix.slice(0, Math.min(queryStart, hashStart))
71-
}
72-
73-
const normalizeTerminalImagePath = (path: string): TerminalImagePathNormalization => {
74-
if (!urlSchemePattern.test(path)) {
75-
return { _tag: "ValidTerminalImagePath", path }
76-
}
77-
if (!fileUrlPattern.test(path)) {
78-
return { _tag: "InvalidTerminalImagePath", message: "Only file:// image URLs are supported." }
79-
}
80-
81-
const rawPathname = rawFileUrlPathname(path)
82-
if (fileUrlTraversalPattern.test(rawPathname)) {
83-
return { _tag: "InvalidTerminalImagePath", message: "Image path must not contain '.' or '..' segments." }
84-
}
85-
if (encodedPathSeparatorPattern.test(rawPathname) || fileUrlBackslashPattern.test(rawPathname)) {
86-
return {
87-
_tag: "InvalidTerminalImagePath",
88-
message: "Image file URL must not contain encoded or backslash path separators."
89-
}
90-
}
91-
92-
try {
93-
const url = new URL(path)
94-
if (url.protocol !== "file:" || (url.hostname !== "" && url.hostname !== "localhost")) {
95-
return { _tag: "InvalidTerminalImagePath", message: "Image file URL must point to a local path." }
96-
}
97-
if (url.search.length > 0 || url.hash.length > 0) {
98-
return { _tag: "InvalidTerminalImagePath", message: "Image file URL must not include query or fragment." }
99-
}
100-
return { _tag: "ValidTerminalImagePath", path: fileURLToPath(url, { windows: false }) }
101-
} catch {
102-
return { _tag: "InvalidTerminalImagePath", message: "Image file URL is invalid." }
103-
}
104-
}
105-
106-
export type TerminalImageFetchOptions = {
107-
readonly baseDir?: string
108-
}
109-
110-
const isAbsolutePosixPath = (value: string): boolean => value.startsWith("/")
111-
112-
const joinBaseDirAndRelativePath = (baseDir: string, relativePath: string): string => {
113-
const trimmedBase = baseDir.replace(/\/+$/u, "")
114-
return `${trimmedBase}/${relativePath}`
115-
}
116-
117-
export const planTerminalImageFetch = (
118-
path: string,
119-
options: TerminalImageFetchOptions = {}
120-
): TerminalImageFetchPlan => {
121-
if (typeof path !== "string" || path.length === 0) {
122-
return { _tag: "InvalidTerminalImageFetch", message: "Image path is required." }
123-
}
124-
const normalized = normalizeTerminalImagePath(path)
125-
if (normalized._tag === "InvalidTerminalImagePath") {
126-
return { _tag: "InvalidTerminalImageFetch", message: normalized.message }
127-
}
128-
const normalizedPath = normalized.path
129-
let containerPath = normalizedPath
130-
if (!isAbsolutePosixPath(containerPath)) {
131-
const baseDir = options.baseDir
132-
if (baseDir === undefined || !isAbsolutePosixPath(baseDir)) {
133-
return { _tag: "InvalidTerminalImageFetch", message: "Image path must be absolute." }
134-
}
135-
if (invalidCharacterPattern.test(baseDir) || traversalPattern.test(baseDir)) {
136-
return { _tag: "InvalidTerminalImageFetch", message: "Image base directory is invalid." }
137-
}
138-
containerPath = joinBaseDirAndRelativePath(baseDir, containerPath)
139-
}
140-
if (invalidCharacterPattern.test(containerPath)) {
141-
return { _tag: "InvalidTerminalImageFetch", message: "Image path contains invalid characters." }
142-
}
143-
if (traversalPattern.test(containerPath)) {
144-
return { _tag: "InvalidTerminalImageFetch", message: "Image path must not contain '.' or '..' segments." }
145-
}
146-
const extension = lowercaseExtension(containerPath)
147-
if (extension === null) {
148-
return { _tag: "InvalidTerminalImageFetch", message: "Image path must include a file extension." }
149-
}
150-
const mediaType = supportedExtensionMediaTypes.get(extension)
151-
if (mediaType === undefined) {
152-
return { _tag: "InvalidTerminalImageFetch", message: `Unsupported image extension: .${extension}` }
153-
}
154-
return { _tag: "ValidTerminalImageFetch", containerPath, mediaType }
155-
}
1+
export {
2+
planTerminalImageFetch,
3+
terminalImageFetchMaxBytes,
4+
type TerminalImageFetchOptions,
5+
type TerminalImageFetchPlan
6+
} from "@prover-coder-ai/docker-git-terminal/server"

0 commit comments

Comments
 (0)