@playcraft/cli 0.1.2 → 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.
- package/dist/workspace-runtime/codex/launcher-client.js +6 -3
- package/dist/workspace-runtime/persistence/journal.js +14 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +1 -0
- package/dist/workspace-runtime/serve.js +2 -0
- package/dist/workspace-runtime/server/dispatch.js +32 -2
- package/dist/workspace-runtime/server/http.js +20 -2
- package/dist/workspace-runtime/workspaces/dependencies.js +229 -0
- package/dist/workspace-runtime/workspaces/git.js +122 -15
- package/dist/workspace-runtime/workspaces/registry.js +3 -0
- package/package.json +3 -3
|
@@ -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 },
|
|
@@ -2,12 +2,12 @@ import { withObservedFile } from '../workspaces/revisions.js';
|
|
|
2
2
|
import { revisionScope, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
-
import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { checkpointRecordSchema, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
7
7
|
import { disposeTerminalHost } from './terminal-host.js';
|
|
8
8
|
import { captureWorkspaceTree, materializeSnapshot, publishFileSnapshot, } from '../workspaces/file-snapshot.js';
|
|
9
9
|
import { InvalidWorkspacePathError, resolveWorkspaceFile, restoreWorkspaceFiles, snapshotWorkspaceFiles, writeWorkspaceFile, } from '../workspaces/files.js';
|
|
10
|
-
import { GitWorkspaceError, commitWorkspaceGit, pushWorkspaceGit } from '../workspaces/git.js';
|
|
10
|
+
import { GitWorkspaceError, commitWorkspaceGit, discardWorkspaceGit, pushWorkspaceGit, stageWorkspaceGit, unstageWorkspaceGit, } from '../workspaces/git.js';
|
|
11
11
|
import { ParameterEditError } from '../workspaces/json5-edit.js';
|
|
12
12
|
import { patchWorkspaceParameters, readWorkspaceParameters } from '../workspaces/parameters.js';
|
|
13
13
|
import { executionGroupRestarted } from './execution-group.js';
|
|
@@ -26,6 +26,9 @@ const LOCAL_KINDS = new Set([
|
|
|
26
26
|
'file.write',
|
|
27
27
|
'git.commit',
|
|
28
28
|
'git.push',
|
|
29
|
+
'git.stage',
|
|
30
|
+
'git.unstage',
|
|
31
|
+
'git.discard',
|
|
29
32
|
'terminal.create',
|
|
30
33
|
'parameter.patch',
|
|
31
34
|
]);
|
|
@@ -88,6 +91,9 @@ async function executeScopedOperation(state, scopeKey, operation, payload) {
|
|
|
88
91
|
if (operation.kind === 'git.push') {
|
|
89
92
|
return executeGitPush(state, scopeKey, current, payload);
|
|
90
93
|
}
|
|
94
|
+
if (operation.kind === 'git.stage' || operation.kind === 'git.unstage' || operation.kind === 'git.discard') {
|
|
95
|
+
return executeGitIndex(state, scopeKey, current, payload);
|
|
96
|
+
}
|
|
91
97
|
if (operation.kind === 'terminal.create') {
|
|
92
98
|
return executeTerminalCreate(state, scopeKey, current, payload);
|
|
93
99
|
}
|
|
@@ -620,6 +626,30 @@ async function executeGitPush(state, scopeKey, operation, payload) {
|
|
|
620
626
|
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
621
627
|
}
|
|
622
628
|
}
|
|
629
|
+
const GIT_INDEX_CHANGES = {
|
|
630
|
+
'git.stage': { run: stageWorkspaceGit, event: 'git.staged' },
|
|
631
|
+
'git.unstage': { run: unstageWorkspaceGit, event: 'git.unstaged' },
|
|
632
|
+
'git.discard': { run: discardWorkspaceGit, event: 'git.discarded' },
|
|
633
|
+
};
|
|
634
|
+
/** Stage, unstage and discard share a payload; each records its own event so the browser refreshes. */
|
|
635
|
+
async function executeGitIndex(state, scopeKey, operation, payload) {
|
|
636
|
+
const change = GIT_INDEX_CHANGES[operation.kind];
|
|
637
|
+
const parsed = gitPathsPayloadSchema.safeParse(payload);
|
|
638
|
+
if (!parsed.success) {
|
|
639
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: 'invalid_git_paths' }));
|
|
640
|
+
}
|
|
641
|
+
try {
|
|
642
|
+
await change.run(await workspaceRoot(state, operation.workspaceId), parsed.data);
|
|
643
|
+
const accepted = await persist(state, scopeKey, transitionOperation(operation, 'accepted'));
|
|
644
|
+
await recordNativeEvents(state, accepted, [
|
|
645
|
+
{ type: change.event, payload: { paths: parsed.data.paths }, occurredAt: new Date().toISOString() },
|
|
646
|
+
]);
|
|
647
|
+
return persist(state, scopeKey, transitionOperation(accepted, 'completed'));
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
return persist(state, scopeKey, transitionOperation(operation, 'failed', { error: gitErrorCode(error) }));
|
|
651
|
+
}
|
|
652
|
+
}
|
|
623
653
|
async function executeParameterPatch(state, scopeKey, operation, payload) {
|
|
624
654
|
const parsed = parameterPatchPayloadSchema.safeParse(payload);
|
|
625
655
|
if (!parsed.success) {
|
|
@@ -1,13 +1,14 @@
|
|
|
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, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, instanceRebuildRequestSchema, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, resolveIdempotentAccept, nativeHistoryReadSchema, 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
|
-
import { GitWorkspaceError, readWorkspaceGitDiff, readWorkspaceGitGraph, readWorkspaceGitStatus, } from '../workspaces/git.js';
|
|
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) {
|
|
@@ -131,6 +132,23 @@ export async function handleRuntimeRequest(state, request, response) {
|
|
|
131
132
|
await writeGitRead(state, response, async () => readWorkspaceGitDiff(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
132
133
|
return;
|
|
133
134
|
}
|
|
135
|
+
if (request.method === 'GET' && url.pathname === '/runtime/git/file') {
|
|
136
|
+
const ref = gitFileRefSchema.safeParse(url.searchParams.get('ref'));
|
|
137
|
+
if (!ref.success) {
|
|
138
|
+
writeJson(response, 400, { error: 'invalid_git_ref' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await writeGitRead(state, response, async () => readWorkspaceGitFile(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined), url.searchParams.get('path') ?? '', ref.data));
|
|
142
|
+
return;
|
|
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
|
+
}
|
|
134
152
|
if (request.method === 'GET' && url.pathname === '/runtime/git/graph') {
|
|
135
153
|
await writeGitRead(state, response, async () => readWorkspaceGitGraph(await workspaceRoot(state, url.searchParams.get('workspaceId') ?? undefined)));
|
|
136
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
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join, relative, resolve, sep } from 'node:path';
|
|
5
|
-
import { gitDiffSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
4
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
const GIT_DIFF_MAX_BYTES = 1024 * 1024;
|
|
7
7
|
const GIT_PREFIX = [
|
|
8
8
|
'-c',
|
|
@@ -42,15 +42,96 @@ export async function readWorkspaceGitStatus(root) {
|
|
|
42
42
|
ahead = Number.parseInt(right ?? '0', 10) || 0;
|
|
43
43
|
}
|
|
44
44
|
const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
|
|
45
|
+
// Line counts are a convenience; a repository without commits still lists its files.
|
|
46
|
+
const [staged, unstaged] = await Promise.all([
|
|
47
|
+
gitAllowFail(rootReal, ['diff', '--cached', '--numstat', '-z']),
|
|
48
|
+
gitAllowFail(rootReal, ['diff', '--numstat', '-z']),
|
|
49
|
+
]);
|
|
50
|
+
const stagedCounts = staged.code === 0 ? parseNumstat(staged.stdout) : new Map();
|
|
51
|
+
const unstagedCounts = unstaged.code === 0 ? parseNumstat(unstaged.stdout) : new Map();
|
|
45
52
|
return gitStatusSchema.parse({
|
|
46
53
|
initialized: true,
|
|
47
54
|
branch,
|
|
48
55
|
ahead,
|
|
49
56
|
behind,
|
|
50
57
|
hasUpstream: upstream.code === 0,
|
|
51
|
-
files: parsePorcelain(porcelain)
|
|
58
|
+
files: parsePorcelain(porcelain).map((entry) => ({
|
|
59
|
+
...entry,
|
|
60
|
+
...(stagedCounts.has(entry.path) ? { staged: stagedCounts.get(entry.path) } : {}),
|
|
61
|
+
...(unstagedCounts.has(entry.path) ? { unstaged: unstagedCounts.get(entry.path) } : {}),
|
|
62
|
+
})),
|
|
52
63
|
});
|
|
53
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* One file as stored in the last commit or in the index. Reads the blob as is, without filters,
|
|
67
|
+
* so the browser compares exactly what Git holds.
|
|
68
|
+
*/
|
|
69
|
+
export async function readWorkspaceGitFile(root, path, ref) {
|
|
70
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
71
|
+
assertWorkspacePaths([path]);
|
|
72
|
+
const object = ref === 'HEAD' ? `HEAD:${path}` : `:${path}`;
|
|
73
|
+
const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
|
|
74
|
+
const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
|
|
75
|
+
if (size.code !== 0)
|
|
76
|
+
return absent;
|
|
77
|
+
if (Number.parseInt(size.stdout.trim(), 10) > WORKSPACE_FILE_CONTENT_MAX) {
|
|
78
|
+
throw new GitWorkspaceError('git_file_too_large', path);
|
|
79
|
+
}
|
|
80
|
+
const blob = await gitAllowFail(rootReal, ['cat-file', 'blob', object]);
|
|
81
|
+
if (blob.code !== 0)
|
|
82
|
+
return absent;
|
|
83
|
+
// Git treats a NUL byte as the mark of binary content; so does the diff view.
|
|
84
|
+
const binary = blob.stdout.includes('\0');
|
|
85
|
+
return gitFileContentSchema.parse({ path, ref, exists: true, binary, content: binary ? '' : blob.stdout });
|
|
86
|
+
}
|
|
87
|
+
/** Adds the paths to the index, including deletions. */
|
|
88
|
+
export async function stageWorkspaceGit(root, payload) {
|
|
89
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
90
|
+
assertWorkspacePaths(payload.paths);
|
|
91
|
+
await git(rootReal, ['--literal-pathspecs', 'add', '-A', '--', ...payload.paths]);
|
|
92
|
+
}
|
|
93
|
+
/** Returns the paths' index entries to the last commit, leaving the work tree alone. */
|
|
94
|
+
export async function unstageWorkspaceGit(root, payload) {
|
|
95
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
96
|
+
assertWorkspacePaths(payload.paths);
|
|
97
|
+
const head = await gitAllowFail(rootReal, ['rev-parse', '--verify', '--quiet', 'HEAD']);
|
|
98
|
+
if (head.code === 0) {
|
|
99
|
+
await git(rootReal, ['--literal-pathspecs', 'restore', '--staged', '--', ...payload.paths]);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
// Before the first commit there is nothing to restore from; the entries are simply removed.
|
|
103
|
+
await git(rootReal, ['--literal-pathspecs', 'rm', '--cached', '-r', '-q', '--ignore-unmatch', '--', ...payload.paths]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Throws away work-tree changes: tracked files return to their staged content, untracked files are
|
|
108
|
+
* deleted. Staged content is never touched, so a staged change survives a discard.
|
|
109
|
+
*/
|
|
110
|
+
export async function discardWorkspaceGit(root, payload) {
|
|
111
|
+
const rootReal = await ensureWorkspaceGit(root);
|
|
112
|
+
assertWorkspacePaths(payload.paths);
|
|
113
|
+
const others = await git(rootReal, ['--literal-pathspecs', 'ls-files', '--others', '--exclude-standard', '-z', '--', ...payload.paths]);
|
|
114
|
+
const untracked = new Set(others.split('\0').filter((path) => path.length > 0));
|
|
115
|
+
const tracked = payload.paths.filter((path) => !untracked.has(path));
|
|
116
|
+
if (tracked.length > 0) {
|
|
117
|
+
await git(rootReal, ['--literal-pathspecs', 'restore', '--worktree', '--', ...tracked]);
|
|
118
|
+
}
|
|
119
|
+
for (const path of untracked) {
|
|
120
|
+
const target = resolve(rootReal, path);
|
|
121
|
+
// The parent must resolve inside the workspace, so a symlinked directory cannot redirect the delete.
|
|
122
|
+
assertInsideRoot(rootReal, join(await realpath(dirname(target)), basename(target)));
|
|
123
|
+
if ((await lstat(target)).isDirectory())
|
|
124
|
+
throw new GitWorkspaceError('invalid_workspace_path', path);
|
|
125
|
+
await unlink(target);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function assertWorkspacePaths(paths) {
|
|
129
|
+
for (const relativePath of paths) {
|
|
130
|
+
if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
|
|
131
|
+
throw new GitWorkspaceError('invalid_workspace_path', relativePath);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
54
135
|
export async function readWorkspaceGitDiff(root) {
|
|
55
136
|
const rootReal = await ensureWorkspaceGit(root);
|
|
56
137
|
const vsHead = await gitAllowFail(rootReal, ['diff', '--no-color', 'HEAD']);
|
|
@@ -92,12 +173,11 @@ function splitLogLine(line) {
|
|
|
92
173
|
}
|
|
93
174
|
export async function commitWorkspaceGit(root, payload) {
|
|
94
175
|
const rootReal = await ensureWorkspaceGit(root);
|
|
95
|
-
if (payload.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
176
|
+
if (payload.stagedOnly) {
|
|
177
|
+
// The index is the commit; nothing is added.
|
|
178
|
+
}
|
|
179
|
+
else if (payload.paths && payload.paths.length > 0) {
|
|
180
|
+
assertWorkspacePaths(payload.paths);
|
|
101
181
|
await git(rootReal, ['add', '--', ...payload.paths]);
|
|
102
182
|
}
|
|
103
183
|
else {
|
|
@@ -202,6 +282,28 @@ function parsePorcelain(raw) {
|
|
|
202
282
|
}
|
|
203
283
|
return files;
|
|
204
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* `diff --numstat -z`: `added<TAB>removed<TAB>path<NUL>`, or for a rename an empty path followed by
|
|
287
|
+
* `old<NUL>new<NUL>`. Binary files report `-` and are left out.
|
|
288
|
+
*/
|
|
289
|
+
function parseNumstat(raw) {
|
|
290
|
+
const counts = new Map();
|
|
291
|
+
const parts = raw.split('\0');
|
|
292
|
+
for (let index = 0; index < parts.length; index += 1) {
|
|
293
|
+
const match = /^(\d+|-)\t(\d+|-)\t(.*)$/s.exec(parts[index] ?? '');
|
|
294
|
+
if (!match)
|
|
295
|
+
continue;
|
|
296
|
+
let path = match[3];
|
|
297
|
+
if (path === '') {
|
|
298
|
+
path = parts[index + 2] ?? '';
|
|
299
|
+
index += 2;
|
|
300
|
+
}
|
|
301
|
+
if (match[1] === '-' || !path)
|
|
302
|
+
continue;
|
|
303
|
+
counts.set(path, { added: Number(match[1]), removed: Number(match[2]) });
|
|
304
|
+
}
|
|
305
|
+
return counts;
|
|
306
|
+
}
|
|
205
307
|
async function git(root, args, timeoutMs = 15000) {
|
|
206
308
|
const result = await gitAllowFail(root, args, timeoutMs);
|
|
207
309
|
if (result.code !== 0) {
|
|
@@ -227,17 +329,18 @@ function gitEnv() {
|
|
|
227
329
|
function runGitProcess(cwd, args, timeoutMs) {
|
|
228
330
|
return new Promise((resolve, reject) => {
|
|
229
331
|
const child = spawn('git', args, { cwd, env: gitEnv(), windowsHide: true });
|
|
230
|
-
|
|
231
|
-
|
|
332
|
+
// Chunks are decoded together; a character split across two chunks would otherwise be corrupted.
|
|
333
|
+
const stdout = [];
|
|
334
|
+
const stderr = [];
|
|
232
335
|
const timer = setTimeout(() => {
|
|
233
336
|
child.kill('SIGKILL');
|
|
234
337
|
reject(new GitWorkspaceError('git_command_failed', 'timeout'));
|
|
235
338
|
}, timeoutMs);
|
|
236
339
|
child.stdout.on('data', (chunk) => {
|
|
237
|
-
stdout
|
|
340
|
+
stdout.push(Buffer.from(chunk));
|
|
238
341
|
});
|
|
239
342
|
child.stderr.on('data', (chunk) => {
|
|
240
|
-
stderr
|
|
343
|
+
stderr.push(Buffer.from(chunk));
|
|
241
344
|
});
|
|
242
345
|
child.on('error', (error) => {
|
|
243
346
|
clearTimeout(timer);
|
|
@@ -249,7 +352,11 @@ function runGitProcess(cwd, args, timeoutMs) {
|
|
|
249
352
|
});
|
|
250
353
|
child.on('close', (code) => {
|
|
251
354
|
clearTimeout(timer);
|
|
252
|
-
resolve({
|
|
355
|
+
resolve({
|
|
356
|
+
stdout: Buffer.concat(stdout).toString('utf8'),
|
|
357
|
+
stderr: Buffer.concat(stderr).toString('utf8'),
|
|
358
|
+
code: code ?? 1,
|
|
359
|
+
});
|
|
253
360
|
});
|
|
254
361
|
});
|
|
255
362
|
}
|
|
@@ -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
|
+
"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.
|
|
52
|
-
"@playcraft/common": "^0.1.
|
|
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",
|