@playcraft/cli 0.0.59 → 0.1.0
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.
- package/dist/agent/local-backend.js +1 -1
- package/dist/workspace-runtime/codex/app-server.js +46 -121
- package/dist/workspace-runtime/codex/jsonrpc-stdio.js +19 -2
- package/dist/workspace-runtime/codex/loopback.js +90 -39
- package/dist/workspace-runtime/codex/native-adapter.js +3 -0
- package/dist/workspace-runtime/persistence/journal.js +213 -200
- package/dist/workspace-runtime/persistence/sqlite-client.js +61 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1025 -0
- package/dist/workspace-runtime/serve.js +27 -1
- package/dist/workspace-runtime/server/desktop-host.js +314 -0
- package/dist/workspace-runtime/server/dispatch.js +107 -48
- package/dist/workspace-runtime/server/http.js +196 -11
- package/dist/workspace-runtime/server/terminal-host.js +243 -0
- package/package.json +6 -3
|
@@ -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, 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
|
-
|
|
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,
|
|
@@ -80,11 +88,8 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
80
88
|
});
|
|
81
89
|
return;
|
|
82
90
|
}
|
|
83
|
-
if (
|
|
84
|
-
|
|
85
|
-
writeJson(response, 200, {
|
|
86
|
-
events: state.journal.listEvents(Number.isInteger(after) ? after : 0),
|
|
87
|
-
});
|
|
91
|
+
if (url.pathname === '/runtime/events' || url.pathname.startsWith('/runtime/events/')) {
|
|
92
|
+
await handleSourceEvents(state, request, response, url);
|
|
88
93
|
return;
|
|
89
94
|
}
|
|
90
95
|
if (request.method === 'GET' && url.pathname === '/runtime/files') {
|
|
@@ -143,6 +148,10 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
143
148
|
await handleTerminalIdentity(state, request, response, url.pathname.slice('/runtime/terminals/'.length));
|
|
144
149
|
return;
|
|
145
150
|
}
|
|
151
|
+
if (desktopRoute(url.pathname)) {
|
|
152
|
+
await desktopHost(state).handleHttp(request, response, url);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
146
155
|
if (request.method === 'GET' && url.pathname === '/runtime/operations') {
|
|
147
156
|
const kind = operationKindSchema.safeParse(url.searchParams.get('kind'));
|
|
148
157
|
const clientRequestId = url.searchParams.get('clientRequestId');
|
|
@@ -219,9 +228,10 @@ function handshake(state) {
|
|
|
219
228
|
: capability('terminal', true);
|
|
220
229
|
}
|
|
221
230
|
if (item.name === 'desktop') {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
231
|
+
if (destroyed)
|
|
232
|
+
return capability('desktop', false, 'runtime_destroyed');
|
|
233
|
+
const status = desktopHost(state).snapshot().status;
|
|
234
|
+
return status === 'ready' ? capability('desktop', true) : capability('desktop', false, status);
|
|
225
235
|
}
|
|
226
236
|
if (item.name === 'parameters') {
|
|
227
237
|
return destroyed
|
|
@@ -502,7 +512,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
|
|
|
502
512
|
writeError(response, runtimeError('capability_unavailable', 'terminal_destroyed'));
|
|
503
513
|
return;
|
|
504
514
|
}
|
|
505
|
-
writeError(response, runtimeError('capability_unavailable', '
|
|
515
|
+
writeError(response, runtimeError('capability_unavailable', 'websocket_required'));
|
|
506
516
|
return;
|
|
507
517
|
}
|
|
508
518
|
if (request.method === 'GET' && !action) {
|
|
@@ -517,6 +527,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
|
|
|
517
527
|
status: 'destroyed',
|
|
518
528
|
destroyedAt: new Date().toISOString(),
|
|
519
529
|
});
|
|
530
|
+
terminalHost(state).revoke(destroyed.terminalId, 'terminal_destroyed');
|
|
520
531
|
writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
|
|
521
532
|
return;
|
|
522
533
|
}
|
|
@@ -554,6 +565,180 @@ function writeJson(response, status, body) {
|
|
|
554
565
|
});
|
|
555
566
|
response.end(payload);
|
|
556
567
|
}
|
|
568
|
+
function authorizeRuntime(state, request) {
|
|
569
|
+
const presented = extractControlToken(request.headers);
|
|
570
|
+
if (controlTokenMatches(state.config.controlToken, presented))
|
|
571
|
+
return 'control';
|
|
572
|
+
const dedicated = headerValue(request.headers['x-playcraft-source-read-token']);
|
|
573
|
+
const candidate = dedicated || presented;
|
|
574
|
+
if (candidate && controlTokenMatches(state.journal.sourceReadToken(), candidate))
|
|
575
|
+
return 'read';
|
|
576
|
+
return null;
|
|
577
|
+
}
|
|
578
|
+
function readSourcePath(method, pathname) {
|
|
579
|
+
if (method === 'GET' && pathname === '/runtime/events')
|
|
580
|
+
return true;
|
|
581
|
+
if (method === 'POST' && pathname === '/runtime/events/replay-pins')
|
|
582
|
+
return true;
|
|
583
|
+
if (method === 'DELETE' && pathname.startsWith('/runtime/events/replay-pins/'))
|
|
584
|
+
return true;
|
|
585
|
+
return false;
|
|
586
|
+
}
|
|
587
|
+
async function handleSourceEvents(state, request, response, url) {
|
|
588
|
+
if (request.method === 'POST' && url.pathname === '/runtime/events/replay-pins') {
|
|
589
|
+
const pin = await state.journal.createReplayPin({
|
|
590
|
+
runtimeId: state.config.runtimeId,
|
|
591
|
+
generation: state.config.generation,
|
|
592
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
593
|
+
ttlMs: Number(asRecord(await readJson(request)).ttlMs) || undefined,
|
|
594
|
+
});
|
|
595
|
+
writeJson(response, 201, pin);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (request.method === 'DELETE' && url.pathname.startsWith('/runtime/events/replay-pins/')) {
|
|
599
|
+
await state.journal.deleteReplayPin(decodeURIComponent(url.pathname.slice('/runtime/events/replay-pins/'.length)));
|
|
600
|
+
writeJson(response, 200, { deleted: true });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (request.method === 'POST' && url.pathname === '/runtime/events/ack') {
|
|
604
|
+
const parsed = sourceAckSchema.safeParse(await readJson(request));
|
|
605
|
+
if (!parsed.success) {
|
|
606
|
+
writeJson(response, 400, { error: 'invalid_source_ack' });
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
await state.journal.acknowledgeSource(parsed.data);
|
|
611
|
+
}
|
|
612
|
+
catch (error) {
|
|
613
|
+
writeJson(response, 409, { code: error instanceof Error ? error.message : 'ack_rejected' });
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
writeJson(response, 200, { ok: true, acceptedLeaseFence: state.journal.acceptedLeaseFence() });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (request.method === 'GET' && url.pathname === '/runtime/events') {
|
|
620
|
+
const after = Number(url.searchParams.get('after') ?? '0');
|
|
621
|
+
const safeAfter = Number.isInteger(after) && after >= 0 ? after : 0;
|
|
622
|
+
const epochQuery = url.searchParams.get('runtimeEpoch');
|
|
623
|
+
const runtimeEpoch = epochQuery === null ? state.runtimeEpoch : Number(epochQuery);
|
|
624
|
+
const query = {
|
|
625
|
+
runtimeId: state.config.runtimeId,
|
|
626
|
+
generation: state.config.generation,
|
|
627
|
+
runtimeEpoch: Number.isInteger(runtimeEpoch) ? runtimeEpoch : state.runtimeEpoch,
|
|
628
|
+
after: safeAfter,
|
|
629
|
+
};
|
|
630
|
+
if (String(request.headers.accept ?? '').includes('text/event-stream')) {
|
|
631
|
+
writeSourceStream(state, request, response, query);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (epochQuery === null) {
|
|
635
|
+
const events = state.journal.listEvents(safeAfter);
|
|
636
|
+
const page = state.journal.readSourcePage({ ...query, limit: 1 });
|
|
637
|
+
writeJson(response, 200, {
|
|
638
|
+
events,
|
|
639
|
+
retainedFrom: page.ok ? page.retainedFrom : page.retainedFrom,
|
|
640
|
+
highWater: page.ok ? page.highWater : page.highWater,
|
|
641
|
+
nextAfter: events.at(-1)?.source.sourceSequence ?? safeAfter,
|
|
642
|
+
runtimeId: query.runtimeId,
|
|
643
|
+
generation: query.generation,
|
|
644
|
+
runtimeEpoch: query.runtimeEpoch,
|
|
645
|
+
});
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
const page = state.journal.readSourcePage(query);
|
|
649
|
+
if (!page.ok) {
|
|
650
|
+
writeJson(response, 409, {
|
|
651
|
+
code: page.code,
|
|
652
|
+
retainedFrom: page.retainedFrom,
|
|
653
|
+
highWater: page.highWater,
|
|
654
|
+
coveredThrough: page.coveredThrough,
|
|
655
|
+
});
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
writeJson(response, 200, {
|
|
659
|
+
events: page.events,
|
|
660
|
+
retainedFrom: page.retainedFrom,
|
|
661
|
+
highWater: page.highWater,
|
|
662
|
+
nextAfter: page.nextAfter,
|
|
663
|
+
runtimeId: page.runtimeId,
|
|
664
|
+
generation: page.generation,
|
|
665
|
+
runtimeEpoch: page.runtimeEpoch,
|
|
666
|
+
});
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
writeJson(response, 404, { error: 'not_found' });
|
|
670
|
+
}
|
|
671
|
+
function writeSourceStream(state, request, response, query) {
|
|
672
|
+
let cursor = query.after;
|
|
673
|
+
const buffered = [];
|
|
674
|
+
const matches = (event) => event.source.runtimeId === query.runtimeId &&
|
|
675
|
+
event.source.generation === query.generation &&
|
|
676
|
+
event.source.runtimeEpoch === query.runtimeEpoch &&
|
|
677
|
+
event.source.sourceSequence > cursor;
|
|
678
|
+
const unsubscribe = state.journal.onSourceEvent((event) => {
|
|
679
|
+
if (matches(event))
|
|
680
|
+
buffered.push(event);
|
|
681
|
+
});
|
|
682
|
+
const page = state.journal.readSourcePage({ ...query, limit: 500 });
|
|
683
|
+
response.writeHead(200, {
|
|
684
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
685
|
+
'cache-control': 'no-cache, no-transform',
|
|
686
|
+
connection: 'keep-alive',
|
|
687
|
+
'x-accel-buffering': 'no',
|
|
688
|
+
});
|
|
689
|
+
const writeEvent = (name, data) => {
|
|
690
|
+
response.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
691
|
+
};
|
|
692
|
+
if (!page.ok) {
|
|
693
|
+
writeEvent('source.gap', {
|
|
694
|
+
code: page.code,
|
|
695
|
+
retainedFrom: page.retainedFrom,
|
|
696
|
+
highWater: page.highWater,
|
|
697
|
+
coveredThrough: page.coveredThrough,
|
|
698
|
+
});
|
|
699
|
+
response.end();
|
|
700
|
+
unsubscribe();
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
writeEvent('source.ready', {
|
|
704
|
+
runtimeId: page.runtimeId,
|
|
705
|
+
generation: page.generation,
|
|
706
|
+
runtimeEpoch: page.runtimeEpoch,
|
|
707
|
+
retainedFrom: page.retainedFrom,
|
|
708
|
+
highWater: page.highWater,
|
|
709
|
+
nextAfter: page.nextAfter,
|
|
710
|
+
});
|
|
711
|
+
for (const event of page.events) {
|
|
712
|
+
writeEvent('source', event);
|
|
713
|
+
cursor = event.source.sourceSequence;
|
|
714
|
+
}
|
|
715
|
+
for (const event of buffered) {
|
|
716
|
+
if (!matches(event))
|
|
717
|
+
continue;
|
|
718
|
+
writeEvent('source', event);
|
|
719
|
+
cursor = event.source.sourceSequence;
|
|
720
|
+
}
|
|
721
|
+
unsubscribe();
|
|
722
|
+
const follow = state.journal.onSourceEvent((event) => {
|
|
723
|
+
if (!matches(event))
|
|
724
|
+
return;
|
|
725
|
+
writeEvent('source', event);
|
|
726
|
+
cursor = event.source.sourceSequence;
|
|
727
|
+
});
|
|
728
|
+
const heartbeat = setInterval(() => {
|
|
729
|
+
response.write(`: ping\n\n`);
|
|
730
|
+
}, 10_000);
|
|
731
|
+
heartbeat.unref?.();
|
|
732
|
+
const stop = () => {
|
|
733
|
+
clearInterval(heartbeat);
|
|
734
|
+
follow();
|
|
735
|
+
};
|
|
736
|
+
request.on('close', stop);
|
|
737
|
+
response.on('close', stop);
|
|
738
|
+
}
|
|
739
|
+
function headerValue(value) {
|
|
740
|
+
return Array.isArray(value) ? value[0] : value;
|
|
741
|
+
}
|
|
557
742
|
function asRecord(value) {
|
|
558
743
|
return value && typeof value === 'object' && !Array.isArray(value)
|
|
559
744
|
? 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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,9 @@
|
|
|
48
48
|
"@gltf-transform/core": "^4.3.0",
|
|
49
49
|
"@gltf-transform/extensions": "^4.3.0",
|
|
50
50
|
"@gltf-transform/functions": "^4.3.0",
|
|
51
|
-
"@playcraft/build": "^0.0
|
|
52
|
-
"@playcraft/common": "^0.0
|
|
51
|
+
"@playcraft/build": "^0.1.0",
|
|
52
|
+
"@playcraft/common": "^0.1.0",
|
|
53
|
+
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
53
54
|
"chokidar": "^4.0.3",
|
|
54
55
|
"commander": "^13.1.0",
|
|
55
56
|
"cors": "^2.8.6",
|
|
@@ -65,6 +66,7 @@
|
|
|
65
66
|
"json5": "^2.2.3",
|
|
66
67
|
"latest-version": "^7.0.0",
|
|
67
68
|
"music-metadata": "^11.12.3",
|
|
69
|
+
"node-pty": "1.0.0",
|
|
68
70
|
"ora": "^8.2.0",
|
|
69
71
|
"picocolors": "^1.1.1",
|
|
70
72
|
"sharp": "^0.34.5",
|
|
@@ -82,6 +84,7 @@
|
|
|
82
84
|
"@types/sharp": "^0.32.0",
|
|
83
85
|
"@types/update-notifier": "^6.0.8",
|
|
84
86
|
"@types/ws": "^8.18.1",
|
|
87
|
+
"esbuild": "^0.28.1",
|
|
85
88
|
"typescript": "^5.9.3",
|
|
86
89
|
"vitest": "^3.2.4"
|
|
87
90
|
}
|