@deckbuilderkit/terminal 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,40 @@
1
+ # DeckBuilder shell integration for PowerShell and pwsh, run after the user's profile.
2
+ # Wraps the existing prompt with OSC 133 marks and OSC 7; PSReadLine's ReadLine gets the C mark.
3
+ if (-not $global:__dbSi) {
4
+ $global:__dbSi = $true
5
+ $global:__dbFirst = $true
6
+ $global:__dbUserPrompt = $function:prompt
7
+ function global:prompt {
8
+ $ok = $?
9
+ $code = $global:LASTEXITCODE
10
+ $e = [char]27
11
+ $a = [char]7
12
+ $out = ''
13
+ if (-not $global:__dbFirst) {
14
+ $ec = if ($ok) { 0 } elseif ($code -is [int] -and $code -ne 0) { $code } else { 1 }
15
+ $out += "$e]133;D;$ec$a"
16
+ }
17
+ $global:__dbFirst = $false
18
+ $loc = $executionContext.SessionState.Path.CurrentLocation
19
+ if ($loc.Provider.Name -eq 'FileSystem') {
20
+ $p = $loc.ProviderPath -replace '\', '/'
21
+ if ($p -match '^[A-Za-z]:') { $p = '/' + $p }
22
+ if ($p -match '^/[^/]') {
23
+ $hostName = if ($env:COMPUTERNAME) { $env:COMPUTERNAME } else { 'localhost' }
24
+ $out += "$e]7;file://$hostName$($p.Replace('%', '%25'))$a"
25
+ }
26
+ }
27
+ $out += "$e]133;A$a"
28
+ $inner = if ($global:__dbUserPrompt) { & $global:__dbUserPrompt } else { "PS $loc$('>' * ($nestedPromptLevel + 1)) " }
29
+ $global:LASTEXITCODE = $code
30
+ "$out$inner$e]133;B$a"
31
+ }
32
+ if (Get-Command PSConsoleHostReadLine -ErrorAction SilentlyContinue) {
33
+ $global:__dbUserReadLine = $function:PSConsoleHostReadLine
34
+ function global:PSConsoleHostReadLine {
35
+ $line = & $global:__dbUserReadLine
36
+ [Console]::Write("$([char]27)]133;C$([char]7)")
37
+ $line
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,16 @@
1
+ declare module '*.sh' {
2
+ const text: string;
3
+ export default text;
4
+ }
5
+ declare module '*.zsh' {
6
+ const text: string;
7
+ export default text;
8
+ }
9
+ declare module '*.fish' {
10
+ const text: string;
11
+ export default text;
12
+ }
13
+ declare module '*.ps1' {
14
+ const text: string;
15
+ export default text;
16
+ }
@@ -0,0 +1,7 @@
1
+ # DeckBuilder shell integration for zsh: forwards __DB_FILE__ to the user's ZDOTDIR.
2
+ __db_ours=$ZDOTDIR
3
+ ZDOTDIR=${DECKBUILDER_USER_ZDOTDIR:-$HOME}
4
+ [[ -r $ZDOTDIR/__DB_FILE__ ]] && source $ZDOTDIR/__DB_FILE__
5
+ DECKBUILDER_USER_ZDOTDIR=$ZDOTDIR
6
+ [[ __DB_FILE__ != .zlogin ]] && ZDOTDIR=$__db_ours
7
+ unset __db_ours
@@ -0,0 +1,22 @@
1
+ # DeckBuilder shell integration for zsh: the .zshrc in the integration ZDOTDIR.
2
+ # Sources the user's .zshrc, then adds precmd/preexec hooks for OSC 133 marks and OSC 7.
3
+ __db_ours=$ZDOTDIR
4
+ ZDOTDIR=${DECKBUILDER_USER_ZDOTDIR:-$HOME}
5
+ [[ -r $ZDOTDIR/.zshrc ]] && source $ZDOTDIR/.zshrc
6
+ DECKBUILDER_USER_ZDOTDIR=$ZDOTDIR
7
+ # A login shell still reads .zlogin from here; that one restores the user's ZDOTDIR.
8
+ [[ -o login ]] && ZDOTDIR=$__db_ours
9
+ unset __db_ours
10
+
11
+ __db_first=1
12
+ __db_ec=0
13
+ __db_status() { __db_ec=$? }
14
+ __db_precmd() {
15
+ [[ -z $__db_first ]] && printf '\e]133;D;%s\a' $__db_ec
16
+ __db_first=
17
+ printf '\e]7;file://%s%s\a' ${HOST:-localhost} ${PWD//\%/%25}
18
+ [[ $PS1 == *'133;B'* ]] || PS1=$'%{\e]133;A\a%}'$PS1$'%{\e]133;B\a%}'
19
+ }
20
+ __db_preexec() { printf '\e]133;C\a' }
21
+ precmd_functions=(__db_status $precmd_functions __db_precmd)
22
+ preexec_functions=($preexec_functions __db_preexec)
@@ -0,0 +1,107 @@
1
+ /// <reference path="./integration/scripts.d.ts" />
2
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import type { ShellId } from '../protocol.ts';
5
+ // Text imports: `bun build --compile` inlines them, so the exe carries the scripts and writes them out at runtime.
6
+ import bashScript from './integration/bash.sh' with { type: 'text' };
7
+ import fishScript from './integration/fish.fish' with { type: 'text' };
8
+ import pwshScript from './integration/pwsh.ps1' with { type: 'text' };
9
+ import zshForward from './integration/zsh-forward.zsh' with { type: 'text' };
10
+ import zshrcScript from './integration/zshrc.zsh' with { type: 'text' };
11
+
12
+ /** Shells that get an integration script. */
13
+ export type IntegratedShell = 'bash' | 'git-bash' | 'zsh' | 'fish' | 'powershell' | 'pwsh' | 'cmd';
14
+
15
+ const lf = (s: string) => s.replace(/\r\n/g, '\n');
16
+
17
+ /** Files written under the integration dir: relative path → content. */
18
+ export function integrationFiles(): Record<string, string> {
19
+ const files: Record<string, string> = {
20
+ 'bash.sh': lf(bashScript),
21
+ 'fish.fish': lf(fishScript),
22
+ 'pwsh.ps1': pwshScript.replace(/\r?\n/g, '\r\n'),
23
+ 'zsh/.zshrc': lf(zshrcScript),
24
+ };
25
+ for (const name of ['.zshenv', '.zprofile', '.zlogin']) files[`zsh/${name}`] = lf(zshForward).replaceAll('__DB_FILE__', name);
26
+ return files;
27
+ }
28
+
29
+ const written = new Set<string>();
30
+
31
+ /** Writes the scripts to `dir` once per process (rewriting any that differ). */
32
+ export function ensureIntegrationFiles(dir: string): void {
33
+ if (written.has(dir)) return;
34
+ for (const [rel, content] of Object.entries(integrationFiles())) {
35
+ const path = join(dir, rel);
36
+ let current: string | null = null;
37
+ try {
38
+ current = readFileSync(path, 'utf8');
39
+ } catch {}
40
+ if (current === content) continue;
41
+ mkdirSync(join(path, '..'), { recursive: true });
42
+ writeFileSync(path, content);
43
+ }
44
+ written.add(dir);
45
+ }
46
+
47
+ /** The integrated shell an argv runs: the profile's shell id, or for `auto` the executable's name. */
48
+ export function integratedShell(id: ShellId | 'custom', argv: string[]): IntegratedShell | null {
49
+ let kind: string = id;
50
+ if (id === 'auto') {
51
+ const exe = argv[0] ?? '';
52
+ kind = exe
53
+ .slice(Math.max(exe.lastIndexOf('/'), exe.lastIndexOf('\\')) + 1)
54
+ .toLowerCase()
55
+ .replace(/\.exe$/, '');
56
+ }
57
+ switch (kind) {
58
+ case 'bash':
59
+ case 'git-bash':
60
+ case 'zsh':
61
+ case 'fish':
62
+ case 'powershell':
63
+ case 'pwsh':
64
+ case 'cmd':
65
+ return kind;
66
+ default:
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /** Forward slashes: Windows shells and Git Bash accept them, and they need no escaping. */
72
+ const slashes = (p: string) => p.replace(/\\/g, '/');
73
+
74
+ const LOGIN_FLAGS = new Set(['-l', '--login', '-i']);
75
+
76
+ /**
77
+ * Argv and env that load the integration script for `shell` (files already in `dir`).
78
+ * `env` is the env the session would get otherwise; the result merges into it.
79
+ */
80
+ export function withIntegration(
81
+ shell: IntegratedShell,
82
+ argv: string[],
83
+ dir: string,
84
+ env: Record<string, string | undefined>,
85
+ ): { argv: string[]; env: Record<string, string> } {
86
+ const [exe = '', ...args] = argv;
87
+ switch (shell) {
88
+ case 'bash':
89
+ case 'git-bash':
90
+ // --rcfile is ignored by login shells; the script sources the login files itself.
91
+ return { argv: [exe, '--rcfile', slashes(join(dir, 'bash.sh')), ...args.filter((a) => !LOGIN_FLAGS.has(a)), '-i'], env: {} };
92
+ case 'zsh':
93
+ return { argv, env: { ZDOTDIR: join(dir, 'zsh'), DECKBUILDER_USER_ZDOTDIR: env.ZDOTDIR ?? '' } };
94
+ case 'fish':
95
+ return { argv: [...argv, '--init-command', `source '${join(dir, 'fish.fish').replace(/[\\']/g, '\\$&')}'`], env: {} };
96
+ case 'powershell':
97
+ case 'pwsh': {
98
+ // A script block from the file text: dot-sourcing the file itself would fail under the Restricted policy.
99
+ const path = join(dir, 'pwsh.ps1').replace(/'/g, "''");
100
+ return { argv: [...argv, '-NoExit', '-Command', `. ([scriptblock]::Create([IO.File]::ReadAllText('${path}')))`], env: {} };
101
+ }
102
+ case 'cmd': {
103
+ const user = env.PROMPT || '$P$G';
104
+ return { argv, env: { PROMPT: `$E]133;D$E\\$E]7;file://localhost/$P$E\\$E]133;A$E\\${user}$E]133;B$E\\` } };
105
+ }
106
+ }
107
+ }
@@ -0,0 +1,70 @@
1
+ export const LAUNCHER_ID = /^[a-z][a-z0-9-]{0,31}$/;
2
+
3
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
4
+
5
+ export const isUuid = (x: unknown): x is string => typeof x === 'string' && UUID.test(x);
6
+
7
+ export const isLauncherId = (x: unknown): x is string => typeof x === 'string' && LAUNCHER_ID.test(x);
8
+
9
+ /**
10
+ * A program other than the shell that the client may start by id. The client never sends a program:
11
+ * it sends a launcher id plus args, and only `command` builds the argv.
12
+ *
13
+ * `parse` is the security boundary. Accept only values with a fixed shape (ids, enums, UUIDs) and
14
+ * return null for anything else; never pass free text through, since it could end up as a flag.
15
+ */
16
+ export interface Launcher<A> {
17
+ label: string;
18
+ /** Validates untrusted client args; null = invalid. Accept only fixed-shape values, never free text. */
19
+ parse(args: unknown): A | null;
20
+ command(args: A, ctx: { shell: string[] }): string[];
21
+ env?(args: A): Record<string, string>;
22
+ meta?(args: A): Record<string, unknown>;
23
+ available?(): boolean;
24
+ }
25
+
26
+ export interface ResolvedLaunch {
27
+ cmd: string[];
28
+ env: Record<string, string>;
29
+ label: string;
30
+ meta: Record<string, unknown>;
31
+ launcher: string;
32
+ }
33
+
34
+ export interface Launchers {
35
+ resolve(id: unknown, args: unknown, ctx: { shell: string[] }): ResolvedLaunch | null;
36
+ list(): Array<{ id: string; label: string }>;
37
+ }
38
+
39
+ const isArgv = (x: unknown): x is string[] => Array.isArray(x) && x.length > 0 && x.every((s) => typeof s === 'string');
40
+
41
+ export function launchers(map: Record<string, Launcher<any>>): Launchers {
42
+ const registry = new Map<string, Launcher<any>>();
43
+ for (const id of Object.keys(map)) {
44
+ if (!isLauncherId(id)) throw new Error(`Invalid launcher id: ${JSON.stringify(id)}`);
45
+ registry.set(id, map[id]!);
46
+ }
47
+ const isAvailable = (l: Launcher<unknown>): boolean => l.available?.() !== false;
48
+
49
+ return {
50
+ resolve(id, args, ctx) {
51
+ if (!isLauncherId(id)) return null;
52
+ const launcher = registry.get(id);
53
+ if (!launcher || !isAvailable(launcher)) return null;
54
+ const parsed = launcher.parse(args);
55
+ if (parsed === null) return null;
56
+ const cmd: unknown = launcher.command(parsed, ctx);
57
+ if (!isArgv(cmd)) return null;
58
+ return {
59
+ cmd: [...cmd],
60
+ env: launcher.env?.(parsed) ?? {},
61
+ label: launcher.label,
62
+ meta: launcher.meta?.(parsed) ?? {},
63
+ launcher: id,
64
+ };
65
+ },
66
+ list() {
67
+ return [...registry].filter(([, l]) => isAvailable(l)).map(([id, l]) => ({ id, label: l.label }));
68
+ },
69
+ };
70
+ }
@@ -0,0 +1,40 @@
1
+ import { posix, win32 } from 'node:path';
2
+ import { isShellId, SHELL_ICONS, SHELL_IDS, SHELL_NAMES, type TerminalProfile, type TerminalProfileInfo } from '../protocol.ts';
3
+ import { resolveShell, type ShellLookup } from './shells.ts';
4
+
5
+ /** Fields a saved entry with a built-in id may override. */
6
+ const OVERRIDABLE = ['name', 'icon', 'args', 'env', 'cwd', 'hidden', 'shellIntegration', 'appearance'] as const;
7
+
8
+ /** Argv a profile runs on this machine; null when its shell or command is missing. */
9
+ export function profileArgv(p: TerminalProfile, lookup: ShellLookup): string[] | null {
10
+ const args = p.args ?? [];
11
+ if (p.shell === 'custom') {
12
+ const abs = lookup.platform === 'win32' ? win32.isAbsolute : posix.isAbsolute;
13
+ if (!p.command || !abs(p.command) || !lookup.exists(p.command)) return null;
14
+ return [p.command, ...args];
15
+ }
16
+ if (!isShellId(p.shell)) return null;
17
+ const base = resolveShell(p.shell, lookup);
18
+ return base ? [...base, ...args] : null;
19
+ }
20
+
21
+ /**
22
+ * Built-in profiles (installed shells, with saved overrides applied), then the saved custom ones.
23
+ * Overrides of a shell not installed here are left out; custom profiles always appear, with `available`.
24
+ */
25
+ export function mergeProfiles(saved: TerminalProfile[], lookup: ShellLookup): TerminalProfileInfo[] {
26
+ const byId = new Map(saved.map((p) => [p.id, p]));
27
+ const out: TerminalProfileInfo[] = [];
28
+ for (const id of SHELL_IDS) {
29
+ if (id === 'auto' || !resolveShell(id, lookup)) continue;
30
+ const p: TerminalProfileInfo = { id, name: SHELL_NAMES[id], shell: id, icon: SHELL_ICONS[id], available: true };
31
+ const o = byId.get(id);
32
+ if (o) for (const k of OVERRIDABLE) if (o[k] !== undefined) (p as unknown as Record<string, unknown>)[k] = o[k];
33
+ out.push(p);
34
+ }
35
+ for (const p of saved) {
36
+ if (isShellId(p.id)) continue;
37
+ out.push({ ...p, available: profileArgv(p, lookup) !== null });
38
+ }
39
+ return out;
40
+ }
@@ -0,0 +1,75 @@
1
+ import { killTree } from '@deckbuilderkit/platform';
2
+
3
+ export interface Pty {
4
+ write(data: string | Uint8Array): void;
5
+ resize(cols: number, rows: number): void;
6
+ kill(): void;
7
+ onData(cb: (data: Uint8Array) => void): void;
8
+ onExit(cb: (code: number | null) => void): void;
9
+ }
10
+
11
+ /** One backend for every platform: Bun's built-in terminal (a PTY, ConPTY on Windows). */
12
+ export function spawnPty(
13
+ cmd: string[],
14
+ cwd: string,
15
+ cols: number,
16
+ rows: number,
17
+ env?: Record<string, string>,
18
+ ): Pty {
19
+ const dataCbs: Array<(data: Uint8Array) => void> = [];
20
+ const exitCbs: Array<(code: number | null) => void> = [];
21
+ // Output that arrives before the owner subscribes (the shell's first prompt) is kept.
22
+ let pending: Uint8Array[] | null = [];
23
+ let exitCode: number | null | undefined;
24
+
25
+ const proc = Bun.spawn(cmd, {
26
+ cwd,
27
+ env: { ...process.env, ...env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
28
+ windowsHide: true,
29
+ terminal: {
30
+ cols,
31
+ rows,
32
+ data(_terminal, data) {
33
+ if (pending) pending.push(data);
34
+ else for (const cb of dataCbs) cb(data);
35
+ },
36
+ },
37
+ });
38
+
39
+ proc.exited.then(
40
+ (code) => finish(code),
41
+ () => finish(null),
42
+ );
43
+
44
+ function finish(code: number | null): void {
45
+ if (exitCode !== undefined) return;
46
+ exitCode = code;
47
+ for (const cb of exitCbs) cb(code);
48
+ }
49
+
50
+ return {
51
+ write(data) {
52
+ if (!proc.terminal?.closed) proc.terminal?.write(data);
53
+ },
54
+ resize(c, r) {
55
+ if (!proc.terminal?.closed) proc.terminal?.resize(c, r);
56
+ },
57
+ kill() {
58
+ if (exitCode === undefined) killTree(proc.pid);
59
+ // A plain kill leaves `claude.cmd`'s node holding the PTY; closing it releases the pipe.
60
+ if (!proc.terminal?.closed) proc.terminal?.close();
61
+ },
62
+ onData(cb) {
63
+ dataCbs.push(cb);
64
+ if (pending) {
65
+ const queued = pending;
66
+ pending = null;
67
+ for (const chunk of queued) for (const c of dataCbs) c(chunk);
68
+ }
69
+ },
70
+ onExit(cb) {
71
+ exitCbs.push(cb);
72
+ if (exitCode !== undefined) cb(exitCode);
73
+ },
74
+ };
75
+ }
@@ -0,0 +1,232 @@
1
+ import { AppError, invalid, isRecord, notFound, socketTickets, type Routes, type SocketData, type SocketRoutes } from '@deckbuilderkit/server-core';
2
+ import type { ServerWebSocket } from 'bun';
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import { homedir, tmpdir } from 'node:os';
5
+ import { join, posix, win32 } from 'node:path';
6
+ import { decodeClientMessage, MAX_COLS, MAX_ROWS, TERMINAL_SOCKET_PATH, type TerminalProfile, type TerminalProfileInfo } from '../protocol.ts';
7
+ import { ensureIntegrationFiles, integratedShell, withIntegration } from './integration.ts';
8
+ import type { Launchers } from './launchers.ts';
9
+ import { mergeProfiles, profileArgv } from './profiles.ts';
10
+ import { availableShells, isShellId, resolveShell, type ShellId, type ShellLookup } from './shells.ts';
11
+ import type { Sink, TerminalSessions, ViewKind } from './sessions.ts';
12
+
13
+ /** Sockets with more than this queued are closed with BACKPRESSURE_CLOSE instead of buffering without bound. */
14
+ export const MAX_BUFFERED = 1024 * 1024;
15
+ export const BACKPRESSURE_CLOSE = 4001;
16
+
17
+ export interface TerminalRoutesOptions {
18
+ sessions: TerminalSessions;
19
+ /** The configured shell id (the `terminal.shell` setting); anything but a known id means `auto`. */
20
+ shell: () => Promise<string>;
21
+ /** Resolves a project root id and relative path to an existing directory, or throws. Required for `root`. */
22
+ resolveCwd?: (root: string, path: string) => Promise<string>;
23
+ launchers?: Launchers;
24
+ /** The saved `terminal.profiles` (overrides of built-ins and custom profiles). */
25
+ profiles?: () => Promise<TerminalProfile[]>;
26
+ /** Profile id used when `open` names none; '' or unknown falls back to `shell`. */
27
+ defaultProfile?: () => Promise<string>;
28
+ /** Start directory when neither the request nor the profile gives one (`~` = home). */
29
+ defaultCwd?: () => Promise<string>;
30
+ /** The `terminal.shellIntegration` setting; integration is on unless this resolves false. */
31
+ shellIntegration?: () => Promise<boolean>;
32
+ /** Where the integration scripts are written (default: a folder in the temp dir). */
33
+ integrationDir?: string;
34
+ /** Where shells are looked up; tests replace it. */
35
+ lookup?: ShellLookup;
36
+ /** Directory check for cwd candidates; tests replace it. */
37
+ isDir?: (path: string) => boolean;
38
+ }
39
+
40
+ interface Ticket {
41
+ session: string;
42
+ view: ViewKind;
43
+ }
44
+
45
+ const badRequest = () => invalid('badRequest', 'Invalid request.');
46
+
47
+ function size(v: unknown, max: number): number {
48
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > max) throw badRequest();
49
+ return v;
50
+ }
51
+
52
+ function sessionId(b: Record<string, unknown>): string {
53
+ if (typeof b.session !== 'string' || !b.session) throw badRequest();
54
+ return b.session;
55
+ }
56
+
57
+ export function terminalRoutes(opts: TerminalRoutesOptions): { routes: Routes; sockets: SocketRoutes } {
58
+ const { sessions } = opts;
59
+ const lookup: ShellLookup = opts.lookup ?? { platform: process.platform, env: process.env, exists: existsSync };
60
+ const tickets = socketTickets<Ticket>();
61
+
62
+ const isDir = opts.isDir ?? ((p: string) => {
63
+ try {
64
+ return statSync(p).isDirectory();
65
+ } catch {
66
+ return false;
67
+ }
68
+ });
69
+ const abs = lookup.platform === 'win32' ? win32.isAbsolute : posix.isAbsolute;
70
+ const integrationDir = opts.integrationDir ?? join(tmpdir(), 'deckbuilder-shell-integration');
71
+
72
+ async function pickShell(): Promise<{ id: ShellId; argv: string[] }> {
73
+ const configured = await opts.shell();
74
+ const id = isShellId(configured) ? configured : 'auto';
75
+ const argv = resolveShell(id, lookup);
76
+ if (argv) return { id, argv };
77
+ const fallback = resolveShell('auto', lookup);
78
+ if (!fallback) throw invalid('terminalNoShell', 'No shell found on this machine.');
79
+ return { id: 'auto', argv: fallback };
80
+ }
81
+
82
+ const listProfiles = async () => mergeProfiles((await opts.profiles?.()) ?? [], lookup);
83
+
84
+ /** The requested profile, else the default profile, else the `shell` setting (no profile). */
85
+ async function pickProfile(requested: string | undefined): Promise<{ id: ShellId | 'custom'; argv: string[]; profile?: TerminalProfileInfo }> {
86
+ const all = requested !== undefined || opts.defaultProfile ? await listProfiles() : [];
87
+ if (requested !== undefined) {
88
+ const profile = all.find((p) => p.id === requested);
89
+ if (!profile) throw invalid('terminalProfile', 'Unknown terminal profile.');
90
+ const argv = profileArgv(profile, lookup);
91
+ if (!argv) throw invalid('terminalNoShell', 'The profile shell is not on this machine.');
92
+ return { id: profile.shell, argv, profile };
93
+ }
94
+ const fallbackId = await opts.defaultProfile?.();
95
+ const profile = fallbackId ? all.find((p) => p.id === fallbackId) : undefined;
96
+ const argv = profile && profileArgv(profile, lookup);
97
+ if (profile && argv) return { id: profile.shell, argv, profile };
98
+ return pickShell();
99
+ }
100
+
101
+ /** An absolute existing directory from `p` (`~` = home), or null. */
102
+ function dirOf(p: string | undefined): string | null {
103
+ if (!p) return null;
104
+ const expanded = p === '~' || p.startsWith('~/') || p.startsWith('~\\') ? homedir() + p.slice(1) : p;
105
+ return abs(expanded) && isDir(expanded) ? expanded : null;
106
+ }
107
+
108
+ /** root/path > dir > profile cwd > defaultCwd > home. */
109
+ async function cwdOf(b: Record<string, unknown>, profile: TerminalProfileInfo | undefined): Promise<string> {
110
+ const { root, path } = b;
111
+ if (root !== undefined) {
112
+ if (typeof root !== 'string' || (path !== undefined && typeof path !== 'string')) throw badRequest();
113
+ if (!opts.resolveCwd) throw invalid('notADirectory', 'Working directories are not available.');
114
+ return opts.resolveCwd(root, path ?? '');
115
+ }
116
+ return dirOf(b.dir as string | undefined) ?? dirOf(profile?.cwd) ?? dirOf(await opts.defaultCwd?.()) ?? homedir();
117
+ }
118
+
119
+ const routes: Routes = {
120
+ 'POST /api/terminal/open': async ({ json }) => {
121
+ const b = await json<Record<string, unknown>>();
122
+ const cols = size(b.cols, MAX_COLS);
123
+ const rows = size(b.rows, MAX_ROWS);
124
+ if (b.launch !== undefined && (!isRecord(b.launch) || typeof b.launch.id !== 'string')) throw badRequest();
125
+ if (b.profile !== undefined && typeof b.profile !== 'string') throw badRequest();
126
+ if (b.dir !== undefined && typeof b.dir !== 'string') throw badRequest();
127
+ const shell = await pickProfile(b.profile as string | undefined);
128
+ const profile = shell.profile;
129
+ let cmd = shell.argv;
130
+ let label: string = profile?.name ?? (shell.id === 'auto' ? 'shell' : shell.id);
131
+ let env: Record<string, string> | undefined = profile?.env;
132
+ let launcher: string | undefined;
133
+ let meta: Record<string, unknown> | undefined;
134
+ let shellId: string | undefined = shell.id;
135
+ if (b.launch !== undefined) {
136
+ const resolved = opts.launchers?.resolve((b.launch as Record<string, unknown>).id, (b.launch as Record<string, unknown>).args, {
137
+ shell: shell.argv,
138
+ });
139
+ if (!resolved) throw invalid('terminalLauncher', 'Unknown or invalid launcher.');
140
+ ({ cmd, env, label, meta, launcher } = resolved);
141
+ shellId = undefined;
142
+ } else {
143
+ const kind = shell.id === 'custom' ? null : integratedShell(shell.id, cmd);
144
+ if (kind && profile?.shellIntegration !== false && (await opts.shellIntegration?.()) !== false) {
145
+ ensureIntegrationFiles(integrationDir);
146
+ const injected = withIntegration(kind, cmd, integrationDir, { ...lookup.env, ...env });
147
+ cmd = injected.argv;
148
+ env = { ...env, ...injected.env };
149
+ }
150
+ }
151
+ const cwd = await cwdOf(b, launcher ? undefined : profile);
152
+ const session = sessions.create({ cmd, cwd, cols, rows, env, label, launcher, meta, profile: launcher ? undefined : profile?.id, shell: shellId });
153
+ return { session: session.id, ticket: tickets.issue({ session: session.id, view: 'interactive' }) };
154
+ },
155
+
156
+ 'POST /api/terminal/attach': async ({ json }) => {
157
+ const b = await json<Record<string, unknown>>();
158
+ const id = sessionId(b);
159
+ if (b.view !== undefined && b.view !== 'interactive' && b.view !== 'preview') throw badRequest();
160
+ if (!sessions.get(id)) throw notFound('terminalSession', 'No such terminal session');
161
+ return { session: id, ticket: tickets.issue({ session: id, view: b.view ?? 'interactive' }) };
162
+ },
163
+
164
+ 'POST /api/terminal/close': async ({ json }) => {
165
+ sessions.close(sessionId(await json<Record<string, unknown>>()));
166
+ return { ok: true };
167
+ },
168
+
169
+ 'POST /api/terminal/label': async ({ json }) => {
170
+ const b = await json<Record<string, unknown>>();
171
+ if (typeof b.label !== 'string') throw badRequest();
172
+ sessions.setLabel(sessionId(b), b.label);
173
+ return { ok: true };
174
+ },
175
+
176
+ 'GET /api/terminal/sessions': () => sessions.list(),
177
+ 'GET /api/terminal/shells': () => availableShells(lookup),
178
+ 'GET /api/terminal/profiles': () => listProfiles(),
179
+ 'GET /api/terminal/launchers': () => opts.launchers?.list() ?? [],
180
+ };
181
+
182
+ const sinks = new WeakMap<ServerWebSocket<SocketData>, { session: string; sink: Sink }>();
183
+
184
+ const sockets: SocketRoutes = {
185
+ [TERMINAL_SOCKET_PATH]: {
186
+ tickets,
187
+ open(ws, { session, view }: Ticket) {
188
+ const sink: Sink = {
189
+ send(frame) {
190
+ if (ws.getBufferedAmount() > MAX_BUFFERED) {
191
+ sessions.detach(session, sink);
192
+ ws.close(BACKPRESSURE_CLOSE, 'backpressure');
193
+ return;
194
+ }
195
+ ws.send(frame);
196
+ },
197
+ close: () => ws.close(),
198
+ };
199
+ sinks.set(ws, { session, sink });
200
+ try {
201
+ sessions.attach(session, sink, view);
202
+ } catch (e) {
203
+ sinks.delete(ws);
204
+ if (!(e instanceof AppError) || e.code !== 'terminalSession') {
205
+ console.error('terminal attach failed', e);
206
+ ws.close(1011, 'error');
207
+ return;
208
+ }
209
+ ws.close(1008, 'noSession');
210
+ }
211
+ },
212
+ message(ws, msg) {
213
+ const attached = sinks.get(ws);
214
+ if (!attached) return;
215
+ const decoded = decodeClientMessage(msg);
216
+ if (!decoded) return;
217
+ if (decoded.kind === 'input') sessions.write(attached.session, attached.sink, decoded.data);
218
+ else if (decoded.msg.type === 'resize') sessions.resize(attached.session, attached.sink, decoded.msg.cols, decoded.msg.rows);
219
+ else if (decoded.msg.type === 'clear') sessions.clear(attached.session, attached.sink);
220
+ else sessions.close(attached.session, attached.sink);
221
+ },
222
+ close(ws) {
223
+ const attached = sinks.get(ws);
224
+ if (!attached) return;
225
+ sinks.delete(ws);
226
+ sessions.detach(attached.session, attached.sink);
227
+ },
228
+ },
229
+ };
230
+
231
+ return { routes, sockets };
232
+ }