@playcraft/cli 0.1.12 → 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.
@@ -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
  }
@@ -2,6 +2,9 @@ import { connect as connectTcp } from 'node:net';
2
2
  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
+ import { createRfbWatchGate } from './rfb-watch-gate.js';
6
+ import { DesktopPreviewError, DesktopPreviewHost } from './desktop-preview.js';
7
+ import { WorkspaceContextError } from '../workspaces/context-error.js';
5
8
  /**
6
9
  * A takeover holds the desktop for this long, whether or not a page is watching, and ends on its
7
10
  * own unless the user renews it.
@@ -24,10 +27,11 @@ export function desktopHost(state) {
24
27
  }
25
28
  return host;
26
29
  }
27
- export function disposeDesktopHost(state) {
28
- hosts.get(state)?.dispose();
30
+ export async function disposeDesktopHost(state) {
31
+ const host = hosts.get(state);
29
32
  hosts.delete(state);
30
33
  sources.delete(state);
34
+ await host?.dispose();
31
35
  }
32
36
  export class DesktopHost {
33
37
  state;
@@ -41,6 +45,8 @@ export class DesktopHost {
41
45
  holdMs = holdDuration(process.env.PLAYCRAFT_DESKTOP_HOLD_MS);
42
46
  holdExpiresAt;
43
47
  holdTimer;
48
+ preview;
49
+ disposed = false;
44
50
  constructor(state) {
45
51
  this.state = state;
46
52
  this.source = sources.get(state);
@@ -66,7 +72,7 @@ export class DesktopHost {
66
72
  return this.snapshot();
67
73
  }
68
74
  async handleHttp(request, response, url) {
69
- if (this.state.journal.destroyed()) {
75
+ if (this.disposed || this.state.journal.destroyed()) {
70
76
  writeError(response, 'runtime_destroyed');
71
77
  return;
72
78
  }
@@ -74,6 +80,43 @@ export class DesktopHost {
74
80
  writeJson(response, 200, await this.refresh());
75
81
  return;
76
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
+ }
77
120
  if (request.method === 'POST' && url.pathname === '/runtime/desktop/takeover') {
78
121
  await this.refresh();
79
122
  if (this.currentStatus() !== 'ready') {
@@ -118,7 +161,7 @@ export class DesktopHost {
118
161
  rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
119
162
  return;
120
163
  }
121
- if (this.state.journal.destroyed()) {
164
+ if (this.disposed || this.state.journal.destroyed()) {
122
165
  rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
123
166
  return;
124
167
  }
@@ -133,9 +176,11 @@ export class DesktopHost {
133
176
  }
134
177
  this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(ws, generation));
135
178
  }
136
- dispose() {
179
+ async dispose() {
180
+ this.disposed = true;
137
181
  this.release();
138
182
  this.sockets.close();
183
+ await this.preview?.dispose();
139
184
  }
140
185
  release() {
141
186
  this.clearHold();
@@ -190,6 +235,7 @@ export class DesktopHost {
190
235
  }
191
236
  else if (this.upstream) {
192
237
  const upstream = new WebSocket(`ws://127.0.0.1:${this.upstream.port}/`);
238
+ const watch = createRfbWatchGate();
193
239
  viewer.upstream = upstream;
194
240
  viewer.detach = () => upstream.close();
195
241
  upstream.on('error', () => {
@@ -204,12 +250,34 @@ export class DesktopHost {
204
250
  return;
205
251
  }
206
252
  const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
253
+ watch.observeServer(chunk);
254
+ if (watch.failed) {
255
+ ws.close(1008, 'desktop_protocol_error');
256
+ upstream.close();
257
+ return;
258
+ }
207
259
  send(chunk, isBinary);
208
260
  });
209
261
  upstream.on('close', () => {
210
262
  if (ws.readyState === WebSocket.OPEN)
211
263
  ws.close();
212
264
  });
265
+ ws.on('message', (data, isBinary) => {
266
+ if (this.generation !== generation)
267
+ return;
268
+ const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data);
269
+ if (viewer.upstream?.readyState !== WebSocket.OPEN)
270
+ return;
271
+ const outgoing = watch.clientBytes(chunk, this.holder === 'human');
272
+ if (watch.failed) {
273
+ ws.close(1008, 'desktop_protocol_error');
274
+ upstream.close();
275
+ return;
276
+ }
277
+ for (const message of outgoing) {
278
+ viewer.upstream.send(message, { binary: isBinary });
279
+ }
280
+ });
213
281
  }
214
282
  this.viewers.add(viewer);
215
283
  let closed = false;
@@ -223,12 +291,6 @@ export class DesktopHost {
223
291
  };
224
292
  ws.on('close', close);
225
293
  ws.on('error', close);
226
- ws.on('message', (data, isBinary) => {
227
- if (this.holder !== 'human' || this.generation !== generation)
228
- return;
229
- if (viewer.upstream?.readyState === WebSocket.OPEN)
230
- viewer.upstream.send(data, { binary: isBinary });
231
- });
232
294
  }
233
295
  rejectPlainConnect(response, url) {
234
296
  if (this.currentStatus() !== 'ready') {
@@ -293,6 +355,7 @@ export class DesktopHost {
293
355
  }
294
356
  export function desktopRoute(pathname) {
295
357
  return (pathname === '/runtime/desktop/health' ||
358
+ pathname === '/runtime/desktop/open' ||
296
359
  pathname === '/runtime/desktop/connect' ||
297
360
  pathname === '/runtime/desktop/takeover' ||
298
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, [
@@ -0,0 +1,168 @@
1
+ /**
2
+ * RFB (Remote Framebuffer, 远程帧缓冲) viewing needs a handshake and picture requests.
3
+ * This gate supports the sandbox's version 3.8 / None security negotiation.
4
+ * Input requires a hold for the entire message; unsupported framing closes the stream.
5
+ */
6
+ const INPUT_TYPES = new Set([4, 5, 6, 250, 251, 255]);
7
+ const VERSION = Buffer.from('RFB 003.008\n');
8
+ const MAX_CLIPBOARD_BYTES = 1_000_000;
9
+ export function createRfbWatchGate() {
10
+ let server = Buffer.alloc(0);
11
+ let client = Buffer.alloc(0);
12
+ let phase = 'server-version';
13
+ let messageInputAllowed = false;
14
+ const fail = () => {
15
+ phase = 'failed';
16
+ server = Buffer.alloc(0);
17
+ client = Buffer.alloc(0);
18
+ };
19
+ const observeServer = (chunk) => {
20
+ if (phase === 'ready' || phase === 'failed')
21
+ return;
22
+ let offset = 0;
23
+ while (offset < chunk.length) {
24
+ let size = serverMessageSize(server, phase);
25
+ if (size === 'invalid') {
26
+ fail();
27
+ return;
28
+ }
29
+ const count = Math.min(size - server.length, chunk.length - offset);
30
+ server = Buffer.concat([server, chunk.subarray(offset, offset + count)]);
31
+ offset += count;
32
+ size = serverMessageSize(server, phase);
33
+ if (size === 'invalid') {
34
+ fail();
35
+ return;
36
+ }
37
+ if (server.length < size)
38
+ continue;
39
+ if (phase === 'server-version' && server.equals(VERSION))
40
+ phase = 'client-version';
41
+ else if (phase === 'server-types' && server.subarray(1).includes(1))
42
+ phase = 'client-security';
43
+ else if (phase === 'server-result' && server.readUInt32BE(0) === 0)
44
+ phase = 'client-init';
45
+ else if (phase === 'server-init')
46
+ phase = 'ready';
47
+ else {
48
+ fail();
49
+ return;
50
+ }
51
+ server = Buffer.alloc(0);
52
+ // The first picture can share the ServerInit chunk; it needs no buffering.
53
+ if (phase === 'ready')
54
+ return;
55
+ }
56
+ };
57
+ const clientBytes = (chunk, allowInput) => {
58
+ if (phase === 'failed')
59
+ return [];
60
+ const outgoing = [];
61
+ let offset = 0;
62
+ if (client.length > 0)
63
+ messageInputAllowed &&= allowInput;
64
+ while (offset < chunk.length) {
65
+ if (client.length === 0)
66
+ messageInputAllowed = allowInput;
67
+ let size = phase === 'client-version' ? 12
68
+ : phase === 'client-security' || phase === 'client-init' ? 1
69
+ : phase === 'server-init' || phase === 'ready' ? clientMessageSize(client)
70
+ : 'invalid';
71
+ if (size === 'invalid') {
72
+ fail();
73
+ return [];
74
+ }
75
+ const count = Math.min(size - client.length, chunk.length - offset);
76
+ client = Buffer.concat([client, chunk.subarray(offset, offset + count)]);
77
+ offset += count;
78
+ if (phase === 'server-init' || phase === 'ready')
79
+ size = clientMessageSize(client);
80
+ if (size === 'invalid') {
81
+ fail();
82
+ return [];
83
+ }
84
+ if (client.length < size)
85
+ continue;
86
+ if (phase === 'client-version' && client.equals(VERSION))
87
+ phase = 'server-types';
88
+ else if (phase === 'client-security' && client[0] === 1)
89
+ phase = 'server-result';
90
+ else if (phase === 'client-init' && client[0] <= 1)
91
+ phase = 'server-init';
92
+ else if (phase !== 'server-init' && phase !== 'ready') {
93
+ fail();
94
+ return [];
95
+ }
96
+ else if (INPUT_TYPES.has(client[0]) && !messageInputAllowed) {
97
+ client = Buffer.alloc(0);
98
+ continue;
99
+ }
100
+ outgoing.push(client);
101
+ client = Buffer.alloc(0);
102
+ }
103
+ return outgoing;
104
+ };
105
+ return { get failed() { return phase === 'failed'; }, observeServer, clientBytes };
106
+ }
107
+ function serverMessageSize(buffer, phase) {
108
+ if (phase === 'server-version')
109
+ return 12;
110
+ if (phase === 'server-types') {
111
+ if (buffer.length < 1)
112
+ return 1;
113
+ return buffer[0] === 0 ? 'invalid' : 1 + buffer[0];
114
+ }
115
+ if (phase === 'server-result')
116
+ return 4;
117
+ if (phase === 'server-init') {
118
+ if (buffer.length < 24)
119
+ return 24;
120
+ const length = buffer.readUInt32BE(20);
121
+ return length > 4096 ? 'invalid' : 24 + length;
122
+ }
123
+ return 'invalid';
124
+ }
125
+ function clientMessageSize(buffer) {
126
+ if (buffer.length === 0)
127
+ return 1;
128
+ const type = buffer[0];
129
+ if (type === 0)
130
+ return 20;
131
+ if (type === 3 || type === 150)
132
+ return 10;
133
+ if (type === 4)
134
+ return 8;
135
+ if (type === 5)
136
+ return 6;
137
+ if (type === 250)
138
+ return 4;
139
+ if (type === 2) {
140
+ if (buffer.length < 4)
141
+ return 4;
142
+ const count = buffer.readUInt16BE(2);
143
+ return count > 64 ? 'invalid' : 4 + count * 4;
144
+ }
145
+ if (type === 6) {
146
+ if (buffer.length < 8)
147
+ return 8;
148
+ // Extended clipboard messages encode their payload size as a negative length.
149
+ const length = Math.abs(buffer.readInt32BE(4));
150
+ return length > MAX_CLIPBOARD_BYTES ? 'invalid' : 8 + length;
151
+ }
152
+ if (type === 248) {
153
+ if (buffer.length < 9)
154
+ return 9;
155
+ return buffer[8] > 64 ? 'invalid' : 9 + buffer[8];
156
+ }
157
+ if (type === 251) {
158
+ if (buffer.length < 8)
159
+ return 8;
160
+ return buffer[6] > 16 ? 'invalid' : 8 + buffer[6] * 16;
161
+ }
162
+ if (type === 255) {
163
+ if (buffer.length < 2)
164
+ return 2;
165
+ return buffer[1] === 0 ? 12 : 'invalid';
166
+ }
167
+ return 'invalid';
168
+ }
@@ -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' };
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
- import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { isLocalWorkspaceGitAuthMode, isLocalWorkspaceGitEnvironment, matchWorkspaceGitGatewayCloneUrl, sameWorkspaceRegistration, workspacePreparationSchema, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { WorkspaceContextError } from './context-error.js';
7
7
  import { scaffoldEmptyWorkspace } from './empty-template.js';
8
8
  /** The caller holds the Workspace lock. Only an absent, never-published directory may be prepared. */
@@ -29,7 +29,11 @@ export async function prepareWorkspaceCheckout(state, binding, input, leaseFence
29
29
  throw new WorkspaceContextError('workspace_recovery_required');
30
30
  if (await exists(target))
31
31
  throw new WorkspaceContextError('workspace_checkout_conflict');
32
- const receipt = previous ?? {
32
+ // An unfinished checkout recorded before the fork instructions were known must be cloned again.
33
+ const forkMatches = !previous || samePendingFork(previous.binding, binding);
34
+ if (previous && !forkMatches)
35
+ await rm(join(parent, previous.stagingName), { recursive: true, force: true });
36
+ const receipt = previous && forkMatches ? previous : {
33
37
  binding,
34
38
  stagingName: `.prepare-${binding.workspaceId}-${randomUUID()}`,
35
39
  status: 'cloning',
@@ -172,11 +176,15 @@ async function exists(path) {
172
176
  throw error;
173
177
  }
174
178
  }
179
+ function samePendingFork(left, right) {
180
+ const fork = (value) => value.targetKind === 'standalone' ? undefined : value.pendingFork;
181
+ return JSON.stringify(fork(left) ?? null) === JSON.stringify(fork(right) ?? null);
182
+ }
175
183
  async function readReceipt(path, binding) {
176
184
  try {
177
185
  const value = JSON.parse(await readFile(path, 'utf8'));
178
186
  const parsed = workspaceRegistrationSchema.parse(value.binding);
179
- if (JSON.stringify(parsed) !== JSON.stringify(binding) ||
187
+ if (!sameWorkspaceRegistration(parsed, binding) ||
180
188
  !['cloning', 'ready', 'published'].includes(value.status) ||
181
189
  !new RegExp(`^\\.prepare-${binding.workspaceId}-[0-9a-f-]{36}$`).test(value.stagingName))
182
190
  throw new Error();
@@ -2,7 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { lstat, realpath } from 'node:fs/promises';
3
3
  import { join, relative } from 'node:path';
4
4
  import { promisify } from 'node:util';
5
- import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { isLocalWorkspaceGitAuthMode, sameWorkspaceRegistration, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { prepareWorkspaceCheckout } from './prepare.js';
7
7
  import { WorkspaceContextError } from './context-error.js';
8
8
  import { scheduleDependencyInstall } from './dependencies.js';
@@ -64,7 +64,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
64
64
  state.acceptedLeaseFence = leaseFence;
65
65
  await state.journal.setAcceptedLeaseFence(leaseFence);
66
66
  const existing = state.journal.getWorkspace(binding.workspaceId);
67
- if (existing && JSON.stringify(existing) !== JSON.stringify(binding))
67
+ if (existing && !sameWorkspaceRegistration(existing, binding))
68
68
  throw new WorkspaceContextError('workspace_binding_conflict');
69
69
  let root;
70
70
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.12",
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.12",
52
- "@playcraft/common": "^0.1.12",
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",