@playcraft/cli 0.1.13 → 0.1.14
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/workspace-runtime/serve.js +13 -8
- package/dist/workspace-runtime/server/desktop-host.js +50 -5
- package/dist/workspace-runtime/server/desktop-preview-config.js +81 -0
- package/dist/workspace-runtime/server/desktop-preview-service.js +157 -0
- package/dist/workspace-runtime/server/desktop-preview-static.js +53 -0
- package/dist/workspace-runtime/server/desktop-preview.js +189 -0
- package/dist/workspace-runtime/server/dispatch.js +9 -3
- package/dist/workspace-runtime/workspaces/dependencies.js +24 -9
- package/package.json +3 -3
|
@@ -120,19 +120,24 @@ export async function startWorkspaceRuntime(config, options = {}) {
|
|
|
120
120
|
state,
|
|
121
121
|
url: `http://${formatHost(config.listen.host)}:${port}`,
|
|
122
122
|
async close() {
|
|
123
|
+
const serverClosed = closeServer(server);
|
|
123
124
|
await closeCliCredentials(state);
|
|
124
125
|
await gitCredentials?.close();
|
|
125
|
-
disposeDesktopHost(state);
|
|
126
|
-
disposeTerminalHost(state);
|
|
127
|
-
await closeServer(server);
|
|
128
126
|
try {
|
|
129
|
-
await
|
|
127
|
+
await disposeDesktopHost(state);
|
|
130
128
|
}
|
|
131
|
-
|
|
132
|
-
|
|
129
|
+
finally {
|
|
130
|
+
disposeTerminalHost(state);
|
|
131
|
+
try {
|
|
132
|
+
await native?.close?.();
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* adapter shutdown is best-effort */
|
|
136
|
+
}
|
|
137
|
+
await serverClosed;
|
|
138
|
+
await journal?.close();
|
|
139
|
+
lock.release();
|
|
133
140
|
}
|
|
134
|
-
await journal?.close();
|
|
135
|
-
lock.release();
|
|
136
141
|
},
|
|
137
142
|
};
|
|
138
143
|
}
|
|
@@ -3,6 +3,8 @@ import { WebSocket, WebSocketServer } from 'ws';
|
|
|
3
3
|
import { desktopHealthSchema, desktopResourceName, runtimeError, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
4
|
import { controlTokenMatches, extractControlToken } from './auth.js';
|
|
5
5
|
import { createRfbWatchGate } from './rfb-watch-gate.js';
|
|
6
|
+
import { DesktopPreviewError, DesktopPreviewHost } from './desktop-preview.js';
|
|
7
|
+
import { WorkspaceContextError } from '../workspaces/context-error.js';
|
|
6
8
|
/**
|
|
7
9
|
* A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
|
|
8
10
|
* own unless the user renews it.
|
|
@@ -25,10 +27,11 @@ export function desktopHost(state) {
|
|
|
25
27
|
}
|
|
26
28
|
return host;
|
|
27
29
|
}
|
|
28
|
-
export function disposeDesktopHost(state) {
|
|
29
|
-
hosts.get(state)
|
|
30
|
+
export async function disposeDesktopHost(state) {
|
|
31
|
+
const host = hosts.get(state);
|
|
30
32
|
hosts.delete(state);
|
|
31
33
|
sources.delete(state);
|
|
34
|
+
await host?.dispose();
|
|
32
35
|
}
|
|
33
36
|
export class DesktopHost {
|
|
34
37
|
state;
|
|
@@ -42,6 +45,8 @@ export class DesktopHost {
|
|
|
42
45
|
holdMs = holdDuration(process.env.PLAYCRAFT_DESKTOP_HOLD_MS);
|
|
43
46
|
holdExpiresAt;
|
|
44
47
|
holdTimer;
|
|
48
|
+
preview;
|
|
49
|
+
disposed = false;
|
|
45
50
|
constructor(state) {
|
|
46
51
|
this.state = state;
|
|
47
52
|
this.source = sources.get(state);
|
|
@@ -67,7 +72,7 @@ export class DesktopHost {
|
|
|
67
72
|
return this.snapshot();
|
|
68
73
|
}
|
|
69
74
|
async handleHttp(request, response, url) {
|
|
70
|
-
if (this.state.journal.destroyed()) {
|
|
75
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
71
76
|
writeError(response, 'runtime_destroyed');
|
|
72
77
|
return;
|
|
73
78
|
}
|
|
@@ -75,6 +80,43 @@ export class DesktopHost {
|
|
|
75
80
|
writeJson(response, 200, await this.refresh());
|
|
76
81
|
return;
|
|
77
82
|
}
|
|
83
|
+
if (request.method === 'POST' && url.pathname === '/runtime/desktop/open') {
|
|
84
|
+
await this.refresh();
|
|
85
|
+
if (this.currentStatus() !== 'ready') {
|
|
86
|
+
writeError(response, this.currentStatus());
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (this.holder === 'human') {
|
|
90
|
+
writeJson(response, 200, this.snapshot());
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
this.preview ??= new DesktopPreviewHost(this.state, {
|
|
95
|
+
canNavigate: () => !this.disposed && !this.state.journal.destroyed() && this.holder !== 'human',
|
|
96
|
+
});
|
|
97
|
+
await this.preview.open(url.searchParams.get('workspaceId') ?? undefined);
|
|
98
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
99
|
+
writeError(response, 'runtime_destroyed');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const health = await this.refresh();
|
|
103
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
104
|
+
writeError(response, 'runtime_destroyed');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (health.status !== 'ready') {
|
|
108
|
+
writeError(response, health.status);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
writeJson(response, 200, health);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
const reason = error instanceof DesktopPreviewError ? error.code
|
|
115
|
+
: error instanceof WorkspaceContextError ? error.message : 'desktop_preview_start_failed';
|
|
116
|
+
writeError(response, reason);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
78
120
|
if (request.method === 'POST' && url.pathname === '/runtime/desktop/takeover') {
|
|
79
121
|
await this.refresh();
|
|
80
122
|
if (this.currentStatus() !== 'ready') {
|
|
@@ -119,7 +161,7 @@ export class DesktopHost {
|
|
|
119
161
|
rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
|
|
120
162
|
return;
|
|
121
163
|
}
|
|
122
|
-
if (this.state.journal.destroyed()) {
|
|
164
|
+
if (this.disposed || this.state.journal.destroyed()) {
|
|
123
165
|
rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
|
|
124
166
|
return;
|
|
125
167
|
}
|
|
@@ -134,9 +176,11 @@ export class DesktopHost {
|
|
|
134
176
|
}
|
|
135
177
|
this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(ws, generation));
|
|
136
178
|
}
|
|
137
|
-
dispose() {
|
|
179
|
+
async dispose() {
|
|
180
|
+
this.disposed = true;
|
|
138
181
|
this.release();
|
|
139
182
|
this.sockets.close();
|
|
183
|
+
await this.preview?.dispose();
|
|
140
184
|
}
|
|
141
185
|
release() {
|
|
142
186
|
this.clearHold();
|
|
@@ -311,6 +355,7 @@ export class DesktopHost {
|
|
|
311
355
|
}
|
|
312
356
|
export function desktopRoute(pathname) {
|
|
313
357
|
return (pathname === '/runtime/desktop/health' ||
|
|
358
|
+
pathname === '/runtime/desktop/open' ||
|
|
314
359
|
pathname === '/runtime/desktop/connect' ||
|
|
315
360
|
pathname === '/runtime/desktop/takeover' ||
|
|
316
361
|
pathname === '/runtime/desktop/renew' ||
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
3
|
+
import { DesktopPreviewError } from './desktop-preview.js';
|
|
4
|
+
import JSON5 from 'json5';
|
|
5
|
+
export async function previewPlan(root) {
|
|
6
|
+
const config = await optionalJson(root, '.playcraft/workspace.json');
|
|
7
|
+
const preview = object(config?.preview);
|
|
8
|
+
if (config?.preview !== undefined) {
|
|
9
|
+
if (!preview || typeof preview !== 'object' || typeof preview.command !== 'string' || !preview.command.trim()
|
|
10
|
+
|| (preview.cwd !== undefined && typeof preview.cwd !== 'string') || (preview.path !== undefined && typeof preview.path !== 'string')) {
|
|
11
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
12
|
+
}
|
|
13
|
+
const cwd = typeof preview.cwd === 'string' ? preview.cwd : '.';
|
|
14
|
+
if (isAbsolute(cwd))
|
|
15
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
16
|
+
let realRoot;
|
|
17
|
+
try {
|
|
18
|
+
realRoot = await realpath(join(root, cwd));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
22
|
+
}
|
|
23
|
+
const resolved = relative(await realpath(root), realRoot);
|
|
24
|
+
if (resolved === '..' || resolved.startsWith(`..${sep}`) || !(await stat(realRoot)).isDirectory()) {
|
|
25
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
26
|
+
}
|
|
27
|
+
return { root: realRoot, command: preview.command, path: entryPath(preview.path), dependencies: await exists(join(realRoot, 'package.json')) };
|
|
28
|
+
}
|
|
29
|
+
const manifest = await optionalJson(root, 'package.json');
|
|
30
|
+
if (manifest) {
|
|
31
|
+
const dev = object(manifest.scripts)?.dev;
|
|
32
|
+
const vite = object(manifest.dependencies)?.vite ?? object(manifest.devDependencies)?.vite;
|
|
33
|
+
if (typeof dev === 'string' && /^\s*vite(?:\s|$)/.test(dev) && typeof vite === 'string') {
|
|
34
|
+
return { root, command: 'pnpm run dev --host 127.0.0.1 --port {port} --strictPort', path: '/', dependencies: true };
|
|
35
|
+
}
|
|
36
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
37
|
+
}
|
|
38
|
+
return { root, path: '/', dependencies: false, welcome: !await exists(join(root, 'index.html')) };
|
|
39
|
+
}
|
|
40
|
+
function entryPath(path) {
|
|
41
|
+
if (path === undefined)
|
|
42
|
+
return '/';
|
|
43
|
+
if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//') || path.includes('\\')) {
|
|
44
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
45
|
+
}
|
|
46
|
+
const url = new URL(path, 'http://127.0.0.1');
|
|
47
|
+
if (url.origin !== 'http://127.0.0.1')
|
|
48
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
49
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
50
|
+
}
|
|
51
|
+
async function exists(path) { try {
|
|
52
|
+
return (await stat(path)).isFile();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
} }
|
|
57
|
+
export async function requiresNodeModules(root) {
|
|
58
|
+
const manifest = await optionalJson(root, 'package.json');
|
|
59
|
+
return ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']
|
|
60
|
+
.some((key) => Object.keys(object(manifest?.[key]) ?? {}).length > 0);
|
|
61
|
+
}
|
|
62
|
+
async function optionalJson(root, path) {
|
|
63
|
+
try {
|
|
64
|
+
const resolved = await realpath(join(root, path));
|
|
65
|
+
const location = relative(await realpath(root), resolved);
|
|
66
|
+
if (location === '..' || location.startsWith(`..${sep}`))
|
|
67
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
68
|
+
const result = object(JSON5.parse(await readFile(resolved, 'utf8')));
|
|
69
|
+
if (!result)
|
|
70
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error.code === 'ENOENT')
|
|
75
|
+
return undefined;
|
|
76
|
+
throw new DesktopPreviewError('desktop_preview_unconfigured');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function object(value) {
|
|
80
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
81
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:net';
|
|
3
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
4
|
+
import { startLauncherProcess } from '../codex/launcher-client.js';
|
|
5
|
+
import { DesktopPreviewError, quote } from './desktop-preview.js';
|
|
6
|
+
// The supervisor runs under the same developer identity as its service. It proves that the
|
|
7
|
+
// loopback listener belongs to its process group before Runtime probes the configured page.
|
|
8
|
+
const supervisor = String.raw `
|
|
9
|
+
const fs = require('node:fs');
|
|
10
|
+
const {spawn,execFileSync} = require('node:child_process');
|
|
11
|
+
const [command,portText,marker] = process.argv.slice(1);
|
|
12
|
+
const port = Number(portText);
|
|
13
|
+
const child = spawn(process.platform==='linux'?'/usr/bin/bash':'/bin/bash',['-lc',command],{stdio:['ignore','inherit','inherit']});
|
|
14
|
+
child.on('error',()=>process.exit(97));
|
|
15
|
+
child.on('exit',code=>process.exit(code === 0 ? 97 : code || 97));
|
|
16
|
+
function listeners() {
|
|
17
|
+
if (process.platform === 'linux') {
|
|
18
|
+
const sockets = ['tcp','tcp6'].flatMap(kind => fs.readFileSync('/proc/net/'+kind,'utf8').trim().split('\n').slice(1).flatMap(line=>{
|
|
19
|
+
const columns=line.trim().split(/\s+/); const [address,p]=columns[1].split(':');
|
|
20
|
+
return parseInt(p,16)===port && columns[3]==='0A' ? [{id:columns[9],local:address==='0100007F'||address==='00000000000000000000000001000000'}] : [];
|
|
21
|
+
}));
|
|
22
|
+
const owned=new Set();
|
|
23
|
+
for (const pid of fs.readdirSync('/proc').filter(name=>/^\d+$/.test(name))) {
|
|
24
|
+
try {
|
|
25
|
+
const stat=fs.readFileSync('/proc/'+pid+'/stat','utf8');
|
|
26
|
+
if (Number(stat.slice(stat.lastIndexOf(')')+2).split(' ')[2])!==process.pid) continue;
|
|
27
|
+
for (const fd of fs.readdirSync('/proc/'+pid+'/fd')) {
|
|
28
|
+
try { const match=fs.readlinkSync('/proc/'+pid+'/fd/'+fd).match(/^socket:\[(\d+)\]$/); if(match)owned.add(match[1]); } catch {}
|
|
29
|
+
}
|
|
30
|
+
} catch {}
|
|
31
|
+
}
|
|
32
|
+
return sockets.map(socket=>({local:socket.local,owned:owned.has(socket.id)}));
|
|
33
|
+
}
|
|
34
|
+
const pids=new Set(execFileSync('/bin/ps',['-axo','pid=,pgid='],{encoding:'utf8'}).trim().split('\n').flatMap(line=>{
|
|
35
|
+
const [pid,group]=line.trim().split(/\s+/); return Number(group)===process.pid?[Number(pid)]:[];
|
|
36
|
+
}));
|
|
37
|
+
let listing='';try{listing=execFileSync('/usr/sbin/lsof',['-nP','-iTCP:'+port,'-sTCP:LISTEN','-Fpn'],{encoding:'utf8',stdio:['ignore','pipe','ignore']});}catch{}
|
|
38
|
+
let pid=0;return listing.split('\n').flatMap(line=>{
|
|
39
|
+
if(line.startsWith('p'))pid=Number(line.slice(1));
|
|
40
|
+
return line.startsWith('n')?[{local:line.slice(1).startsWith('127.0.0.1:')||line.slice(1).startsWith('[::1]:'),owned:pids.has(pid)}]:[];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
let ready=false;
|
|
44
|
+
const timer=setInterval(()=>{
|
|
45
|
+
try {
|
|
46
|
+
const found=listeners();
|
|
47
|
+
if(found.some(socket=>!socket.local||!socket.owned)){console.log(marker+':port');process.exit(98);}
|
|
48
|
+
if(!ready&&found.some(socket=>socket.owned)){ready=true;clearInterval(timer);console.log(marker+':ready');}
|
|
49
|
+
} catch {console.log(marker+':failed');process.exit(97);}
|
|
50
|
+
},50);
|
|
51
|
+
`;
|
|
52
|
+
export async function startManagedPreview(input) {
|
|
53
|
+
const { state, options } = input;
|
|
54
|
+
const socketPath = options.launcherSocket ?? process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
55
|
+
if (!socketPath)
|
|
56
|
+
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
57
|
+
const port = await reservePort();
|
|
58
|
+
const url = new URL(input.path, `http://127.0.0.1:${port}/`).href;
|
|
59
|
+
const marker = `PLAYCRAFT_PREVIEW_${randomUUID()}`;
|
|
60
|
+
const command = `export HOST=127.0.0.1 PORT=${port} PLAYCRAFT_PREVIEW_PORT=${port}; exec ${quote(process.execPath)} -e ${quote(supervisor)} -- ${quote(input.command.replaceAll('{port}', String(port)))} ${port} ${quote(marker)}`;
|
|
61
|
+
const processHandle = await launchPreviewProcess({ socketPath,
|
|
62
|
+
processId: `desktop-preview-service-${randomUUID()}`, runtimeEpoch: state.runtimeEpoch,
|
|
63
|
+
argv: ['/usr/bin/bash', '-lc', command], cwd: input.root, env: {} }, options, input.deadline, input.cancelled, 'desktop_preview_start_failed');
|
|
64
|
+
if (!processHandle)
|
|
65
|
+
return undefined;
|
|
66
|
+
let alive = true;
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let stopping;
|
|
69
|
+
let owned = false;
|
|
70
|
+
let failure;
|
|
71
|
+
let tail = '';
|
|
72
|
+
processHandle.onExit((code) => { alive = false; failure ??= code === 98 ? 'desktop_preview_port_unavailable' : 'desktop_preview_start_failed'; });
|
|
73
|
+
processHandle.stdout?.on('data', (chunk) => {
|
|
74
|
+
tail = (tail + chunk.toString('utf8')).slice(-8192);
|
|
75
|
+
if (tail.includes(`${marker}:ready`))
|
|
76
|
+
owned = true;
|
|
77
|
+
if (tail.includes(`${marker}:port`))
|
|
78
|
+
failure = 'desktop_preview_port_unavailable';
|
|
79
|
+
if (tail.includes(`${marker}:failed`))
|
|
80
|
+
failure = 'desktop_preview_start_failed';
|
|
81
|
+
});
|
|
82
|
+
processHandle.stderr?.resume();
|
|
83
|
+
const stop = () => stopping ??= processHandle.stop().then(() => { stopped = true; alive = false; });
|
|
84
|
+
try {
|
|
85
|
+
while (Date.now() < input.deadline) {
|
|
86
|
+
if (input.cancelled()) {
|
|
87
|
+
await stop();
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
if (!alive || failure)
|
|
91
|
+
throw new DesktopPreviewError(failure ?? 'desktop_preview_start_failed');
|
|
92
|
+
if (owned) {
|
|
93
|
+
try {
|
|
94
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(Math.min(500, Math.max(1, input.deadline - Date.now()))) });
|
|
95
|
+
await response.body?.cancel();
|
|
96
|
+
if (response.ok && alive)
|
|
97
|
+
return { url, alive: () => alive && !stopped && !stopping, stop };
|
|
98
|
+
}
|
|
99
|
+
catch { }
|
|
100
|
+
}
|
|
101
|
+
await delay(options.pollIntervalMs ?? 100);
|
|
102
|
+
}
|
|
103
|
+
throw new DesktopPreviewError('desktop_preview_timeout');
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
await stop();
|
|
107
|
+
throw error;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** Starting the launcher is bounded too; late replies are stopped after cancellation. */
|
|
111
|
+
export async function launchPreviewProcess(input, options, deadline, cancelled, failure) {
|
|
112
|
+
let finished = false;
|
|
113
|
+
let abandoned = false;
|
|
114
|
+
let failed = false;
|
|
115
|
+
let child;
|
|
116
|
+
void (options.startProcess ?? startLauncherProcess)(input).then((value) => {
|
|
117
|
+
child = value;
|
|
118
|
+
finished = true;
|
|
119
|
+
if (abandoned)
|
|
120
|
+
void value.stop().catch(() => undefined);
|
|
121
|
+
}, () => { finished = true; failed = true; });
|
|
122
|
+
while (!finished) {
|
|
123
|
+
if (cancelled()) {
|
|
124
|
+
abandoned = true;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
if (Date.now() >= deadline) {
|
|
128
|
+
abandoned = true;
|
|
129
|
+
throw new DesktopPreviewError('desktop_preview_timeout');
|
|
130
|
+
}
|
|
131
|
+
await delay(Math.min(options.pollIntervalMs ?? 50, Math.max(1, deadline - Date.now())));
|
|
132
|
+
}
|
|
133
|
+
if (failed || !child)
|
|
134
|
+
throw new DesktopPreviewError(failure);
|
|
135
|
+
if (cancelled()) {
|
|
136
|
+
await child.stop();
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
return child;
|
|
140
|
+
}
|
|
141
|
+
async function reservePort() {
|
|
142
|
+
const server = createServer();
|
|
143
|
+
try {
|
|
144
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
145
|
+
const address = server.address();
|
|
146
|
+
if (!address || typeof address === 'string')
|
|
147
|
+
throw new Error('port_unavailable');
|
|
148
|
+
return address.port;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
throw new DesktopPreviewError('desktop_preview_port_unavailable');
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
if (server.listening)
|
|
155
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, relative, sep } from 'node:path';
|
|
4
|
+
const types = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8',
|
|
5
|
+
'.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml',
|
|
6
|
+
'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp', '.wasm': 'application/wasm' };
|
|
7
|
+
export async function startStaticPreview(root, welcome = false) {
|
|
8
|
+
const realRoot = await realpath(root);
|
|
9
|
+
const server = createServer((request, response) => {
|
|
10
|
+
void (async () => {
|
|
11
|
+
try {
|
|
12
|
+
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://localhost').pathname);
|
|
13
|
+
if (welcome && pathname === '/') {
|
|
14
|
+
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
|
|
15
|
+
response.end(welcomePage());
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const segments = pathname.split('/').filter(Boolean);
|
|
19
|
+
if (segments.some((segment) => segment.startsWith('.')))
|
|
20
|
+
throw new Error('hidden_path');
|
|
21
|
+
const target = await realpath(join(realRoot, ...segments, ...(pathname.endsWith('/') ? ['index.html'] : [])));
|
|
22
|
+
const path = relative(realRoot, target);
|
|
23
|
+
if (path === '..' || path.startsWith(`..${sep}`) || !(await stat(target)).isFile())
|
|
24
|
+
throw new Error('outside_root');
|
|
25
|
+
const body = await readFile(target);
|
|
26
|
+
response.writeHead(200, { 'content-type': types[extname(target)] ?? 'application/octet-stream', 'cache-control': 'no-store' });
|
|
27
|
+
response.end(request.method === 'HEAD' ? undefined : body);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
response.writeHead(404);
|
|
31
|
+
response.end();
|
|
32
|
+
}
|
|
33
|
+
})();
|
|
34
|
+
});
|
|
35
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
36
|
+
const address = server.address();
|
|
37
|
+
if (!address || typeof address === 'string')
|
|
38
|
+
throw new Error('preview_port_unavailable');
|
|
39
|
+
let stopped = false;
|
|
40
|
+
return { url: `http://127.0.0.1:${address.port}/`, alive: () => server.listening && !stopped,
|
|
41
|
+
async stop() {
|
|
42
|
+
if (stopped)
|
|
43
|
+
return;
|
|
44
|
+
stopped = true;
|
|
45
|
+
server.closeAllConnections();
|
|
46
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
47
|
+
} };
|
|
48
|
+
}
|
|
49
|
+
function welcomePage() {
|
|
50
|
+
const messages = { en: { title: 'Your workspace desktop is ready', body: 'Add a project to see its preview here.' },
|
|
51
|
+
zh: { title: '工作区桌面已就绪', body: '添加项目后,这里会显示项目预览。' } };
|
|
52
|
+
return `<!doctype html><html lang="en"><meta charset="utf-8"><title>${messages.en.title}</title><style>body{margin:0;background:#f1f0eb;color:#252724;font:22px system-ui;display:grid;min-height:100vh;place-items:center}main{max-width:680px;padding:48px}h1{font-size:42px}p{line-height:1.7;color:#596159}</style><main><h1>${messages.en.title}</h1><p>${messages.en.body}</p><h2 lang="zh">${messages.zh.title}</h2><p lang="zh">${messages.zh.body}</p></main></html>`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { lstat } from 'node:fs/promises';
|
|
5
|
+
import { workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
|
|
6
|
+
import { startStaticPreview } from './desktop-preview-static.js';
|
|
7
|
+
import { previewPlan, requiresNodeModules } from './desktop-preview-config.js';
|
|
8
|
+
import { launchPreviewProcess, startManagedPreview } from './desktop-preview-service.js';
|
|
9
|
+
import { planInstall, scheduleDependencyInstall, readDependencies } from '../workspaces/dependencies.js';
|
|
10
|
+
export class DesktopPreviewError extends Error {
|
|
11
|
+
code;
|
|
12
|
+
constructor(code) {
|
|
13
|
+
super(code);
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.name = 'DesktopPreviewError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** One visible browser; project services and browser profiles survive workspace switches. */
|
|
19
|
+
export class DesktopPreviewHost {
|
|
20
|
+
state;
|
|
21
|
+
options;
|
|
22
|
+
pending = new Map();
|
|
23
|
+
servers = new Map();
|
|
24
|
+
emptyDependencyReady = new Map();
|
|
25
|
+
activeBrowser;
|
|
26
|
+
activation = Promise.resolve();
|
|
27
|
+
disposed = false;
|
|
28
|
+
disposal;
|
|
29
|
+
requestedKey;
|
|
30
|
+
constructor(state, options = {}) {
|
|
31
|
+
this.state = state;
|
|
32
|
+
this.options = options;
|
|
33
|
+
}
|
|
34
|
+
open(workspaceId) {
|
|
35
|
+
if (!this.canNavigate())
|
|
36
|
+
return Promise.resolve();
|
|
37
|
+
const key = workspaceId ?? 'single';
|
|
38
|
+
this.requestedKey = key;
|
|
39
|
+
const existing = this.pending.get(key);
|
|
40
|
+
if (existing)
|
|
41
|
+
return existing;
|
|
42
|
+
const task = this.openWorkspace(key, workspaceId).catch((error) => {
|
|
43
|
+
if (error instanceof DesktopPreviewError || error instanceof WorkspaceContextError)
|
|
44
|
+
throw error;
|
|
45
|
+
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
46
|
+
}).finally(() => this.pending.delete(key));
|
|
47
|
+
this.pending.set(key, task);
|
|
48
|
+
return task;
|
|
49
|
+
}
|
|
50
|
+
dispose() {
|
|
51
|
+
if (this.disposal)
|
|
52
|
+
return this.disposal;
|
|
53
|
+
this.disposed = true;
|
|
54
|
+
this.disposal = (async () => {
|
|
55
|
+
const stopped = await Promise.allSettled([
|
|
56
|
+
...(this.activeBrowser ? [this.activeBrowser.process.stop()] : []),
|
|
57
|
+
...[...this.servers.values()].map(({ server }) => server.stop()),
|
|
58
|
+
]);
|
|
59
|
+
await Promise.allSettled([...this.pending.values()]);
|
|
60
|
+
this.servers.clear();
|
|
61
|
+
this.activeBrowser = undefined;
|
|
62
|
+
if (stopped.some((result) => result.status === 'rejected'))
|
|
63
|
+
throw new DesktopPreviewError('desktop_preview_start_failed');
|
|
64
|
+
})();
|
|
65
|
+
return this.disposal;
|
|
66
|
+
}
|
|
67
|
+
canNavigate() {
|
|
68
|
+
return !this.disposed && !this.state.journal.destroyed() && (this.options.canNavigate?.() ?? true);
|
|
69
|
+
}
|
|
70
|
+
async openWorkspace(key, workspaceId) {
|
|
71
|
+
const root = await workspaceRoot(this.state, workspaceId);
|
|
72
|
+
const deadline = Date.now() + (this.options.timeoutMs ?? 90_000);
|
|
73
|
+
if (!this.canNavigate())
|
|
74
|
+
return;
|
|
75
|
+
const plan = await previewPlan(root);
|
|
76
|
+
if (!this.canNavigate())
|
|
77
|
+
return;
|
|
78
|
+
if (plan.dependencies) {
|
|
79
|
+
const dependencyDeadline = Math.min(deadline, Date.now() + (this.options.dependencyTimeoutMs ?? 60_000));
|
|
80
|
+
if (!await this.ensureDependencies(key, plan.root, dependencyDeadline))
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!this.canNavigate())
|
|
84
|
+
return;
|
|
85
|
+
const signature = JSON.stringify(plan);
|
|
86
|
+
const entry = this.servers.get(key);
|
|
87
|
+
let server = entry?.server;
|
|
88
|
+
if (!server?.alive() || entry?.signature !== signature) {
|
|
89
|
+
await server?.stop();
|
|
90
|
+
if (!this.canNavigate())
|
|
91
|
+
return;
|
|
92
|
+
server = plan.command ? await startManagedPreview({ state: this.state, options: this.options,
|
|
93
|
+
root: plan.root, command: plan.command, path: plan.path, deadline, cancelled: () => !this.canNavigate() }) : await startStaticPreview(root, plan.welcome);
|
|
94
|
+
if (!server)
|
|
95
|
+
return;
|
|
96
|
+
if (!this.canNavigate()) {
|
|
97
|
+
await server.stop();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.servers.set(key, { server, signature });
|
|
101
|
+
}
|
|
102
|
+
const current = this.activation.then(() => this.activate(key, root, server, deadline));
|
|
103
|
+
this.activation = current.catch(() => undefined);
|
|
104
|
+
await current;
|
|
105
|
+
}
|
|
106
|
+
async ensureDependencies(key, root, deadline) {
|
|
107
|
+
const initialPlan = await planInstall(root);
|
|
108
|
+
if (initialPlan.kind === 'install' && !await requiresNodeModules(root)
|
|
109
|
+
&& this.emptyDependencyReady.get(root) === initialPlan.lockHash)
|
|
110
|
+
return this.canNavigate();
|
|
111
|
+
let finished = false;
|
|
112
|
+
const install = () => {
|
|
113
|
+
finished = false;
|
|
114
|
+
void scheduleDependencyInstall(this.state, key, root, () => this.canNavigate()).finally(() => { finished = true; });
|
|
115
|
+
};
|
|
116
|
+
if (!this.canNavigate())
|
|
117
|
+
return false;
|
|
118
|
+
install();
|
|
119
|
+
while (Date.now() < deadline) {
|
|
120
|
+
if (!this.canNavigate())
|
|
121
|
+
return false;
|
|
122
|
+
if (finished) {
|
|
123
|
+
const current = await planInstall(root);
|
|
124
|
+
if (!this.canNavigate())
|
|
125
|
+
return false;
|
|
126
|
+
const record = readDependencies(this.state, key);
|
|
127
|
+
if (record.status === 'failed')
|
|
128
|
+
throw new DesktopPreviewError('dependencies_not_ready');
|
|
129
|
+
if (current.kind === 'skip' && record.status === 'none')
|
|
130
|
+
return true;
|
|
131
|
+
if (current.kind === 'install' && record.status === 'ready' && !await requiresNodeModules(root)) {
|
|
132
|
+
this.emptyDependencyReady.set(root, current.lockHash);
|
|
133
|
+
return this.canNavigate();
|
|
134
|
+
}
|
|
135
|
+
if (current.kind === 'install' && record.lockHash === current.lockHash) {
|
|
136
|
+
const modules = await lstat(join(root, 'node_modules')).catch(() => undefined);
|
|
137
|
+
if (record.status === 'ready' && modules?.isDirectory() && !modules.isSymbolicLink())
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
// A changed manifest or lockfile needs a current install before this project can start.
|
|
141
|
+
if (!this.canNavigate())
|
|
142
|
+
return false;
|
|
143
|
+
install();
|
|
144
|
+
}
|
|
145
|
+
await delay(this.options.pollIntervalMs ?? 100);
|
|
146
|
+
}
|
|
147
|
+
if (!this.canNavigate())
|
|
148
|
+
return false;
|
|
149
|
+
throw new DesktopPreviewError('dependencies_not_ready');
|
|
150
|
+
}
|
|
151
|
+
async activate(key, root, server, deadline) {
|
|
152
|
+
const canActivate = () => this.canNavigate() && this.requestedKey === key;
|
|
153
|
+
if (!canActivate())
|
|
154
|
+
return;
|
|
155
|
+
if (this.activeBrowser?.alive && this.activeBrowser.key === key && this.activeBrowser.server === server)
|
|
156
|
+
return;
|
|
157
|
+
if (Date.now() >= deadline)
|
|
158
|
+
throw new DesktopPreviewError('desktop_preview_timeout');
|
|
159
|
+
const socketPath = this.options.launcherSocket ?? process.env.PLAYCRAFT_LAUNCHER_SOCKET;
|
|
160
|
+
if (!socketPath)
|
|
161
|
+
throw new DesktopPreviewError('desktop_browser_unavailable');
|
|
162
|
+
if (this.activeBrowser)
|
|
163
|
+
await this.activeBrowser.process.stop();
|
|
164
|
+
this.activeBrowser = undefined;
|
|
165
|
+
if (!canActivate())
|
|
166
|
+
return;
|
|
167
|
+
const profile = join(this.options.profileRoot ?? '/home/playcraft-dev/.cache/playcraft-desktop', key);
|
|
168
|
+
const executable = this.options.browserExecutable ?? '/usr/bin/chromium';
|
|
169
|
+
const command = `test -x ${quote(executable)} || exit 127; mkdir -p -- ${quote(profile)} || exit 1; exec ${quote(executable)} --no-first-run --no-default-browser-check --disable-dev-shm-usage --start-maximized --user-data-dir=${quote(profile)} --app=${quote(server.url)}`;
|
|
170
|
+
const child = await launchPreviewProcess({ socketPath,
|
|
171
|
+
processId: `desktop-preview-browser-${randomUUID()}`, runtimeEpoch: this.state.runtimeEpoch,
|
|
172
|
+
argv: ['/usr/bin/bash', '-lc', command], cwd: root, env: { DISPLAY: process.env.DISPLAY ?? ':0' } }, this.options, deadline, () => !canActivate(), 'desktop_browser_start_failed');
|
|
173
|
+
if (!child)
|
|
174
|
+
return;
|
|
175
|
+
const browser = { key, server, process: child, alive: true };
|
|
176
|
+
let exitCode;
|
|
177
|
+
child.onExit((code) => { browser.alive = false; exitCode = code; });
|
|
178
|
+
child.stdout?.resume();
|
|
179
|
+
child.stderr?.resume();
|
|
180
|
+
this.activeBrowser = browser;
|
|
181
|
+
await delay(150);
|
|
182
|
+
if (!browser.alive) {
|
|
183
|
+
await child.stop();
|
|
184
|
+
this.activeBrowser = undefined;
|
|
185
|
+
throw new DesktopPreviewError(exitCode === 127 ? 'desktop_browser_unavailable' : 'desktop_browser_start_failed');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
export function quote(value) { return `'${value.replaceAll("'", "'\\''")}'`; }
|
|
@@ -10,6 +10,7 @@ import { loadWorkspaceSkillArtifact } from '../workspaces/skill-artifact-loader.
|
|
|
10
10
|
import { enableLocalProjectSkill, syncLocalProjectSkills } from '../../project-skills/local-store.js';
|
|
11
11
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
12
12
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
13
|
+
import { disposeDesktopHost } from './desktop-host.js';
|
|
13
14
|
import { captureWorkspaceTree, liveTreeDigest, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
|
|
14
15
|
import { objectStoreHeaders, oneSaveAtATime, rememberFileCheckpoint, unchangedCheckpoint, workspaceSaves, } from '../workspaces/checkpoint-saves.js';
|
|
15
16
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
@@ -590,10 +591,15 @@ async function executeDestroy(state, scopeKey, operation) {
|
|
|
590
591
|
await state.gitCredentials?.destroy();
|
|
591
592
|
disposeTerminalHost(state);
|
|
592
593
|
try {
|
|
593
|
-
await state
|
|
594
|
+
await disposeDesktopHost(state);
|
|
594
595
|
}
|
|
595
|
-
|
|
596
|
-
|
|
596
|
+
finally {
|
|
597
|
+
try {
|
|
598
|
+
await state.native?.close?.();
|
|
599
|
+
}
|
|
600
|
+
catch {
|
|
601
|
+
/* Codex process shutdown is best-effort once the runtime is destroyed */
|
|
602
|
+
}
|
|
597
603
|
}
|
|
598
604
|
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
599
605
|
await recordNativeEvents(state, accepted, [
|
|
@@ -31,26 +31,41 @@ const running = new WeakMap();
|
|
|
31
31
|
/**
|
|
32
32
|
* Starts the install after a workspace is registered; opening the workspace never waits for it.
|
|
33
33
|
* Registering again retries a failed install and reinstalls after the manifest or lockfile
|
|
34
|
-
* changed. One install runs per workspace at a time:
|
|
35
|
-
*
|
|
34
|
+
* changed. One install runs per workspace at a time: the same directory shares its task; another
|
|
35
|
+
* directory waits for it before installing. The returned task covers the requested directory.
|
|
36
36
|
*/
|
|
37
|
-
export function scheduleDependencyInstall(state, workspaceId, root) {
|
|
37
|
+
export function scheduleDependencyInstall(state, workspaceId, root, shouldStart) {
|
|
38
38
|
const inFlight = running.get(state) ?? new Map();
|
|
39
39
|
running.set(state, inFlight);
|
|
40
40
|
const existing = inFlight.get(workspaceId);
|
|
41
|
-
if (existing)
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
if (existing?.root === root) {
|
|
42
|
+
if (!shouldStart)
|
|
43
|
+
existing.guards = undefined;
|
|
44
|
+
else
|
|
45
|
+
existing.guards?.push(shouldStart);
|
|
46
|
+
return existing.task;
|
|
47
|
+
}
|
|
48
|
+
const scheduled = { root, task: Promise.resolve(), guards: shouldStart ? [shouldStart] : undefined };
|
|
49
|
+
const begin = () => {
|
|
50
|
+
if (state.journal.destroyed() || (scheduled.guards && !scheduled.guards.some((guard) => guard())))
|
|
51
|
+
return;
|
|
52
|
+
return installOnce(state, workspaceId, root);
|
|
53
|
+
};
|
|
54
|
+
const task = (existing ? existing.task.then(begin) : begin() ?? Promise.resolve())
|
|
44
55
|
.catch((error) => {
|
|
45
56
|
console.error(`[workspace-runtime] dependency install for ${workspaceId} failed to run`, error);
|
|
46
57
|
})
|
|
47
|
-
.finally(() => inFlight.
|
|
48
|
-
|
|
58
|
+
.finally(() => { if (inFlight.get(workspaceId)?.task === task)
|
|
59
|
+
inFlight.delete(workspaceId); });
|
|
60
|
+
scheduled.task = task;
|
|
61
|
+
inFlight.set(workspaceId, scheduled);
|
|
49
62
|
return task;
|
|
50
63
|
}
|
|
51
64
|
/** Settles every install started in this process; used by tests and shutdown. */
|
|
52
65
|
export async function settleDependencyInstalls(state) {
|
|
53
|
-
|
|
66
|
+
while (running.get(state)?.size) {
|
|
67
|
+
await Promise.all([...(running.get(state)?.values() ?? [])].map((record) => record.task));
|
|
68
|
+
}
|
|
54
69
|
}
|
|
55
70
|
export function readDependencies(state, workspaceId) {
|
|
56
71
|
return state.journal.getWorkspaceDependencies(workspaceId) ?? { workspaceId, status: 'pending' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -48,8 +48,8 @@
|
|
|
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.1.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
51
|
+
"@playcraft/build": "^0.1.14",
|
|
52
|
+
"@playcraft/common": "^0.1.14",
|
|
53
53
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
54
54
|
"chokidar": "^4.0.3",
|
|
55
55
|
"commander": "^13.1.0",
|