@playcraft/cli 0.1.13 → 0.1.15

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.
@@ -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 native?.close?.();
127
+ await disposeDesktopHost(state);
130
128
  }
131
- catch {
132
- /* adapter shutdown is best-effort */
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)?.dispose();
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.native?.close?.();
594
+ await disposeDesktopHost(state);
594
595
  }
595
- catch {
596
- /* Codex process shutdown is best-effort once the runtime is destroyed */
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: a second call gets the running one. The
35
- * returned task settles when that install has finished or been found current.
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
- return existing;
43
- const task = installOnce(state, workspaceId, root)
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.delete(workspaceId));
48
- inFlight.set(workspaceId, task);
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
- await Promise.all([...(running.get(state)?.values() ?? [])]);
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.13",
3
+ "version": "0.1.15",
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.13",
52
- "@playcraft/common": "^0.1.13",
51
+ "@playcraft/build": "^0.1.15",
52
+ "@playcraft/common": "^0.1.15",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",