@playcraft/cli 0.0.59 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,26 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { canExecuteKind, executeOperation } from './dispatch.js';
7
7
  import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/files.js';
8
8
  import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
9
9
  import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
10
10
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
11
+ import { desktopHost, desktopRoute } from './desktop-host.js';
12
+ import { terminalHost } from './terminal-host.js';
11
13
  export async function handleRuntimeRequest(state, request, response) {
12
14
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
13
- if (!controlTokenMatches(state.config.controlToken, extractControlToken(request.headers))) {
15
+ const access = authorizeRuntime(state, request);
16
+ if (!access) {
14
17
  writeError(response, runtimeError('unauthenticated', 'control_token_required'));
15
18
  return;
16
19
  }
20
+ if (access === 'read' && !readSourcePath(request.method ?? 'GET', url.pathname)) {
21
+ writeJson(response, 403, { error: 'source_read_only' });
22
+ return;
23
+ }
17
24
  try {
18
25
  if (request.method === 'GET' && url.pathname === '/runtime/health') {
19
26
  writeJson(response, 200, { status: 'ok', handshake: handshake(state) });
@@ -28,6 +35,7 @@ export async function handleRuntimeRequest(state, request, response) {
28
35
  generation,
29
36
  runtimeEpoch: state.runtimeEpoch,
30
37
  instanceToken: state.instanceToken,
38
+ sourceReadToken: state.journal.sourceReadToken(),
31
39
  workspaceMode: state.config.workspaceMode,
32
40
  executionGroup: {
33
41
  replaced: state.journal.executionGroup().replaced,
@@ -80,11 +88,8 @@ export async function handleRuntimeRequest(state, request, response) {
80
88
  });
81
89
  return;
82
90
  }
83
- if (request.method === 'GET' && url.pathname === '/runtime/events') {
84
- const after = Number(url.searchParams.get('after') ?? '0');
85
- writeJson(response, 200, {
86
- events: state.journal.listEvents(Number.isInteger(after) ? after : 0),
87
- });
91
+ if (url.pathname === '/runtime/events' || url.pathname.startsWith('/runtime/events/')) {
92
+ await handleSourceEvents(state, request, response, url);
88
93
  return;
89
94
  }
90
95
  if (request.method === 'GET' && url.pathname === '/runtime/files') {
@@ -143,6 +148,10 @@ export async function handleRuntimeRequest(state, request, response) {
143
148
  await handleTerminalIdentity(state, request, response, url.pathname.slice('/runtime/terminals/'.length));
144
149
  return;
145
150
  }
151
+ if (desktopRoute(url.pathname)) {
152
+ await desktopHost(state).handleHttp(request, response, url);
153
+ return;
154
+ }
146
155
  if (request.method === 'GET' && url.pathname === '/runtime/operations') {
147
156
  const kind = operationKindSchema.safeParse(url.searchParams.get('kind'));
148
157
  const clientRequestId = url.searchParams.get('clientRequestId');
@@ -219,9 +228,10 @@ function handshake(state) {
219
228
  : capability('terminal', true);
220
229
  }
221
230
  if (item.name === 'desktop') {
222
- return destroyed
223
- ? capability('desktop', false, 'runtime_destroyed')
224
- : capability('desktop', false, 'display_not_started');
231
+ if (destroyed)
232
+ return capability('desktop', false, 'runtime_destroyed');
233
+ const status = desktopHost(state).snapshot().status;
234
+ return status === 'ready' ? capability('desktop', true) : capability('desktop', false, status);
225
235
  }
226
236
  if (item.name === 'parameters') {
227
237
  return destroyed
@@ -502,7 +512,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
502
512
  writeError(response, runtimeError('capability_unavailable', 'terminal_destroyed'));
503
513
  return;
504
514
  }
505
- writeError(response, runtimeError('capability_unavailable', 'pty_not_started'));
515
+ writeError(response, runtimeError('capability_unavailable', 'websocket_required'));
506
516
  return;
507
517
  }
508
518
  if (request.method === 'GET' && !action) {
@@ -517,6 +527,7 @@ async function handleTerminalIdentity(state, request, response, remainder) {
517
527
  status: 'destroyed',
518
528
  destroyedAt: new Date().toISOString(),
519
529
  });
530
+ terminalHost(state).revoke(destroyed.terminalId, 'terminal_destroyed');
520
531
  writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
521
532
  return;
522
533
  }
@@ -554,6 +565,180 @@ function writeJson(response, status, body) {
554
565
  });
555
566
  response.end(payload);
556
567
  }
568
+ function authorizeRuntime(state, request) {
569
+ const presented = extractControlToken(request.headers);
570
+ if (controlTokenMatches(state.config.controlToken, presented))
571
+ return 'control';
572
+ const dedicated = headerValue(request.headers['x-playcraft-source-read-token']);
573
+ const candidate = dedicated || presented;
574
+ if (candidate && controlTokenMatches(state.journal.sourceReadToken(), candidate))
575
+ return 'read';
576
+ return null;
577
+ }
578
+ function readSourcePath(method, pathname) {
579
+ if (method === 'GET' && pathname === '/runtime/events')
580
+ return true;
581
+ if (method === 'POST' && pathname === '/runtime/events/replay-pins')
582
+ return true;
583
+ if (method === 'DELETE' && pathname.startsWith('/runtime/events/replay-pins/'))
584
+ return true;
585
+ return false;
586
+ }
587
+ async function handleSourceEvents(state, request, response, url) {
588
+ if (request.method === 'POST' && url.pathname === '/runtime/events/replay-pins') {
589
+ const pin = await state.journal.createReplayPin({
590
+ runtimeId: state.config.runtimeId,
591
+ generation: state.config.generation,
592
+ runtimeEpoch: state.runtimeEpoch,
593
+ ttlMs: Number(asRecord(await readJson(request)).ttlMs) || undefined,
594
+ });
595
+ writeJson(response, 201, pin);
596
+ return;
597
+ }
598
+ if (request.method === 'DELETE' && url.pathname.startsWith('/runtime/events/replay-pins/')) {
599
+ await state.journal.deleteReplayPin(decodeURIComponent(url.pathname.slice('/runtime/events/replay-pins/'.length)));
600
+ writeJson(response, 200, { deleted: true });
601
+ return;
602
+ }
603
+ if (request.method === 'POST' && url.pathname === '/runtime/events/ack') {
604
+ const parsed = sourceAckSchema.safeParse(await readJson(request));
605
+ if (!parsed.success) {
606
+ writeJson(response, 400, { error: 'invalid_source_ack' });
607
+ return;
608
+ }
609
+ try {
610
+ await state.journal.acknowledgeSource(parsed.data);
611
+ }
612
+ catch (error) {
613
+ writeJson(response, 409, { code: error instanceof Error ? error.message : 'ack_rejected' });
614
+ return;
615
+ }
616
+ writeJson(response, 200, { ok: true, acceptedLeaseFence: state.journal.acceptedLeaseFence() });
617
+ return;
618
+ }
619
+ if (request.method === 'GET' && url.pathname === '/runtime/events') {
620
+ const after = Number(url.searchParams.get('after') ?? '0');
621
+ const safeAfter = Number.isInteger(after) && after >= 0 ? after : 0;
622
+ const epochQuery = url.searchParams.get('runtimeEpoch');
623
+ const runtimeEpoch = epochQuery === null ? state.runtimeEpoch : Number(epochQuery);
624
+ const query = {
625
+ runtimeId: state.config.runtimeId,
626
+ generation: state.config.generation,
627
+ runtimeEpoch: Number.isInteger(runtimeEpoch) ? runtimeEpoch : state.runtimeEpoch,
628
+ after: safeAfter,
629
+ };
630
+ if (String(request.headers.accept ?? '').includes('text/event-stream')) {
631
+ writeSourceStream(state, request, response, query);
632
+ return;
633
+ }
634
+ if (epochQuery === null) {
635
+ const events = state.journal.listEvents(safeAfter);
636
+ const page = state.journal.readSourcePage({ ...query, limit: 1 });
637
+ writeJson(response, 200, {
638
+ events,
639
+ retainedFrom: page.ok ? page.retainedFrom : page.retainedFrom,
640
+ highWater: page.ok ? page.highWater : page.highWater,
641
+ nextAfter: events.at(-1)?.source.sourceSequence ?? safeAfter,
642
+ runtimeId: query.runtimeId,
643
+ generation: query.generation,
644
+ runtimeEpoch: query.runtimeEpoch,
645
+ });
646
+ return;
647
+ }
648
+ const page = state.journal.readSourcePage(query);
649
+ if (!page.ok) {
650
+ writeJson(response, 409, {
651
+ code: page.code,
652
+ retainedFrom: page.retainedFrom,
653
+ highWater: page.highWater,
654
+ coveredThrough: page.coveredThrough,
655
+ });
656
+ return;
657
+ }
658
+ writeJson(response, 200, {
659
+ events: page.events,
660
+ retainedFrom: page.retainedFrom,
661
+ highWater: page.highWater,
662
+ nextAfter: page.nextAfter,
663
+ runtimeId: page.runtimeId,
664
+ generation: page.generation,
665
+ runtimeEpoch: page.runtimeEpoch,
666
+ });
667
+ return;
668
+ }
669
+ writeJson(response, 404, { error: 'not_found' });
670
+ }
671
+ function writeSourceStream(state, request, response, query) {
672
+ let cursor = query.after;
673
+ const buffered = [];
674
+ const matches = (event) => event.source.runtimeId === query.runtimeId &&
675
+ event.source.generation === query.generation &&
676
+ event.source.runtimeEpoch === query.runtimeEpoch &&
677
+ event.source.sourceSequence > cursor;
678
+ const unsubscribe = state.journal.onSourceEvent((event) => {
679
+ if (matches(event))
680
+ buffered.push(event);
681
+ });
682
+ const page = state.journal.readSourcePage({ ...query, limit: 500 });
683
+ response.writeHead(200, {
684
+ 'content-type': 'text/event-stream; charset=utf-8',
685
+ 'cache-control': 'no-cache, no-transform',
686
+ connection: 'keep-alive',
687
+ 'x-accel-buffering': 'no',
688
+ });
689
+ const writeEvent = (name, data) => {
690
+ response.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`);
691
+ };
692
+ if (!page.ok) {
693
+ writeEvent('source.gap', {
694
+ code: page.code,
695
+ retainedFrom: page.retainedFrom,
696
+ highWater: page.highWater,
697
+ coveredThrough: page.coveredThrough,
698
+ });
699
+ response.end();
700
+ unsubscribe();
701
+ return;
702
+ }
703
+ writeEvent('source.ready', {
704
+ runtimeId: page.runtimeId,
705
+ generation: page.generation,
706
+ runtimeEpoch: page.runtimeEpoch,
707
+ retainedFrom: page.retainedFrom,
708
+ highWater: page.highWater,
709
+ nextAfter: page.nextAfter,
710
+ });
711
+ for (const event of page.events) {
712
+ writeEvent('source', event);
713
+ cursor = event.source.sourceSequence;
714
+ }
715
+ for (const event of buffered) {
716
+ if (!matches(event))
717
+ continue;
718
+ writeEvent('source', event);
719
+ cursor = event.source.sourceSequence;
720
+ }
721
+ unsubscribe();
722
+ const follow = state.journal.onSourceEvent((event) => {
723
+ if (!matches(event))
724
+ return;
725
+ writeEvent('source', event);
726
+ cursor = event.source.sourceSequence;
727
+ });
728
+ const heartbeat = setInterval(() => {
729
+ response.write(`: ping\n\n`);
730
+ }, 10_000);
731
+ heartbeat.unref?.();
732
+ const stop = () => {
733
+ clearInterval(heartbeat);
734
+ follow();
735
+ };
736
+ request.on('close', stop);
737
+ response.on('close', stop);
738
+ }
739
+ function headerValue(value) {
740
+ return Array.isArray(value) ? value[0] : value;
741
+ }
557
742
  function asRecord(value) {
558
743
  return value && typeof value === 'object' && !Array.isArray(value)
559
744
  ? value
@@ -0,0 +1,243 @@
1
+ import { createRequire } from 'node:module';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { lstat } from 'node:fs/promises';
4
+ import { WebSocketServer } from 'ws';
5
+ import { appendTerminalOutput, createTerminalOutputLog, decodeTerminalBytes, runtimeError, terminalClientFrameSchema, terminalOutputSince, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { resolveWorkspaceFile } from '../workspaces/files.js';
7
+ import { workspaceRoot } from '../workspaces/registry.js';
8
+ import { controlTokenMatches, extractControlToken } from './auth.js';
9
+ const require = createRequire(import.meta.url);
10
+ const pty = require('node-pty');
11
+ const commands = new WeakMap();
12
+ const hosts = new WeakMap();
13
+ export function setTerminalCommand(state, command) {
14
+ commands.set(state, command);
15
+ }
16
+ export function terminalHost(state) {
17
+ let host = hosts.get(state);
18
+ if (!host) {
19
+ host = new TerminalHost(state);
20
+ hosts.set(state, host);
21
+ }
22
+ return host;
23
+ }
24
+ export function disposeTerminalHost(state) {
25
+ hosts.get(state)?.dispose();
26
+ hosts.delete(state);
27
+ }
28
+ export class TerminalHost {
29
+ state;
30
+ sessions = new Map();
31
+ sockets = new WebSocketServer({ noServer: true });
32
+ constructor(state) {
33
+ this.state = state;
34
+ }
35
+ async handleUpgrade(request, socket, head) {
36
+ const url = new URL(request.url ?? '/', 'http://127.0.0.1');
37
+ const match = /^\/runtime\/terminals\/([^/]+)\/connect$/.exec(url.pathname);
38
+ if (!match || !controlTokenMatches(this.state.config.controlToken, extractControlToken(request.headers))) {
39
+ rejectUpgrade(socket, 401, runtimeError('unauthenticated', 'control_token_required'));
40
+ return;
41
+ }
42
+ if (this.state.journal.destroyed()) {
43
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'runtime_destroyed'));
44
+ return;
45
+ }
46
+ const terminalId = decodeURIComponent(match[1]);
47
+ const terminal = this.state.journal.getTerminal(terminalId);
48
+ if (!terminal) {
49
+ rejectUpgrade(socket, 404, { error: 'terminal_not_found' });
50
+ return;
51
+ }
52
+ if (terminal.status === 'destroyed') {
53
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
54
+ return;
55
+ }
56
+ let session = this.sessions.get(terminal.terminalId);
57
+ if (!session) {
58
+ try {
59
+ session = this.spawn(terminal, await terminalDirectory(this.state, terminal));
60
+ }
61
+ catch {
62
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'pty_unavailable'));
63
+ return;
64
+ }
65
+ }
66
+ const active = session;
67
+ this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(active, ws));
68
+ }
69
+ revoke(terminalId, reason) {
70
+ const session = this.sessions.get(terminalId);
71
+ if (!session)
72
+ return;
73
+ this.sessions.delete(terminalId);
74
+ const frame = { type: 'revoked', reason };
75
+ for (const listener of session.listeners)
76
+ listener(frame);
77
+ session.listeners.clear();
78
+ try {
79
+ session.pty.kill();
80
+ }
81
+ catch {
82
+ /* the process may already have exited */
83
+ }
84
+ }
85
+ dispose() {
86
+ for (const terminalId of [...this.sessions.keys()])
87
+ this.revoke(terminalId, 'runtime_destroyed');
88
+ this.sockets.close();
89
+ }
90
+ spawn(terminal, cwd) {
91
+ const command = commands.get(this.state) ?? defaultShell();
92
+ const session = {
93
+ streamId: randomUUID(),
94
+ log: createTerminalOutputLog(),
95
+ listeners: new Set(),
96
+ pty: pty.spawn(command.file, command.args, {
97
+ name: 'xterm-256color',
98
+ cols: 80,
99
+ rows: 24,
100
+ cwd,
101
+ env: shellEnv(command.file, cwd),
102
+ }),
103
+ };
104
+ session.pty.onData((data) => publish(session, data));
105
+ session.pty.onExit(({ exitCode, signal }) => {
106
+ session.exited = {
107
+ code: exitCode,
108
+ ...(signal === undefined ? {} : { signal: String(signal) }),
109
+ };
110
+ const frame = {
111
+ type: 'exit',
112
+ streamId: session.streamId,
113
+ code: exitCode,
114
+ ...(session.exited.signal ? { signal: session.exited.signal } : {}),
115
+ };
116
+ for (const listener of session.listeners)
117
+ listener(frame);
118
+ });
119
+ this.sessions.set(terminal.terminalId, session);
120
+ return session;
121
+ }
122
+ attach(session, ws) {
123
+ let open = true;
124
+ let resumed = false;
125
+ let cursor = 0;
126
+ const send = (frame) => {
127
+ if (open && ws.readyState === ws.OPEN)
128
+ ws.send(JSON.stringify(frame));
129
+ };
130
+ const listener = (frame) => {
131
+ if (!resumed)
132
+ return;
133
+ if (frame.type === 'output') {
134
+ const end = frame.offset + decodeTerminalBytes(frame.data).length;
135
+ if (end <= cursor)
136
+ return;
137
+ cursor = Math.max(cursor, end);
138
+ }
139
+ send(frame);
140
+ };
141
+ session.listeners.add(listener);
142
+ ws.on('message', (raw) => {
143
+ let body;
144
+ try {
145
+ body = JSON.parse(String(raw));
146
+ }
147
+ catch {
148
+ return;
149
+ }
150
+ const parsed = terminalClientFrameSchema.safeParse(body);
151
+ if (!parsed.success || session.exited && parsed.data.type !== 'resume')
152
+ return;
153
+ const frame = parsed.data;
154
+ if (frame.type === 'resume') {
155
+ if (frame.streamId && frame.streamId !== session.streamId) {
156
+ send({ type: 'reset', streamId: session.streamId, offset: session.log.end });
157
+ cursor = session.log.end;
158
+ resumed = true;
159
+ if (session.exited)
160
+ send(exitFrame(session));
161
+ return;
162
+ }
163
+ const since = terminalOutputSince(session.log, frame.offset);
164
+ if (since.gap)
165
+ send({ type: 'gap', streamId: session.streamId, offset: since.offset });
166
+ for (const item of since.frames) {
167
+ send({ type: 'output', streamId: session.streamId, offset: item.offset, data: item.data });
168
+ }
169
+ cursor = session.log.end;
170
+ resumed = true;
171
+ if (session.exited)
172
+ send(exitFrame(session));
173
+ return;
174
+ }
175
+ if (!resumed)
176
+ return;
177
+ if (frame.type === 'resize') {
178
+ session.pty.resize(frame.cols, frame.rows);
179
+ return;
180
+ }
181
+ session.pty.write(Buffer.from(decodeTerminalBytes(frame.data)).toString('utf8'));
182
+ });
183
+ const detach = () => {
184
+ open = false;
185
+ session.listeners.delete(listener);
186
+ };
187
+ ws.on('close', detach);
188
+ ws.on('error', detach);
189
+ }
190
+ }
191
+ function publish(session, text) {
192
+ const before = session.log.end;
193
+ session.log = appendTerminalOutput(session.log, text);
194
+ const since = terminalOutputSince(session.log, before);
195
+ for (const item of since.frames) {
196
+ const frame = {
197
+ type: 'output',
198
+ streamId: session.streamId,
199
+ offset: item.offset,
200
+ data: item.data,
201
+ };
202
+ for (const listener of session.listeners)
203
+ listener(frame);
204
+ }
205
+ }
206
+ function exitFrame(session) {
207
+ return {
208
+ type: 'exit',
209
+ streamId: session.streamId,
210
+ code: session.exited?.code ?? null,
211
+ ...(session.exited?.signal ? { signal: session.exited.signal } : {}),
212
+ };
213
+ }
214
+ async function terminalDirectory(state, terminal) {
215
+ const root = await workspaceRoot(state, terminal.workspaceId);
216
+ if (!terminal.cwd)
217
+ return root;
218
+ const resolved = await resolveWorkspaceFile(root, terminal.cwd);
219
+ const stat = await lstat(resolved);
220
+ if (!stat.isDirectory() || stat.isSymbolicLink())
221
+ throw new Error('pty_unavailable');
222
+ return resolved;
223
+ }
224
+ function defaultShell() {
225
+ const shell = process.env.SHELL;
226
+ const file = shell && shell.startsWith('/') ? shell : '/bin/sh';
227
+ return { file, args: ['-i'] };
228
+ }
229
+ function shellEnv(file, cwd) {
230
+ return {
231
+ PATH: process.env.PATH ?? '/usr/bin:/bin',
232
+ HOME: process.env.HOME ?? cwd,
233
+ TERM: 'xterm-256color',
234
+ LANG: process.env.LANG ?? 'C.UTF-8',
235
+ SHELL: file,
236
+ };
237
+ }
238
+ function rejectUpgrade(socket, status, body) {
239
+ const payload = JSON.stringify(body);
240
+ const title = status === 401 ? 'Unauthorized' : status === 404 ? 'Not Found' : 'Conflict';
241
+ socket.write(`HTTP/1.1 ${status} ${title}\r\ncontent-type: application/json; charset=utf-8\r\ncontent-length: ${Buffer.byteLength(payload)}\r\nconnection: close\r\n\r\n${payload}`);
242
+ socket.destroy();
243
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.0.59",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -48,8 +48,9 @@
48
48
  "@gltf-transform/core": "^4.3.0",
49
49
  "@gltf-transform/extensions": "^4.3.0",
50
50
  "@gltf-transform/functions": "^4.3.0",
51
- "@playcraft/build": "^0.0.59",
52
- "@playcraft/common": "^0.0.59",
51
+ "@playcraft/build": "^0.1.0",
52
+ "@playcraft/common": "^0.1.0",
53
+ "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
53
54
  "chokidar": "^4.0.3",
54
55
  "commander": "^13.1.0",
55
56
  "cors": "^2.8.6",
@@ -65,6 +66,7 @@
65
66
  "json5": "^2.2.3",
66
67
  "latest-version": "^7.0.0",
67
68
  "music-metadata": "^11.12.3",
69
+ "node-pty": "1.0.0",
68
70
  "ora": "^8.2.0",
69
71
  "picocolors": "^1.1.1",
70
72
  "sharp": "^0.34.5",
@@ -82,6 +84,7 @@
82
84
  "@types/sharp": "^0.32.0",
83
85
  "@types/update-notifier": "^6.0.8",
84
86
  "@types/ws": "^8.18.1",
87
+ "esbuild": "^0.28.1",
85
88
  "typescript": "^5.9.3",
86
89
  "vitest": "^3.2.4"
87
90
  }