@playcraft/cli 0.1.73 → 0.1.75
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/recording/recorder.js +3 -0
- package/dist/video-replication/comparison-output.js +14 -4
- package/dist/video-replication/evidence-share.js +25 -0
- package/dist/workspace-runtime/server/http.js +6 -3
- package/dist/workspace-runtime/workspaces/attachments.js +3 -15
- package/dist/workspace-runtime/workspaces/directories.js +89 -20
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +89 -11
- package/dist/workspace-runtime/workspaces/registry.js +6 -0
- package/dist/workspace-runtime/workspaces/workspace-shares.js +1 -0
- package/package.json +3 -3
|
@@ -9,6 +9,7 @@ import { promisify } from 'node:util';
|
|
|
9
9
|
import { createRequire } from 'node:module';
|
|
10
10
|
import { canonicalReplicaJson, replicaRecordingMetadataSchema, } from '@playcraft/common/agent-tools';
|
|
11
11
|
import { prepareArtifactDirectory, writeJsonAtomic } from '../video-replication/evidence.js';
|
|
12
|
+
import { shareVideoEvidence } from '../video-replication/evidence-share.js';
|
|
12
13
|
import { probeVideo } from '../video-replication/media.js';
|
|
13
14
|
import { VideoCliError } from '../video-replication/messages.js';
|
|
14
15
|
import { computeProjectInputDigest } from './project-digest.js';
|
|
@@ -101,6 +102,8 @@ async function recordOne(browser, root, url, scenario, variant, signal) {
|
|
|
101
102
|
const consolePath = join(directory, 'console.jsonl');
|
|
102
103
|
await writeFile(eventPath, '', { flag: 'wx', mode: 0o600 });
|
|
103
104
|
await writeFile(consolePath, '', { flag: 'wx', mode: 0o600 });
|
|
105
|
+
await shareVideoEvidence(eventPath, 'file');
|
|
106
|
+
await shareVideoEvidence(consolePath, 'file');
|
|
104
107
|
const context = await browser.newContext({
|
|
105
108
|
viewport: { width: scenario.viewport.width, height: scenario.viewport.height }, deviceScaleFactor: scenario.viewport.deviceScaleFactor,
|
|
106
109
|
hasTouch: scenario.pointer === 'touch', isMobile: scenario.pointer === 'touch', serviceWorkers: 'block',
|
|
@@ -2,14 +2,24 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { lstat, mkdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { dirname, extname, isAbsolute, join, parse, resolve, sep } from 'node:path';
|
|
4
4
|
import { ComparisonCliError } from './comparison-messages.js';
|
|
5
|
+
import { shareVideoEvidence } from './evidence-share.js';
|
|
6
|
+
/** Creates one private directory; one this run created is shared inside a cloud workspace. */
|
|
7
|
+
async function makeOutputDirectory(path) {
|
|
8
|
+
const created = await mkdir(path, { mode: 0o700 }).then(() => true, error => {
|
|
9
|
+
if (error.code !== 'EEXIST')
|
|
10
|
+
throw error;
|
|
11
|
+
return false;
|
|
12
|
+
});
|
|
13
|
+
if (created)
|
|
14
|
+
await shareVideoEvidence(path, 'directory');
|
|
15
|
+
}
|
|
5
16
|
export async function comparisonOutputRoot(root, create = false) {
|
|
6
17
|
const absolute = resolve(root), prefix = parse(absolute).root;
|
|
7
18
|
let current = prefix;
|
|
8
19
|
for (const part of absolute.slice(prefix.length).split(sep).filter(Boolean)) {
|
|
9
20
|
current = join(current, part);
|
|
10
21
|
if (create)
|
|
11
|
-
await
|
|
12
|
-
throw error; });
|
|
22
|
+
await makeOutputDirectory(current);
|
|
13
23
|
const stat = await lstat(current);
|
|
14
24
|
if (stat.isSymbolicLink()) {
|
|
15
25
|
// macOS exposes system-managed temporary directories through these aliases.
|
|
@@ -32,8 +42,7 @@ export async function comparisonOutputPath(root, relative) {
|
|
|
32
42
|
const parts = relative.split('/');
|
|
33
43
|
for (const part of parts.slice(0, -1)) {
|
|
34
44
|
current = join(current, part);
|
|
35
|
-
await
|
|
36
|
-
throw error; });
|
|
45
|
+
await makeOutputDirectory(current);
|
|
37
46
|
const stat = await lstat(current);
|
|
38
47
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
39
48
|
throw new ComparisonCliError('unsafeOutput', 64);
|
|
@@ -51,6 +60,7 @@ export async function writeComparisonArtifact(root, relative, writer) {
|
|
|
51
60
|
const temporary = join(dirname(target), `.comparison-${randomUUID()}.tmp${extname(target)}`);
|
|
52
61
|
try {
|
|
53
62
|
await writer(temporary);
|
|
63
|
+
await shareVideoEvidence(temporary, 'file');
|
|
54
64
|
await comparisonOutputPath(root, relative);
|
|
55
65
|
await rename(temporary, target);
|
|
56
66
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { lstat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, resolve } from 'node:path';
|
|
3
|
+
import { isInsideRoot, SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, workspaceGroup, } from '../workspace-runtime/workspaces/shared-write.js';
|
|
4
|
+
/** The cloud workspace checkout above `path`: its control process writes this context file. */
|
|
5
|
+
async function cloudWorkspaceRoot(path) {
|
|
6
|
+
for (let current = resolve(path);; current = dirname(current)) {
|
|
7
|
+
const context = await lstat(join(current, '.playcraft/workspace-context.json')).catch(() => undefined);
|
|
8
|
+
if (context?.isFile())
|
|
9
|
+
return current;
|
|
10
|
+
if (dirname(current) === current)
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Evidence is created private (0700/0600) so a local run never exposes it to other users. In a
|
|
16
|
+
* cloud workspace the controller is a second identity that lists and opens these files for the
|
|
17
|
+
* user, so an entry this run created gets the development group instead. Ownership elsewhere is
|
|
18
|
+
* left to the workspace share repair.
|
|
19
|
+
*/
|
|
20
|
+
export async function shareVideoEvidence(path, kind) {
|
|
21
|
+
const root = await cloudWorkspaceRoot(dirname(path));
|
|
22
|
+
if (!root || resolve(path) === root || !isInsideRoot(root, path))
|
|
23
|
+
return;
|
|
24
|
+
await sharePathWithGroup(path, await workspaceGroup(root), kind === 'directory' ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE);
|
|
25
|
+
}
|
|
@@ -17,7 +17,7 @@ import { listLevelEditorTasks } from '../workspaces/level-editor-agent.js';
|
|
|
17
17
|
import { listLevelEditorRequests } from '../workspaces/level-editor-requests.js';
|
|
18
18
|
import { listSkillActivationRequests } from '../workspaces/skill-activation-requests.js';
|
|
19
19
|
import { readDependencies } from '../workspaces/dependencies.js';
|
|
20
|
-
import { permissionDriftFromError } from '../workspaces/workspace-shares.js';
|
|
20
|
+
import { permissionDriftFromError, withShareRepair } from '../workspaces/workspace-shares.js';
|
|
21
21
|
import { desktopHost, desktopRoute } from './desktop-host.js';
|
|
22
22
|
import { ensureCliCredentials } from '../workspaces/cli-credentials.js';
|
|
23
23
|
import { readAttachmentUpload, uploadWorkspaceAttachment, uploadWorkspaceVideoAttachment, } from '../workspaces/attachments.js';
|
|
@@ -440,7 +440,7 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
440
440
|
const body = asRecord(await readJson(request));
|
|
441
441
|
const workspaceId = maintenance[1];
|
|
442
442
|
writeJson(response, 200, maintenance[2] === 'cleanup'
|
|
443
|
-
? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames)
|
|
443
|
+
? await cleanupWorkspaceDirectories(state, workspaceId, body.directoryNames, body.archived === true ? { archived: true } : undefined)
|
|
444
444
|
: await syncWorkspaceDirectories(state, workspaceId, body.commitHash, body.targetDirectoryNames));
|
|
445
445
|
return;
|
|
446
446
|
}
|
|
@@ -898,8 +898,11 @@ async function dispatchRuntimeRequest(state, request, response) {
|
|
|
898
898
|
if (writeDestroyed(state, response))
|
|
899
899
|
return;
|
|
900
900
|
const relativePath = url.searchParams.get('path') ?? '';
|
|
901
|
+
const fileWorkspaceId = url.searchParams.get('workspaceId') ?? undefined;
|
|
901
902
|
try {
|
|
902
|
-
|
|
903
|
+
// A file that lost development-group access is repaired once before the read is reported.
|
|
904
|
+
const root = await workspaceRoot(state, fileWorkspaceId, directoryName);
|
|
905
|
+
writeJson(response, 200, await withShareRepair(state, root, () => readObservedFile(state, fileWorkspaceId, relativePath, directoryName)));
|
|
903
906
|
}
|
|
904
907
|
catch (error) {
|
|
905
908
|
if (error instanceof InvalidWorkspacePathError) {
|
|
@@ -8,21 +8,9 @@ import { excludeFromGit } from './dependencies.js';
|
|
|
8
8
|
import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
|
|
9
9
|
import { resolveLibraryAttachment } from './library-attachments.js';
|
|
10
10
|
import { resolveWorkspaceDirectoryAttachment } from './directory-attachments.js';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
for (const part of WORKSPACE_USER_UPLOADS_DIRECTORY.split('/')) {
|
|
15
|
-
directory = join(directory, part);
|
|
16
|
-
// shared
|
|
17
|
-
await mkdir(directory, { mode: 0o755 }).catch((error) => {
|
|
18
|
-
if (error.code !== 'EEXIST')
|
|
19
|
-
throw error;
|
|
20
|
-
});
|
|
21
|
-
if (!(await lstat(directory)).isDirectory())
|
|
22
|
-
throw new Error('invalid_attachment');
|
|
23
|
-
await chmod(directory, 0o755);
|
|
24
|
-
}
|
|
25
|
-
return directory;
|
|
11
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
12
|
+
function attachmentRoot(root) {
|
|
13
|
+
return ensureSharedDirectory(root, join(root, WORKSPACE_USER_UPLOADS_DIRECTORY));
|
|
26
14
|
}
|
|
27
15
|
export function imageMime(bytes) {
|
|
28
16
|
if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])))
|
|
@@ -3,13 +3,15 @@ import { randomUUID } from 'node:crypto';
|
|
|
3
3
|
import { lstat, rename, rm } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
|
-
import { workspaceDirectoryRegistrationSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { workspaceDirectoryRegistrationSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { workspaceContainerRoot, workspaceRoot, WorkspaceContextError } from './registry.js';
|
|
8
8
|
import { openDirectoryToDevelopment } from './directory-access.js';
|
|
9
9
|
import { prepareWorkspaceCheckout } from './prepare.js';
|
|
10
10
|
import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
|
|
11
11
|
import { revisionScope } from './registry.js';
|
|
12
|
-
import { ensureWorkspaceSkills } from './skills.js';
|
|
12
|
+
import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
13
|
+
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess, } from './skill-artifact-loader.js';
|
|
14
|
+
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
13
15
|
import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
|
|
14
16
|
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
15
17
|
import { shareWorkspaceRepository } from './git.js';
|
|
@@ -28,19 +30,42 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
|
|
|
28
30
|
const binding = state.journal.getWorkspace(directory.workspaceId);
|
|
29
31
|
if (!binding)
|
|
30
32
|
throw new WorkspaceContextError('workspace_not_registered');
|
|
31
|
-
const existing = binding.directories?.find(item => item.directoryName === directory.directoryName);
|
|
32
|
-
if (existing &&
|
|
33
|
+
const existing = binding.directories?.find((item) => item.directoryName === directory.directoryName);
|
|
34
|
+
if (existing &&
|
|
35
|
+
(existing.remixId !== directory.remixId ||
|
|
36
|
+
existing.branchName !== directory.branchName ||
|
|
37
|
+
(existing.sourceCommit !== undefined && existing.sourceCommit !== directory.sourceCommit)))
|
|
33
38
|
throw new WorkspaceContextError('workspace_directory_binding_conflict');
|
|
34
39
|
const parent = await workspaceContainerRoot(state, directory.workspaceId);
|
|
35
40
|
const root = join(parent, directory.directoryName);
|
|
36
|
-
const git = async (cwd, ...args) => (await execute('git', [
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const git = async (cwd, ...args) => (await execute('git', [
|
|
42
|
+
'-c',
|
|
43
|
+
'core.hooksPath=/dev/null',
|
|
44
|
+
'-c',
|
|
45
|
+
'credential.helper=',
|
|
46
|
+
'-c',
|
|
47
|
+
'core.fsmonitor=false',
|
|
48
|
+
'-C',
|
|
49
|
+
cwd,
|
|
50
|
+
...args,
|
|
51
|
+
], {
|
|
52
|
+
timeout: 90_000,
|
|
53
|
+
maxBuffer: 1024 * 1024,
|
|
54
|
+
env: {
|
|
55
|
+
PATH: process.env.PATH,
|
|
56
|
+
HOME: state.config.stateDir,
|
|
57
|
+
GIT_CONFIG_GLOBAL: '/dev/null',
|
|
58
|
+
GIT_CONFIG_NOSYSTEM: '1',
|
|
59
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
60
|
+
},
|
|
39
61
|
})).stdout.trim();
|
|
40
62
|
const stat = await lstat(root).catch(() => undefined);
|
|
41
63
|
if (stat) {
|
|
42
|
-
if (!stat.isDirectory() ||
|
|
43
|
-
|
|
64
|
+
if (!stat.isDirectory() ||
|
|
65
|
+
stat.isSymbolicLink() ||
|
|
66
|
+
(await git(root, 'symbolic-ref', '--short', 'HEAD')) !== directory.branchName ||
|
|
67
|
+
(binding.repositoryUrl !== null &&
|
|
68
|
+
(await git(root, 'config', '--get', 'remote.origin.url')) !== binding.repositoryUrl))
|
|
44
69
|
throw new WorkspaceContextError('workspace_checkout_mismatch');
|
|
45
70
|
}
|
|
46
71
|
else {
|
|
@@ -55,12 +80,29 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
|
|
|
55
80
|
if (worktree)
|
|
56
81
|
await checkoutChatWorktree(source, staging, directory.sourceCommit, state.config.stateDir);
|
|
57
82
|
else if (sourceDirectoryName !== undefined) {
|
|
58
|
-
const frozen = await freezeBatchCommit({
|
|
59
|
-
|
|
60
|
-
|
|
83
|
+
const frozen = await freezeBatchCommit({
|
|
84
|
+
source: { workspaceId: directory.workspaceId, root: source },
|
|
85
|
+
commitHash: directory.sourceCommit,
|
|
86
|
+
snapshotId: `directory-${directory.remixId}`,
|
|
87
|
+
});
|
|
88
|
+
await checkoutBatchCommit({
|
|
89
|
+
source: { workspaceId: directory.workspaceId, root: source },
|
|
90
|
+
frozen,
|
|
91
|
+
destination: staging,
|
|
92
|
+
});
|
|
61
93
|
}
|
|
62
94
|
else
|
|
63
|
-
await execute('git', [
|
|
95
|
+
await execute('git', [
|
|
96
|
+
'-c',
|
|
97
|
+
'core.hooksPath=/dev/null',
|
|
98
|
+
'clone',
|
|
99
|
+
'--local',
|
|
100
|
+
'--no-hardlinks',
|
|
101
|
+
'--no-checkout',
|
|
102
|
+
'--',
|
|
103
|
+
source,
|
|
104
|
+
staging,
|
|
105
|
+
], { timeout: 90_000, maxBuffer: 1024 * 1024 });
|
|
64
106
|
await git(staging, 'checkout', '-b', directory.branchName, directory.sourceCommit);
|
|
65
107
|
if (binding.repositoryUrl)
|
|
66
108
|
await git(staging, 'remote', 'set-url', 'origin', binding.repositoryUrl);
|
|
@@ -85,7 +127,13 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
|
|
|
85
127
|
state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
|
|
86
128
|
await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
|
|
87
129
|
const latest = state.journal.getWorkspace(directory.workspaceId) ?? binding;
|
|
88
|
-
await state.journal.putWorkspace({
|
|
130
|
+
await state.journal.putWorkspace({
|
|
131
|
+
...latest,
|
|
132
|
+
directories: [
|
|
133
|
+
...(latest.directories ?? []).filter((item) => item.directoryName !== directory.directoryName),
|
|
134
|
+
{ ...directory, status: 'ready' },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
89
137
|
});
|
|
90
138
|
if (state.gitCredentials && binding.repositoryUrl) {
|
|
91
139
|
await state.gitCredentials.configure(state.journal.getWorkspace(directory.workspaceId) ?? binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
|
|
@@ -99,7 +147,7 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
99
147
|
return;
|
|
100
148
|
await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
101
149
|
const binding = state.journal.getWorkspace(workspaceId);
|
|
102
|
-
const directory = binding?.directories?.find(item => item.directoryName === directoryName);
|
|
150
|
+
const directory = binding?.directories?.find((item) => item.directoryName === directoryName);
|
|
103
151
|
if (!binding)
|
|
104
152
|
return;
|
|
105
153
|
if (directory?.status !== 'reclaimed') {
|
|
@@ -113,15 +161,33 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
113
161
|
throw new WorkspaceContextError('workspace_recovery_required');
|
|
114
162
|
const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
|
|
115
163
|
const { pendingFork: _pendingFork, directories: _directories, ...base } = binding;
|
|
116
|
-
const target = directoryName === 'basic'
|
|
164
|
+
const target = directoryName === 'basic'
|
|
165
|
+
? base
|
|
166
|
+
: {
|
|
167
|
+
...base,
|
|
168
|
+
targetKind: 'remix',
|
|
169
|
+
remixId: directory.remixId,
|
|
170
|
+
branchName: directory.branchName,
|
|
171
|
+
};
|
|
117
172
|
const root = join(state.config.workspaceRoot, workspaceId, directoryName);
|
|
118
|
-
if (!await lstat(root).catch(() => undefined))
|
|
173
|
+
if (!(await lstat(root).catch(() => undefined)))
|
|
119
174
|
await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
|
|
120
175
|
await shareWorkspaceRepository(root);
|
|
121
|
-
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
|
|
176
|
+
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, workspaceId), { optionalPlatformDefaults: true });
|
|
177
|
+
if (directoryName === 'basic')
|
|
178
|
+
await writeWorkspaceCliContext(root, workspaceId);
|
|
122
179
|
await ensureWorkspaceArtifactInstructions(root);
|
|
123
180
|
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
124
|
-
await state.journal.putWorkspace({
|
|
181
|
+
await state.journal.putWorkspace({
|
|
182
|
+
...binding,
|
|
183
|
+
directories: binding.directories?.map((item) => item.directoryName === directoryName ? { ...item, status: 'ready' } : item),
|
|
184
|
+
});
|
|
185
|
+
try {
|
|
186
|
+
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), (entry) => loadWorkspaceSkillArtifact(state, workspaceId, entry));
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
/* Skills reads and native turns diagnose missing artifacts; files and source repair remain available. */
|
|
190
|
+
}
|
|
125
191
|
try {
|
|
126
192
|
await state.gitCredentials.configure(binding, preparation, directoryName);
|
|
127
193
|
const scope = revisionScope(state, workspaceId, directoryName);
|
|
@@ -131,7 +197,10 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
131
197
|
}
|
|
132
198
|
catch (error) {
|
|
133
199
|
const current = state.journal.getWorkspace(workspaceId);
|
|
134
|
-
await state.journal.putWorkspace({
|
|
200
|
+
await state.journal.putWorkspace({
|
|
201
|
+
...current,
|
|
202
|
+
directories: current.directories?.map((item) => item.directoryName === directoryName ? { ...item, status: 'reclaimed' } : item),
|
|
203
|
+
});
|
|
135
204
|
throw error;
|
|
136
205
|
}
|
|
137
206
|
});
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, relative } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { workspaceDirectoryNameSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
7
7
|
import { batchDirectoryHeld } from '../batch/occupancy.js';
|
|
8
|
-
import {
|
|
8
|
+
import { WorkspaceContextError } from './context-error.js';
|
|
9
|
+
import { residentWorkspaceDirectory, workspaceRoot, workspaceSessions } from './registry.js';
|
|
9
10
|
import { withDirectoryWrite } from '../server/directory-access.js';
|
|
11
|
+
import { runDeveloperCommand } from './developer-command.js';
|
|
10
12
|
const execute = promisify(execFile);
|
|
11
13
|
const git = async (state, root, ...args) => (await execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root, ...args], {
|
|
12
14
|
timeout: 30_000, maxBuffer: 1024 * 1024, env: { PATH: process.env.PATH, HOME: state.config.stateDir, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' },
|
|
@@ -30,10 +32,74 @@ async function assertCleanAndPushed(state, root, allowedHead) {
|
|
|
30
32
|
if (!upstream || (Number(await git(state, root, 'rev-list', '--count', `${upstream}..HEAD`)) > 0 && await git(state, root, 'rev-parse', 'HEAD') !== allowedHead))
|
|
31
33
|
throw new Error('workspace_directory_unpushed');
|
|
32
34
|
}
|
|
35
|
+
async function cleanupRoot(state, workspaceId, directoryName, archived) {
|
|
36
|
+
if (state.journal.getWorkspace(workspaceId)) {
|
|
37
|
+
try {
|
|
38
|
+
return await workspaceRoot(state, workspaceId, directoryName);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (!archived || !(error instanceof WorkspaceContextError) || error.message !== 'workspace_directory_not_registered')
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else if (!archived)
|
|
46
|
+
throw new Error('workspace_not_registered');
|
|
47
|
+
// An archived checkout can outlive the journal that registered it.
|
|
48
|
+
return residentWorkspaceDirectory(state, workspaceId, directoryName);
|
|
49
|
+
}
|
|
50
|
+
/** True only when the archived checkout path is absent. A symlink or escaped path stays blocked. */
|
|
51
|
+
async function archivedCheckoutMissing(state, workspaceId, directoryName) {
|
|
52
|
+
if (!/^[0-9a-f-]{36}$/i.test(workspaceId) || !/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
|
|
53
|
+
return false;
|
|
54
|
+
const parent = await realpath(state.config.workspaceRoot);
|
|
55
|
+
const container = join(parent, workspaceId);
|
|
56
|
+
let containerStat;
|
|
57
|
+
try {
|
|
58
|
+
containerStat = await lstat(container);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return error.code === 'ENOENT';
|
|
62
|
+
}
|
|
63
|
+
if (!containerStat.isDirectory() || containerStat.isSymbolicLink() || relative(parent, await realpath(container)) !== workspaceId)
|
|
64
|
+
return false;
|
|
65
|
+
try {
|
|
66
|
+
await lstat(join(container, directoryName));
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
return error.code === 'ENOENT';
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function shellQuote(value) {
|
|
74
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
75
|
+
}
|
|
76
|
+
/** The controller cannot unlink files the development user created with mode 0700. That user removes them. */
|
|
77
|
+
async function removeCheckout(state, root) {
|
|
78
|
+
try {
|
|
79
|
+
await rm(root, { recursive: true, force: true });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const code = error.code;
|
|
84
|
+
if (code !== 'EACCES' && code !== 'EPERM')
|
|
85
|
+
throw error;
|
|
86
|
+
const outcome = await runDeveloperCommand(state, {
|
|
87
|
+
processId: `checkout-remove-${randomUUID()}`,
|
|
88
|
+
cwd: dirname(root),
|
|
89
|
+
command: `rm -rf -- ${shellQuote(root)}`,
|
|
90
|
+
timeoutMs: 60_000,
|
|
91
|
+
onOutput: () => undefined,
|
|
92
|
+
});
|
|
93
|
+
if (outcome.code !== 0 || outcome.timedOut)
|
|
94
|
+
throw new Error('workspace_permission_denied');
|
|
95
|
+
}
|
|
96
|
+
await rm(root, { recursive: true, force: true });
|
|
97
|
+
}
|
|
33
98
|
/** Keep remote branches, published previews and all journal identities; only local checkouts are reclaimed. */
|
|
34
|
-
export async function cleanupWorkspaceDirectories(state, workspaceId, directoryNames) {
|
|
99
|
+
export async function cleanupWorkspaceDirectories(state, workspaceId, directoryNames, options) {
|
|
35
100
|
if (!Array.isArray(directoryNames) || directoryNames.length === 0 || directoryNames.length > 64)
|
|
36
101
|
throw new Error('workspace_directories_invalid');
|
|
102
|
+
const archived = options?.archived === true;
|
|
37
103
|
const names = [...new Set(directoryNames.map(name => workspaceDirectoryNameSchema.parse(name)))];
|
|
38
104
|
return state.journal.exclusive(`maintenance:${workspaceId}`, async () => {
|
|
39
105
|
const cleaned = [];
|
|
@@ -43,14 +109,26 @@ export async function cleanupWorkspaceDirectories(state, workspaceId, directoryN
|
|
|
43
109
|
await withDirectoryWrite(state, { workspaceId, directoryName, mode: 'exclusive', wait: false }, () => state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
|
|
44
110
|
if (await workspaceDirectoryOccupied(state, workspaceId, directoryName))
|
|
45
111
|
throw new Error('workspace_directory_occupied');
|
|
46
|
-
const root = await
|
|
47
|
-
|
|
112
|
+
const root = await cleanupRoot(state, workspaceId, directoryName, archived).catch(async (error) => {
|
|
113
|
+
if (!archived || !(error instanceof WorkspaceContextError) || error.message !== 'workspace_not_prepared' || !await archivedCheckoutMissing(state, workspaceId, directoryName))
|
|
114
|
+
throw error;
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
// Archived projects are reclaimed to free the sandbox. Active projects keep uncommitted work.
|
|
118
|
+
if (!archived) {
|
|
119
|
+
if (root === null)
|
|
120
|
+
throw new Error('workspace_not_prepared');
|
|
121
|
+
await assertCleanAndPushed(state, root);
|
|
122
|
+
}
|
|
48
123
|
const binding = state.journal.getWorkspace(workspaceId);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
124
|
+
if (binding) {
|
|
125
|
+
// Persist recoverable identity before removing the checkout; a retry cannot silently adopt another branch.
|
|
126
|
+
const existing = binding.directories?.find(item => item.directoryName === directoryName);
|
|
127
|
+
const directory = existing ?? { workspaceId, directoryName, remixId: binding.remixId, branchName: binding.branchName };
|
|
128
|
+
await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directoryName), { ...directory, status: 'reclaimed' }] });
|
|
129
|
+
}
|
|
130
|
+
if (root)
|
|
131
|
+
await removeCheckout(state, root);
|
|
54
132
|
cleaned.push(directoryName);
|
|
55
133
|
}));
|
|
56
134
|
}
|
|
@@ -23,6 +23,12 @@ export function workspaceSessions(state, workspaceId, directoryName = 'basic') {
|
|
|
23
23
|
.listSessions()
|
|
24
24
|
.filter((item) => state.config.workspaceMode !== 'multi' || (item.workspaceId === workspaceId && (item.directoryName ?? 'basic') === directoryName));
|
|
25
25
|
}
|
|
26
|
+
/** A checkout that outlived the journal. Cleanup of an archived workspace is the only caller. */
|
|
27
|
+
export async function residentWorkspaceDirectory(state, workspaceId, directoryName) {
|
|
28
|
+
if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
|
|
29
|
+
throw new WorkspaceContextError('invalid_workspace_directory');
|
|
30
|
+
return checkedDirectory(state, workspaceId, directoryName);
|
|
31
|
+
}
|
|
26
32
|
export async function workspaceRoot(state, workspaceId, directoryName = 'basic') {
|
|
27
33
|
if (state.config.workspaceMode !== 'multi')
|
|
28
34
|
return state.config.workspaceRoot;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.75",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"@gltf-transform/core": "^4.3.0",
|
|
58
58
|
"@gltf-transform/extensions": "^4.3.0",
|
|
59
59
|
"@gltf-transform/functions": "^4.3.0",
|
|
60
|
-
"@playcraft/build": "0.1.
|
|
61
|
-
"@playcraft/common": "0.1.
|
|
60
|
+
"@playcraft/build": "0.1.75",
|
|
61
|
+
"@playcraft/common": "0.1.75",
|
|
62
62
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
63
63
|
"chokidar": "^4.0.3",
|
|
64
64
|
"commander": "^13.1.0",
|