@playcraft/cli 0.0.59 → 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.
@@ -1,19 +1,26 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { canExecuteKind, executeOperation } from './dispatch.js';
7
7
  import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
8
8
  import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
9
9
  import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
10
10
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
11
+ import { desktopHost, desktopRoute } from './desktop-host.js';
12
+ import { terminalHost } from './terminal-host.js';
11
13
  export async function handleRuntimeRequest(state, request, response) {
12
14
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
13
- if (!controlTokenMatches(state.config.controlToken, extractControlToken(request.headers))) {
15
+ const access = authorizeRuntime(state, request);
16
+ if (!access) {
14
17
  writeError(response, runtimeError('unauthenticated', 'control_token_required'));
15
18
  return;
16
19
  }
20
+ if (access === 'read' && !readSourcePath(request.method ?? 'GET', url.pathname)) {
21
+ writeJson(response, 403, { error: 'source_read_only' });
22
+ return;
23
+ }
17
24
  try {
18
25
  if (request.method === 'GET' && url.pathname === '/runtime/health') {
19
26
  writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
@@ -28,6 +35,7 @@ export async function handleRuntimeRequest(state, request, response) {
28
35
  generation,
29
36
  runtimeEpoch: state.runtimeEpoch,
30
37
  instanceToken: state.instanceToken,
38
+ sourceReadToken: state.journal.sourceReadToken(),
31
39
  workspaceMode: state.config.workspaceMode,
32
40
  executionGroup: {
33
41
  replaced: state.journal.executionGroup().replaced,
@@ -56,6 +64,11 @@ export async function handleRuntimeRequest(state, request, response) {
56
64
  writeJson(response, 200, handshake(state));
57
65
  return;
58
66
  }
67
+ const historyPath = sessionHistoryPath(url.pathname);
68
+ if (request.method === 'GET' && historyPath) {
69
+ await handleSessionHistory(state, response, historyPath.workspaceId, historyPath.sessionId);
70
+ return;
71
+ }
59
72
  if (isUnimplementedWorkbenchPath(url.pathname)) {
60
73
  if (writeDestroyed(state, response))
61
74
  return;
@@ -80,11 +93,8 @@ export async function handleRuntimeRequest(state, request, response) {
80
93
  });
81
94
  return;
82
95
  }
83
- if (request.method === 'GET' && url.pathname === '/runtime/events') {
84
- const after = Number(url.searchParams.get('after') ?? '0');
85
- writeJson(response, 200, {
86
- events: state.journal.listEvents(Number.isInteger(after) ? after : 0),
87
- });
96
+ if (url.pathname === '/runtime/events' || url.pathname.startsWith('/runtime/events/')) {
97
+ await handleSourceEvents(state, request, response, url);
88
98
  return;
89
99
  }
90
100
  if (request.method === 'GET' && url.pathname === '/runtime/files') {
@@ -143,6 +153,10 @@ export async function handleRuntimeRequest(state, request, response) {
143
153
  await handleTerminalIdentity(state, request, response, url.pathname.slice('/runtime/terminals/'.length));
144
154
  return;
145
155
  }
156
+ if (desktopRoute(url.pathname)) {
157
+ await desktopHost(state).handleHttp(request, response, url);
158
+ return;
159
+ }
146
160
  if (request.method === 'GET' && url.pathname === '/runtime/operations') {
147
161
  const kind = operationKindSchema.safeParse(url.searchParams.get('kind'));
148
162
  const clientRequestId = url.searchParams.get('clientRequestId');
@@ -219,9 +233,10 @@ function handshake(state) {
219
233
  : capability('terminal', true);
220
234
  }
221
235
  if (item.name === 'desktop') {
222
- return destroyed
223
- ? capability('desktop', false, 'runtime_destroyed')
224
- : capability('desktop', false, 'display_not_started');
236
+ if (destroyed)
237
+ return capability('desktop', false, 'runtime_destroyed');
238
+ const status = desktopHost(state).snapshot().status;
239
+ return status === 'ready' ? capability('desktop', true) : capability('desktop', false, status);
225
240
  }
226
241
  if (item.name === 'parameters') {
227
242
  return destroyed
@@ -502,7 +517,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
502
517
  writeError(response, runtimeError('capability_unavailable', 'terminal_destroyed'));
503
518
  return;
504
519
  }
505
- writeError(response, runtimeError('capability_unavailable', 'pty_not_started'));
520
+ writeError(response, runtimeError('capability_unavailable', 'websocket_required'));
506
521
  return;
507
522
  }
508
523
  if (request.method === 'GET' && !action) {
@@ -517,6 +532,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
517
532
  status: 'destroyed',
518
533
  destroyedAt: new Date().toISOString(),
519
534
  });
535
+ terminalHost(state).revoke(destroyed.terminalId, 'terminal_destroyed');
520
536
  writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
521
537
  return;
522
538
  }
@@ -529,9 +545,67 @@ function unavailableReason(state, kind) {
529
545
  return 'capability_not_implemented';
530
546
  return 'adapter_not_started';
531
547
  }
548
+ function sessionHistoryPath(pathname) {
549
+ const match = pathname.match(/^\/runtime\/workspaces\/([^/]+)\/sessions\/([^/]+)\/history$/);
550
+ if (!match?.[1] || !match[2])
551
+ return undefined;
552
+ try {
553
+ return {
554
+ workspaceId: decodeURIComponent(match[1]),
555
+ sessionId: decodeURIComponent(match[2]),
556
+ };
557
+ }
558
+ catch {
559
+ return undefined;
560
+ }
561
+ }
532
562
  function isUnimplementedWorkbenchPath(pathname) {
533
563
  const parts = pathname.split('/').filter(Boolean);
534
- return parts[0] === 'runtime' && (parts.includes('previews') || parts.includes('history'));
564
+ return parts[0] === 'runtime' && parts.includes('previews');
565
+ }
566
+ async function handleSessionHistory(state, response, workspaceId, sessionId) {
567
+ if (writeDestroyed(state, response))
568
+ return;
569
+ const session = state.journal.getSession(sessionId);
570
+ if (!session || (session.workspaceId && session.workspaceId !== workspaceId)) {
571
+ writeJson(response, 404, { error: 'session_not_found' });
572
+ return;
573
+ }
574
+ if (state.config.workspaceMode === 'multi' && session.workspaceId !== workspaceId) {
575
+ writeJson(response, 404, { error: 'session_not_found' });
576
+ return;
577
+ }
578
+ const readThread = state.native?.readThread;
579
+ if (!readThread) {
580
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
581
+ return;
582
+ }
583
+ let read;
584
+ try {
585
+ read = await readThread({ threadId: session.threadId, sessionId });
586
+ }
587
+ catch {
588
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
589
+ return;
590
+ }
591
+ if (!read || read.threadId !== session.threadId) {
592
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
593
+ return;
594
+ }
595
+ const body = nativeHistoryReadSchema.safeParse({
596
+ source: 'native',
597
+ sessionId,
598
+ workspaceId,
599
+ threadId: read.threadId,
600
+ ...(read.threadName ? { threadName: read.threadName } : {}),
601
+ truncated: read.truncated,
602
+ items: read.items,
603
+ });
604
+ if (!body.success) {
605
+ writeError(response, runtimeError('capability_unavailable', 'native_history_unavailable'));
606
+ return;
607
+ }
608
+ writeJson(response, 200, body.data);
535
609
  }
536
610
  async function readObservedParameters(state, workspaceId) {
537
611
  const root = await workspaceRoot(state, workspaceId);
@@ -554,6 +628,180 @@ function writeJson(response, status, body) {
554
628
  });
555
629
  response.end(payload);
556
630
  }
631
+ function authorizeRuntime(state, request) {
632
+ const presented = extractControlToken(request.headers);
633
+ if (controlTokenMatches(state.config.controlToken, presented))
634
+ return 'control';
635
+ const dedicated = headerValue(request.headers['x-playcraft-source-read-token']);
636
+ const candidate = dedicated || presented;
637
+ if (candidate && controlTokenMatches(state.journal.sourceReadToken(), candidate))
638
+ return 'read';
639
+ return null;
640
+ }
641
+ function readSourcePath(method, pathname) {
642
+ if (method === 'GET' && pathname === '/runtime/events')
643
+ return true;
644
+ if (method === 'POST' && pathname === '/runtime/events/replay-pins')
645
+ return true;
646
+ if (method === 'DELETE' && pathname.startsWith('/runtime/events/replay-pins/'))
647
+ return true;
648
+ return false;
649
+ }
650
+ async function handleSourceEvents(state, request, response, url) {
651
+ if (request.method === 'POST' && url.pathname === '/runtime/events/replay-pins') {
652
+ const pin = await state.journal.createReplayPin({
653
+ runtimeId: state.config.runtimeId,
654
+ generation: state.config.generation,
655
+ runtimeEpoch: state.runtimeEpoch,
656
+ ttlMs: Number(asRecord(await readJson(request)).ttlMs) || undefined,
657
+ });
658
+ writeJson(response, 201, pin);
659
+ return;
660
+ }
661
+ if (request.method === 'DELETE' && url.pathname.startsWith('/runtime/events/replay-pins/')) {
662
+ await state.journal.deleteReplayPin(decodeURIComponent(url.pathname.slice('/runtime/events/replay-pins/'.length)));
663
+ writeJson(response, 200, { deleted: true });
664
+ return;
665
+ }
666
+ if (request.method === 'POST' && url.pathname === '/runtime/events/ack') {
667
+ const parsed = sourceAckSchema.safeParse(await readJson(request));
668
+ if (!parsed.success) {
669
+ writeJson(response, 400, { error: 'invalid_source_ack' });
670
+ return;
671
+ }
672
+ try {
673
+ await state.journal.acknowledgeSource(parsed.data);
674
+ }
675
+ catch (error) {
676
+ writeJson(response, 409, { code: error instanceof Error ? error.message : 'ack_rejected' });
677
+ return;
678
+ }
679
+ writeJson(response, 200, { ok: true, acceptedLeaseFence: state.journal.acceptedLeaseFence() });
680
+ return;
681
+ }
682
+ if (request.method === 'GET' && url.pathname === '/runtime/events') {
683
+ const after = Number(url.searchParams.get('after') ?? '0');
684
+ const safeAfter = Number.isInteger(after) && after >= 0 ? after : 0;
685
+ const epochQuery = url.searchParams.get('runtimeEpoch');
686
+ const runtimeEpoch = epochQuery === null ? state.runtimeEpoch : Number(epochQuery);
687
+ const query = {
688
+ runtimeId: state.config.runtimeId,
689
+ generation: state.config.generation,
690
+ runtimeEpoch: Number.isInteger(runtimeEpoch) ? runtimeEpoch : state.runtimeEpoch,
691
+ after: safeAfter,
692
+ };
693
+ if (String(request.headers.accept ?? '').includes('text/event-stream')) {
694
+ writeSourceStream(state, request, response, query);
695
+ return;
696
+ }
697
+ if (epochQuery === null) {
698
+ const events = state.journal.listEvents(safeAfter);
699
+ const page = state.journal.readSourcePage({ ...query, limit: 1 });
700
+ writeJson(response, 200, {
701
+ events,
702
+ retainedFrom: page.ok ? page.retainedFrom : page.retainedFrom,
703
+ highWater: page.ok ? page.highWater : page.highWater,
704
+ nextAfter: events.at(-1)?.source.sourceSequence ?? safeAfter,
705
+ runtimeId: query.runtimeId,
706
+ generation: query.generation,
707
+ runtimeEpoch: query.runtimeEpoch,
708
+ });
709
+ return;
710
+ }
711
+ const page = state.journal.readSourcePage(query);
712
+ if (!page.ok) {
713
+ writeJson(response, 409, {
714
+ code: page.code,
715
+ retainedFrom: page.retainedFrom,
716
+ highWater: page.highWater,
717
+ coveredThrough: page.coveredThrough,
718
+ });
719
+ return;
720
+ }
721
+ writeJson(response, 200, {
722
+ events: page.events,
723
+ retainedFrom: page.retainedFrom,
724
+ highWater: page.highWater,
725
+ nextAfter: page.nextAfter,
726
+ runtimeId: page.runtimeId,
727
+ generation: page.generation,
728
+ runtimeEpoch: page.runtimeEpoch,
729
+ });
730
+ return;
731
+ }
732
+ writeJson(response, 404, { error: 'not_found' });
733
+ }
734
+ function writeSourceStream(state, request, response, query) {
735
+ let cursor = query.after;
736
+ const buffered = [];
737
+ const matches = (event) => event.source.runtimeId === query.runtimeId &&
738
+ event.source.generation === query.generation &&
739
+ event.source.runtimeEpoch === query.runtimeEpoch &&
740
+ event.source.sourceSequence > cursor;
741
+ const unsubscribe = state.journal.onSourceEvent((event) => {
742
+ if (matches(event))
743
+ buffered.push(event);
744
+ });
745
+ const page = state.journal.readSourcePage({ ...query, limit: 500 });
746
+ response.writeHead(200, {
747
+ 'content-type': 'text/event-stream; charset=utf-8',
748
+ 'cache-control': 'no-cache, no-transform',
749
+ connection: 'keep-alive',
750
+ 'x-accel-buffering': 'no',
751
+ });
752
+ const writeEvent = (name, data) => {
753
+ response.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
754
+ };
755
+ if (!page.ok) {
756
+ writeEvent('source.gap', {
757
+ code: page.code,
758
+ retainedFrom: page.retainedFrom,
759
+ highWater: page.highWater,
760
+ coveredThrough: page.coveredThrough,
761
+ });
762
+ response.end();
763
+ unsubscribe();
764
+ return;
765
+ }
766
+ writeEvent('source.ready', {
767
+ runtimeId: page.runtimeId,
768
+ generation: page.generation,
769
+ runtimeEpoch: page.runtimeEpoch,
770
+ retainedFrom: page.retainedFrom,
771
+ highWater: page.highWater,
772
+ nextAfter: page.nextAfter,
773
+ });
774
+ for (const event of page.events) {
775
+ writeEvent('source', event);
776
+ cursor = event.source.sourceSequence;
777
+ }
778
+ for (const event of buffered) {
779
+ if (!matches(event))
780
+ continue;
781
+ writeEvent('source', event);
782
+ cursor = event.source.sourceSequence;
783
+ }
784
+ unsubscribe();
785
+ const follow = state.journal.onSourceEvent((event) => {
786
+ if (!matches(event))
787
+ return;
788
+ writeEvent('source', event);
789
+ cursor = event.source.sourceSequence;
790
+ });
791
+ const heartbeat = setInterval(() => {
792
+ response.write(`: ping\n\n`);
793
+ }, 10_000);
794
+ heartbeat.unref?.();
795
+ const stop = () => {
796
+ clearInterval(heartbeat);
797
+ follow();
798
+ };
799
+ request.on('close', stop);
800
+ response.on('close', stop);
801
+ }
802
+ function headerValue(value) {
803
+ return Array.isArray(value) ? value[0] : value;
804
+ }
557
805
  function asRecord(value) {
558
806
  return value && typeof value === 'object' && !Array.isArray(value)
559
807
  ? value
@@ -0,0 +1,243 @@
1
+ import { createRequire } from 'node:module';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { lstat } from 'node:fs/promises';
4
+ import { WebSocketServer } from 'ws';
5
+ import { appendTerminalOutput, createTerminalOutputLog, decodeTerminalBytes, runtimeError, terminalClientFrameSchema, terminalOutputSince, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { resolveWorkspaceFile } from '../workspaces/files.js';
7
+ import { workspaceRoot } from '../workspaces/registry.js';
8
+ import { controlTokenMatches, extractControlToken } from './auth.js';
9
+ const require = createRequire(import.meta.url);
10
+ const pty = require('node-pty');
11
+ const commands = new WeakMap();
12
+ const hosts = new WeakMap();
13
+ export function setTerminalCommand(state, command) {
14
+ commands.set(state, command);
15
+ }
16
+ export function terminalHost(state) {
17
+ let host = hosts.get(state);
18
+ if (!host) {
19
+ host = new TerminalHost(state);
20
+ hosts.set(state, host);
21
+ }
22
+ return host;
23
+ }
24
+ export function disposeTerminalHost(state) {
25
+ hosts.get(state)?.dispose();
26
+ hosts.delete(state);
27
+ }
28
+ export class TerminalHost {
29
+ state;
30
+ sessions = new Map();
31
+ sockets = new WebSocketServer({ noServer: true });
32
+ constructor(state) {
33
+ this.state = state;
34
+ }
35
+ async handleUpgrade(request, socket, head) {
36
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
37
+ const match = /^\/runtime\/terminals\/([^/]+)\/connect$/.exec(url.pathname);
38
+ if (!match || !controlTokenMatches(this.state.config.controlToken, extractControlToken(request.headers))) {
39
+ rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
40
+ return;
41
+ }
42
+ if (this.state.journal.destroyed()) {
43
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
44
+ return;
45
+ }
46
+ const terminalId = decodeURIComponent(match[1]);
47
+ const terminal = this.state.journal.getTerminal(terminalId);
48
+ if (!terminal) {
49
+ rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
50
+ return;
51
+ }
52
+ if (terminal.status === 'destroyed') {
53
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
54
+ return;
55
+ }
56
+ let session = this.sessions.get(terminal.terminalId);
57
+ if (!session) {
58
+ try {
59
+ session = this.spawn(terminal, await terminalDirectory(this.state, terminal));
60
+ }
61
+ catch {
62
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'pty_unavailable'));
63
+ return;
64
+ }
65
+ }
66
+ const active = session;
67
+ this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(active, ws));
68
+ }
69
+ revoke(terminalId, reason) {
70
+ const session = this.sessions.get(terminalId);
71
+ if (!session)
72
+ return;
73
+ this.sessions.delete(terminalId);
74
+ const frame = { type: 'revoked', reason };
75
+ for (const listener of session.listeners)
76
+ listener(frame);
77
+ session.listeners.clear();
78
+ try {
79
+ session.pty.kill();
80
+ }
81
+ catch {
82
+ /* the process may already have exited */
83
+ }
84
+ }
85
+ dispose() {
86
+ for (const terminalId of [...this.sessions.keys()])
87
+ this.revoke(terminalId, 'runtime_destroyed');
88
+ this.sockets.close();
89
+ }
90
+ spawn(terminal, cwd) {
91
+ const command = commands.get(this.state) ?? defaultShell();
92
+ const session = {
93
+ streamId: randomUUID(),
94
+ log: createTerminalOutputLog(),
95
+ listeners: new Set(),
96
+ pty: pty.spawn(command.file, command.args, {
97
+ name: 'xterm-256color',
98
+ cols: 80,
99
+ rows: 24,
100
+ cwd,
101
+ env: shellEnv(command.file, cwd),
102
+ }),
103
+ };
104
+ session.pty.onData((data) => publish(session, data));
105
+ session.pty.onExit(({ exitCode, signal }) => {
106
+ session.exited = {
107
+ code: exitCode,
108
+ ...(signal === undefined ? {} : { signal: String(signal) }),
109
+ };
110
+ const frame = {
111
+ type: 'exit',
112
+ streamId: session.streamId,
113
+ code: exitCode,
114
+ ...(session.exited.signal ? { signal: session.exited.signal } : {}),
115
+ };
116
+ for (const listener of session.listeners)
117
+ listener(frame);
118
+ });
119
+ this.sessions.set(terminal.terminalId, session);
120
+ return session;
121
+ }
122
+ attach(session, ws) {
123
+ let open = true;
124
+ let resumed = false;
125
+ let cursor = 0;
126
+ const send = (frame) => {
127
+ if (open && ws.readyState === ws.OPEN)
128
+ ws.send(JSON.stringify(frame));
129
+ };
130
+ const listener = (frame) => {
131
+ if (!resumed)
132
+ return;
133
+ if (frame.type === 'output') {
134
+ const end = frame.offset + decodeTerminalBytes(frame.data).length;
135
+ if (end <= cursor)
136
+ return;
137
+ cursor = Math.max(cursor, end);
138
+ }
139
+ send(frame);
140
+ };
141
+ session.listeners.add(listener);
142
+ ws.on('message', (raw) => {
143
+ let body;
144
+ try {
145
+ body = JSON.parse(String(raw));
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ const parsed = terminalClientFrameSchema.safeParse(body);
151
+ if (!parsed.success || session.exited && parsed.data.type !== 'resume')
152
+ return;
153
+ const frame = parsed.data;
154
+ if (frame.type === 'resume') {
155
+ if (frame.streamId && frame.streamId !== session.streamId) {
156
+ send({ type: 'reset', streamId: session.streamId, offset: session.log.end });
157
+ cursor = session.log.end;
158
+ resumed = true;
159
+ if (session.exited)
160
+ send(exitFrame(session));
161
+ return;
162
+ }
163
+ const since = terminalOutputSince(session.log, frame.offset);
164
+ if (since.gap)
165
+ send({ type: 'gap', streamId: session.streamId, offset: since.offset });
166
+ for (const item of since.frames) {
167
+ send({ type: 'output', streamId: session.streamId, offset: item.offset, data: item.data });
168
+ }
169
+ cursor = session.log.end;
170
+ resumed = true;
171
+ if (session.exited)
172
+ send(exitFrame(session));
173
+ return;
174
+ }
175
+ if (!resumed)
176
+ return;
177
+ if (frame.type === 'resize') {
178
+ session.pty.resize(frame.cols, frame.rows);
179
+ return;
180
+ }
181
+ session.pty.write(Buffer.from(decodeTerminalBytes(frame.data)).toString('utf8'));
182
+ });
183
+ const detach = () => {
184
+ open = false;
185
+ session.listeners.delete(listener);
186
+ };
187
+ ws.on('close', detach);
188
+ ws.on('error', detach);
189
+ }
190
+ }
191
+ function publish(session, text) {
192
+ const before = session.log.end;
193
+ session.log = appendTerminalOutput(session.log, text);
194
+ const since = terminalOutputSince(session.log, before);
195
+ for (const item of since.frames) {
196
+ const frame = {
197
+ type: 'output',
198
+ streamId: session.streamId,
199
+ offset: item.offset,
200
+ data: item.data,
201
+ };
202
+ for (const listener of session.listeners)
203
+ listener(frame);
204
+ }
205
+ }
206
+ function exitFrame(session) {
207
+ return {
208
+ type: 'exit',
209
+ streamId: session.streamId,
210
+ code: session.exited?.code ?? null,
211
+ ...(session.exited?.signal ? { signal: session.exited.signal } : {}),
212
+ };
213
+ }
214
+ async function terminalDirectory(state, terminal) {
215
+ const root = await workspaceRoot(state, terminal.workspaceId);
216
+ if (!terminal.cwd)
217
+ return root;
218
+ const resolved = await resolveWorkspaceFile(root, terminal.cwd);
219
+ const stat = await lstat(resolved);
220
+ if (!stat.isDirectory() || stat.isSymbolicLink())
221
+ throw new Error('pty_unavailable');
222
+ return resolved;
223
+ }
224
+ function defaultShell() {
225
+ const shell = process.env.SHELL;
226
+ const file = shell && shell.startsWith('/') ? shell : '/bin/sh';
227
+ return { file, args: ['-i'] };
228
+ }
229
+ function shellEnv(file, cwd) {
230
+ return {
231
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
232
+ HOME: process.env.HOME ?? cwd,
233
+ TERM: 'xterm-256color',
234
+ LANG: process.env.LANG ?? 'C.UTF-8',
235
+ SHELL: file,
236
+ };
237
+ }
238
+ function rejectUpgrade(socket, status, body) {
239
+ const payload = JSON.stringify(body);
240
+ const title = status === 401 ? 'Unauthorized' : status === 404 ? 'Not Found' : 'Conflict';
241
+ socket.write(`HTTP/1.1 ${status} ${title}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(payload)}\r\nconnection: close\r\n\r\n${payload}`);
242
+ socket.destroy();
243
+ }