@playcraft/cli 0.1.3 → 0.1.4

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.
@@ -39,6 +39,7 @@ export async function startLauncherProcess(input) {
39
39
  const stderr = new PassThrough();
40
40
  const events = new EventEmitter();
41
41
  let exited = false;
42
+ let exitCode;
42
43
  let buffer = '';
43
44
  let identity;
44
45
  let stopPromise;
@@ -56,7 +57,7 @@ export async function startLauncherProcess(input) {
56
57
  if (exited)
57
58
  return;
58
59
  exited = true;
59
- events.emit('exit');
60
+ events.emit('exit', exitCode);
60
61
  }
61
62
  socket.setEncoding('utf8');
62
63
  const pid = await new Promise((resolve, reject) => {
@@ -97,8 +98,10 @@ export async function startLauncherProcess(input) {
97
98
  stream.once('drain', () => socket.resume());
98
99
  }
99
100
  }
100
- else if (frame.event === 'exit')
101
+ else if (frame.event === 'exit') {
102
+ exitCode = typeof frame.code === 'number' ? frame.code : null;
101
103
  exit();
104
+ }
102
105
  else
103
106
  throw new Error('launcher_invalid_frame');
104
107
  }
@@ -128,7 +131,7 @@ export async function startLauncherProcess(input) {
128
131
  stderr,
129
132
  onExit(listener) {
130
133
  if (exited)
131
- queueMicrotask(listener);
134
+ queueMicrotask(() => listener(exitCode));
132
135
  else
133
136
  events.once('exit', listener);
134
137
  },
@@ -137,6 +137,19 @@ export class RuntimeJournal {
137
137
  async putManagedWrite(record) {
138
138
  await this.run('upsertRecord', { bucket: 'managed_writes', key: record.writeId, body: record });
139
139
  }
140
+ getWorkspaceDependencies(workspaceId) {
141
+ return this.snapshot.workspaceDependencies[workspaceId];
142
+ }
143
+ listWorkspaceDependencies() {
144
+ return Object.values(this.snapshot.workspaceDependencies);
145
+ }
146
+ async putWorkspaceDependencies(record) {
147
+ await this.run('upsertRecord', {
148
+ bucket: 'workspace_dependencies',
149
+ key: record.workspaceId,
150
+ body: record,
151
+ });
152
+ }
140
153
  fileRevisions(workspaceId) {
141
154
  return { ...(workspaceId ? this.snapshot.workspaceFileRevisions[workspaceId] ?? {} : this.snapshot.fileRevisions) };
142
155
  }
@@ -355,6 +368,7 @@ function emptySnapshot() {
355
368
  fileRevisions: {},
356
369
  approvals: {},
357
370
  managedWrites: {},
371
+ workspaceDependencies: {},
358
372
  executionGroup: { stopConfirmed: false, replaced: false },
359
373
  lastSourceSequence: 0,
360
374
  acceptedLeaseFence: 0,
@@ -705,6 +705,7 @@ function dump(database) {
705
705
  fileRevisions: bucketObject(database, 'file_revisions'),
706
706
  approvals: bucketObject(database, 'approvals'),
707
707
  managedWrites: bucketObject(database, 'managed_writes'),
708
+ workspaceDependencies: bucketObject(database, 'workspace_dependencies'),
708
709
  fileCheckpointId: metaString(database, 'file_checkpoint_id') || undefined,
709
710
  consistentCheckpointId: metaString(database, 'consistent_checkpoint_id') || undefined,
710
711
  executionGroup: executionGroup(database),
@@ -14,6 +14,7 @@ import { RuntimeProcessLock } from './server/lock.js';
14
14
  import { probeGitBinary } from './workspaces/git.js';
15
15
  import { WorkspaceGitCredentialBroker } from './workspaces/git-credential-broker.js';
16
16
  import { WorkspaceModelBroker } from './codex/model-broker.js';
17
+ import { recoverInterruptedInstalls } from './workspaces/dependencies.js';
17
18
  export async function startWorkspaceRuntime(config, options = {}) {
18
19
  process.env.PLAYCRAFT_SKIP_UPDATE_CHECK = '1';
19
20
  const negotiated = negotiateProtocol(config);
@@ -31,6 +32,7 @@ export async function startWorkspaceRuntime(config, options = {}) {
31
32
  try {
32
33
  journal = new RuntimeJournal(config.stateDir);
33
34
  await journal.load();
35
+ await recoverInterruptedInstalls(journal);
34
36
  await mkdir(config.workspaceRoot, { recursive: true, mode: 0o700 });
35
37
  const state = {
36
38
  config: { ...config, protocolMinor: negotiated.protocolMinor },
@@ -8,6 +8,7 @@ import { InvalidWorkspacePathError, listWorkspaceFiles, } from '../workspaces/fi
8
8
  import { materializeRemoteSnapshot } from '../workspaces/file-snapshot.js';
9
9
  import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitFile, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
10
10
  import { readWorkspaceParameters } from '../workspaces/parameters.js';
11
+ import { readDependencies } from '../workspaces/dependencies.js';
11
12
  import { desktopHost, desktopRoute } from './desktop-host.js';
12
13
  import { terminalHost } from './terminal-host.js';
13
14
  export async function handleRuntimeRequest(state, request, response) {
@@ -140,6 +141,14 @@ export async function handleRuntimeRequest(state, request, response) {
140
141
  await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), url.searchParams.get('path') ?? '', ref.data));
141
142
  return;
142
143
  }
144
+ if (request.method === 'GET' && url.pathname === '/runtime/dependencies') {
145
+ if (writeDestroyed(state, response))
146
+ return;
147
+ const workspaceId = url.searchParams.get('workspaceId') ?? undefined;
148
+ await workspaceRoot(state, workspaceId);
149
+ writeJson(response, 200, readDependencies(state, workspaceId ?? 'workspace'));
150
+ return;
151
+ }
143
152
  if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
144
153
  await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
145
154
  return;
@@ -0,0 +1,229 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { appendFile, lstat, mkdir, readFile, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { startLauncherProcess } from '../codex/launcher-client.js';
6
+ /** Long enough for a cold store on a game template; a stuck registry must not hold the write lease. */
7
+ const INSTALL_TIMEOUT_MS = 10 * 60_000;
8
+ const LOG_TAIL_LINES = 50;
9
+ const LOG_TAIL_MAX_CHARS = 8_000;
10
+ const WRITE_PREFIX = 'dependencies-';
11
+ /**
12
+ * Projects are managed with pnpm. It follows pnpm-lock.yaml when the lockfile matches, never
13
+ * prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
14
+ * sandbox and the install runs as the development user.
15
+ */
16
+ const INSTALL_COMMAND = 'pnpm install --reporter=append-only --config.confirm-modules-purge=false';
17
+ /** Installs whenever there is a package.json; the hash says whether a finished install is current. */
18
+ export async function planInstall(root) {
19
+ const manifest = await readOptional(join(root, 'package.json'));
20
+ if (manifest === undefined)
21
+ return { kind: 'skip' };
22
+ const lock = (await readOptional(join(root, 'pnpm-lock.yaml'))) ?? '';
23
+ return {
24
+ kind: 'install',
25
+ lockHash: createHash('sha256').update(manifest).update('\0').update(lock).digest('hex'),
26
+ };
27
+ }
28
+ const running = new WeakMap();
29
+ /**
30
+ * Starts the install after a workspace is registered and returns at once; opening the workspace
31
+ * never waits for it. Registering again retries a failed install and reinstalls after the
32
+ * manifest or lockfile changed. One install runs per workspace at a time.
33
+ */
34
+ export function scheduleDependencyInstall(state, workspaceId, root) {
35
+ const inFlight = running.get(state) ?? new Map();
36
+ running.set(state, inFlight);
37
+ if (inFlight.has(workspaceId))
38
+ return;
39
+ const task = installOnce(state, workspaceId, root)
40
+ .catch((error) => {
41
+ console.error(`[workspace-runtime] dependency install for ${workspaceId} failed to run`, error);
42
+ })
43
+ .finally(() => inFlight.delete(workspaceId));
44
+ inFlight.set(workspaceId, task);
45
+ }
46
+ /** Settles every install started in this process; used by tests and shutdown. */
47
+ export async function settleDependencyInstalls(state) {
48
+ await Promise.all([...(running.get(state)?.values() ?? [])]);
49
+ }
50
+ export function readDependencies(state, workspaceId) {
51
+ return state.journal.getWorkspaceDependencies(workspaceId) ?? { workspaceId, status: 'pending' };
52
+ }
53
+ /**
54
+ * An install cut off by a restart left no process behind (the launcher stops the group when its
55
+ * connection closes), so its status and write lease are closed rather than left in progress.
56
+ */
57
+ export async function recoverInterruptedInstalls(journal) {
58
+ const now = new Date().toISOString();
59
+ for (const record of journal.listWorkspaceDependencies()) {
60
+ if (record.status !== 'installing')
61
+ continue;
62
+ await journal.putWorkspaceDependencies({
63
+ ...record,
64
+ status: 'failed',
65
+ error: 'interrupted',
66
+ finishedAt: now,
67
+ });
68
+ }
69
+ for (const write of journal.listManagedWrites()) {
70
+ if (write.writeId.startsWith(WRITE_PREFIX) && !write.stoppedAt) {
71
+ await journal.putManagedWrite({ ...write, stoppedAt: now });
72
+ }
73
+ }
74
+ }
75
+ async function installOnce(state, workspaceId, root) {
76
+ const plan = await planInstall(root);
77
+ const current = state.journal.getWorkspaceDependencies(workspaceId);
78
+ if (plan.kind === 'skip') {
79
+ if (current?.status !== 'none') {
80
+ await state.journal.putWorkspaceDependencies({
81
+ workspaceId,
82
+ status: 'none',
83
+ finishedAt: new Date().toISOString(),
84
+ });
85
+ }
86
+ return;
87
+ }
88
+ if (current?.status === 'ready' &&
89
+ current.lockHash === plan.lockHash &&
90
+ (await isDirectory(join(root, 'node_modules')))) {
91
+ return;
92
+ }
93
+ await excludeFromGit(root);
94
+ const startedAt = new Date().toISOString();
95
+ const writeId = `${WRITE_PREFIX}${workspaceId}-${randomUUID()}`;
96
+ // A checkpoint waits for this write: node_modules is half written until the install ends.
97
+ await state.journal.putManagedWrite({ writeId, workspaceId, source: 'background', startedAt });
98
+ await state.journal.putWorkspaceDependencies({
99
+ workspaceId,
100
+ status: 'installing',
101
+ startedAt,
102
+ });
103
+ let outcome;
104
+ try {
105
+ outcome = await runInstall(state, workspaceId, root);
106
+ }
107
+ finally {
108
+ await state.journal.putManagedWrite({
109
+ writeId,
110
+ workspaceId,
111
+ source: 'background',
112
+ startedAt,
113
+ stoppedAt: new Date().toISOString(),
114
+ });
115
+ }
116
+ const ok = outcome.code === 0 && !outcome.timedOut;
117
+ await state.journal.putWorkspaceDependencies({
118
+ workspaceId,
119
+ status: ok ? 'ready' : 'failed',
120
+ ...(ok
121
+ ? { lockHash: plan.lockHash }
122
+ : { error: outcome.timedOut ? 'install_timeout' : `install_exit_${outcome.code ?? 'lost'}` }),
123
+ startedAt,
124
+ finishedAt: new Date().toISOString(),
125
+ logTail: tail(outcome.log),
126
+ });
127
+ }
128
+ /**
129
+ * Runs as the development user through the launcher, so the packages belong to the same user as
130
+ * Codex and its own later installs. Without a launcher (local runs and tests) it runs directly.
131
+ */
132
+ async function runInstall(state, workspaceId, root) {
133
+ // Tests replace the install; a real registry is never reached from them.
134
+ const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND;
135
+ const command = `{ ${planned}; } </dev/null 2>&1`;
136
+ const logDir = join(state.config.stateDir, 'dependency-logs');
137
+ await mkdir(logDir, { recursive: true, mode: 0o700 });
138
+ const logPath = join(logDir, `${workspaceId}.log`);
139
+ await writeFile(logPath, `$ ${planned}\n`, { mode: 0o600 });
140
+ let log = '';
141
+ let pending = Promise.resolve();
142
+ const record = (chunk) => {
143
+ const text = String(chunk);
144
+ log = (log + text).slice(-64 * 1024);
145
+ pending = pending.then(() => appendFile(logPath, text)).catch(() => undefined);
146
+ };
147
+ const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
148
+ const launched = socketPath && state.config.workspaceMode === 'multi'
149
+ ? await startLauncherProcess({
150
+ socketPath,
151
+ processId: `${WRITE_PREFIX}${workspaceId}`,
152
+ runtimeEpoch: state.runtimeEpoch,
153
+ argv: ['/usr/bin/bash', '-lc', command],
154
+ cwd: root,
155
+ env: {},
156
+ })
157
+ : undefined;
158
+ launched?.stdin?.end();
159
+ const local = launched
160
+ ? undefined
161
+ : spawn('bash', ['-lc', command], {
162
+ cwd: root,
163
+ detached: true,
164
+ stdio: ['ignore', 'pipe', 'pipe'],
165
+ });
166
+ const stdout = launched?.stdout ?? local.stdout;
167
+ const stderr = launched?.stderr ?? local.stderr;
168
+ stdout.on('data', record);
169
+ stderr.on('data', record);
170
+ let timedOut = false;
171
+ const code = await new Promise((resolve) => {
172
+ const timer = setTimeout(() => {
173
+ timedOut = true;
174
+ if (launched)
175
+ void launched.stop().catch(() => undefined);
176
+ else if (local?.pid) {
177
+ try {
178
+ process.kill(-local.pid, 'SIGKILL');
179
+ }
180
+ catch {
181
+ /* already gone */
182
+ }
183
+ }
184
+ }, INSTALL_TIMEOUT_MS);
185
+ const finish = (value) => {
186
+ clearTimeout(timer);
187
+ resolve(value);
188
+ };
189
+ if (launched)
190
+ launched.onExit(finish);
191
+ else {
192
+ local.once('error', () => finish(null));
193
+ local.once('close', (value) => finish(value));
194
+ }
195
+ });
196
+ await pending;
197
+ return { code, timedOut, log };
198
+ }
199
+ /** A local-only ignore, never committed: the change list must not fill up with installed packages. */
200
+ async function excludeFromGit(root) {
201
+ const info = join(root, '.git', 'info');
202
+ if (!(await isDirectory(join(root, '.git'))))
203
+ return;
204
+ await mkdir(info, { recursive: true });
205
+ const path = join(info, 'exclude');
206
+ const current = (await readOptional(path)) ?? '';
207
+ if (current.split('\n').some((line) => line.trim() === '/node_modules/'))
208
+ return;
209
+ await appendFile(path, `${current && !current.endsWith('\n') ? '\n' : ''}/node_modules/\n`);
210
+ }
211
+ function tail(log) {
212
+ return log.split('\n').slice(-LOG_TAIL_LINES).join('\n').slice(-LOG_TAIL_MAX_CHARS);
213
+ }
214
+ async function readOptional(path) {
215
+ try {
216
+ return await readFile(path, 'utf8');
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ }
222
+ async function isDirectory(path) {
223
+ try {
224
+ return (await lstat(path)).isDirectory();
225
+ }
226
+ catch {
227
+ return false;
228
+ }
229
+ }
@@ -5,6 +5,7 @@ import { promisify } from 'node:util';
5
5
  import { isLocalWorkspaceGitAuthMode, workspaceRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { prepareWorkspaceCheckout } from './prepare.js';
7
7
  import { WorkspaceContextError } from './context-error.js';
8
+ import { scheduleDependencyInstall } from './dependencies.js';
8
9
  export { WorkspaceContextError } from './context-error.js';
9
10
  const exec = promisify(execFile);
10
11
  export function revisionScope(state, workspaceId) {
@@ -112,6 +113,8 @@ export async function registerWorkspace(state, input, leaseFence, preparation) {
112
113
  }
113
114
  }
114
115
  await state.journal.putWorkspace(binding);
116
+ // Runs in the background; every registration rechecks it, so reopening retries a failed install.
117
+ scheduleDependencyInstall(state, binding.workspaceId, root);
115
118
  return binding;
116
119
  });
117
120
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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.3",
52
- "@playcraft/common": "^0.1.3",
51
+ "@playcraft/build": "^0.1.4",
52
+ "@playcraft/common": "^0.1.4",
53
53
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
54
54
  "chokidar": "^4.0.3",
55
55
  "commander": "^13.1.0",