@playcraft/cli 0.1.74 → 0.1.77
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/batch/deliver.js +1 -0
- package/dist/workspace-runtime/verify-artifact.js +7 -4
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +14 -2
- package/dist/workspace-runtime/workspaces/attachments.js +12 -33
- package/dist/workspace-runtime/workspaces/dependencies.js +35 -10
- package/dist/workspace-runtime/workspaces/directories.js +6 -1
- package/dist/workspace-runtime/workspaces/directory-attachments.js +7 -12
- package/dist/workspace-runtime/workspaces/library-attachments.js +10 -14
- package/dist/workspace-runtime/workspaces/registry.js +2 -0
- package/dist/workspace-runtime/workspaces/transfer.js +12 -2
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +4 -7
- package/package.json +3 -3
|
@@ -23,6 +23,7 @@ function buildCommand() {
|
|
|
23
23
|
*/
|
|
24
24
|
const PERMISSION_REPAIR_COMMAND = [
|
|
25
25
|
'find . \\( -path ./node_modules -o -path ./.git \\) -prune -o',
|
|
26
|
+
'-uid "$(id -u)"',
|
|
26
27
|
'\\( -type d \\( -not -perm -040 -o -not -perm -010 \\) -o -type f -not -perm -040 \\)',
|
|
27
28
|
'-exec chmod g+rX {} +',
|
|
28
29
|
].join(' ');
|
|
@@ -20,13 +20,16 @@ async function dependencyVersion(name, entry) {
|
|
|
20
20
|
directory = parent;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
/** Starts only an isolated temporary runtime; never reads or changes an existing workspace. */
|
|
24
23
|
export async function verifyRuntimeArtifact(expectedVersion) {
|
|
25
24
|
assert.match(expectedVersion, /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/);
|
|
26
25
|
const metadata = JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
27
26
|
assert.equal(metadata.version, expectedVersion, 'command package version differs from the release target');
|
|
28
|
-
assert.equal(await dependencyVersion('@playcraft/common', '@playcraft/common/cloud-workspace/runtime'), expectedVersion);
|
|
29
|
-
assert.equal(await dependencyVersion('@playcraft/build', '@playcraft/build'), expectedVersion);
|
|
27
|
+
assert.equal(await dependencyVersion('@playcraft/common', '@playcraft/common/cloud-workspace/runtime'), expectedVersion, '@playcraft/common version differs from the release target');
|
|
28
|
+
assert.equal(await dependencyVersion('@playcraft/build', '@playcraft/build'), expectedVersion, '@playcraft/build version differs from the release target');
|
|
29
|
+
await verifyRuntimeCapabilities(expectedVersion);
|
|
30
|
+
}
|
|
31
|
+
/** Verifies the runtime in a temporary workspace, including before release versions are aligned. */
|
|
32
|
+
export async function verifyRuntimeCapabilities(cliVersion) {
|
|
30
33
|
const batchId = randomUUID();
|
|
31
34
|
assert.equal(batchSourceRemoteRef(batchId), `refs/tags/playcraft/batch/${batchId}/source`, 'installed shared library lacks standard tag references for source snapshots');
|
|
32
35
|
const directory = await mkdtemp(join(tmpdir(), 'playcraft-artifact-verify-'));
|
|
@@ -36,7 +39,7 @@ export async function verifyRuntimeArtifact(expectedVersion) {
|
|
|
36
39
|
protocolMajor: 1, protocolMinor: 0, environment: 'artifact-verification',
|
|
37
40
|
runtimeId: randomUUID(), userId: 'artifact-verification', generation: 1, runtimeEpoch: 0,
|
|
38
41
|
workspaceMode: 'multi', workspaceRoot: join(directory, 'workspaces'), stateDir: join(directory, 'state'),
|
|
39
|
-
publicBaseUrl: 'http://127.0.0.1:9', controlToken: randomUUID(), cliVersion
|
|
42
|
+
publicBaseUrl: 'http://127.0.0.1:9', controlToken: randomUUID(), cliVersion,
|
|
40
43
|
listen: { host: '127.0.0.1', port: 0 },
|
|
41
44
|
});
|
|
42
45
|
runtime = await startWorkspaceRuntime(config, { nativeAdapter: createLoopbackNativeAdapter() });
|
|
@@ -7,7 +7,19 @@ import { artifactFormat, relativeArtifactPath, snapshotArtifactBundle } from './
|
|
|
7
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
8
|
import { ensureSharedDirectory, openSharedFile } from './shared-write.js';
|
|
9
9
|
export const ARTIFACT_RECEIPTS_DIRECTORY = '.playcraft/artifact-receipts';
|
|
10
|
+
const ARTIFACT_RECEIPTS_EXCLUDE = `/${ARTIFACT_RECEIPTS_DIRECTORY}/`;
|
|
10
11
|
export const artifactReceiptSchema = workspaceArtifactReceiptSchema;
|
|
12
|
+
/** The controller can write Git metadata before a development command runs with read-only Git. */
|
|
13
|
+
export async function prepareWorkspaceArtifactReceiptExclusion(root) {
|
|
14
|
+
const marker = await lstat(join(root, '.git')).catch(error => {
|
|
15
|
+
if (error.code === 'ENOENT')
|
|
16
|
+
return undefined;
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
// Some existing checkouts use a gitdir file. Do not follow it or block workspace startup.
|
|
20
|
+
if (marker?.isDirectory())
|
|
21
|
+
await excludeFromGit(root, ARTIFACT_RECEIPTS_EXCLUDE);
|
|
22
|
+
}
|
|
11
23
|
export async function generatedWorkspace(cwd) {
|
|
12
24
|
for (let root = resolve(cwd);; root = dirname(root)) {
|
|
13
25
|
const file = join(root, '.playcraft/workspace-context.json');
|
|
@@ -48,6 +60,8 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
48
60
|
}
|
|
49
61
|
if (!isWorkspaceArtifactOutputPath(path) || !artifactFormat(path))
|
|
50
62
|
return;
|
|
63
|
+
// Check the ignore before publishing a receipt that must stay out of source commits.
|
|
64
|
+
await excludeFromGit(context.root, ARTIFACT_RECEIPTS_EXCLUDE);
|
|
51
65
|
// Generations are written by whichever identity runs the tool; the controller scans the same tree.
|
|
52
66
|
const receipts = await ensureSharedDirectory(context.root, join(context.root, ARTIFACT_RECEIPTS_DIRECTORY));
|
|
53
67
|
const objects = await ensureSharedDirectory(context.root, join(context.root, ARTIFACT_RECEIPTS_DIRECTORY, 'objects'));
|
|
@@ -69,8 +83,6 @@ export async function captureGeneratedArtifact(output, tool, metadata = {}, cwd
|
|
|
69
83
|
await handle.sync();
|
|
70
84
|
await handle.close();
|
|
71
85
|
await rename(temporary, join(receipts, `${receipt.id}.json`));
|
|
72
|
-
// The workspace checkpoint retains these receipts; they are not source files or exported assets.
|
|
73
|
-
await excludeFromGit(context.root, `/${ARTIFACT_RECEIPTS_DIRECTORY}/`);
|
|
74
86
|
}
|
|
75
87
|
finally {
|
|
76
88
|
await handle.close();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MIME_TYPES, workspaceVideoAttachmentQuerySchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { NativePreparationError } from '../codex/native-adapter.js';
|
|
@@ -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, shareIfNeeded, SHARED_DIRECTORY_MODE } 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])))
|
|
@@ -62,17 +50,11 @@ export async function uploadWorkspaceAttachment(root, body) {
|
|
|
62
50
|
await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
|
|
63
51
|
const uploads = await attachmentRoot(root);
|
|
64
52
|
const directory = join(uploads, attachment.id);
|
|
65
|
-
const metadataDirectory = join(uploads, '.metadata');
|
|
66
|
-
// shared
|
|
67
|
-
await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => {
|
|
68
|
-
if (error.code !== 'EEXIST')
|
|
69
|
-
throw error;
|
|
70
|
-
});
|
|
71
|
-
if (!(await lstat(metadataDirectory)).isDirectory())
|
|
72
|
-
throw new Error('invalid_attachment');
|
|
53
|
+
const metadataDirectory = await ensureSharedDirectory(root, join(uploads, '.metadata'));
|
|
73
54
|
// Codex can run as a different user from the sandbox control process.
|
|
74
55
|
// shared
|
|
75
|
-
await mkdir(directory, { mode:
|
|
56
|
+
await mkdir(directory, { mode: SHARED_DIRECTORY_MODE });
|
|
57
|
+
await ensureSharedDirectory(root, directory);
|
|
76
58
|
// shared
|
|
77
59
|
await writeFile(join(directory, attachment.name), bytes, { flag: 'wx', mode: 0o644 });
|
|
78
60
|
// shared
|
|
@@ -92,14 +74,11 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
|
|
|
92
74
|
throw new Error('invalid_attachment');
|
|
93
75
|
await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
|
|
94
76
|
const uploads = await attachmentRoot(root);
|
|
95
|
-
const id = randomUUID(), directory = join(uploads, id)
|
|
96
|
-
|
|
97
|
-
await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
|
|
98
|
-
throw error; });
|
|
99
|
-
if (!(await lstat(metadataDirectory)).isDirectory())
|
|
100
|
-
throw new Error('invalid_attachment');
|
|
77
|
+
const id = randomUUID(), directory = join(uploads, id);
|
|
78
|
+
const metadataDirectory = await ensureSharedDirectory(root, join(uploads, '.metadata'));
|
|
101
79
|
// shared
|
|
102
|
-
await mkdir(directory, { mode:
|
|
80
|
+
await mkdir(directory, { mode: SHARED_DIRECTORY_MODE });
|
|
81
|
+
await ensureSharedDirectory(root, directory);
|
|
103
82
|
// shared
|
|
104
83
|
const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
|
|
105
84
|
try {
|
|
@@ -140,7 +119,7 @@ export async function resolveWorkspaceAttachments(root, input) {
|
|
|
140
119
|
const directory = join(uploads, id);
|
|
141
120
|
if (!(await lstat(directory)).isDirectory())
|
|
142
121
|
throw new Error('invalid_attachment');
|
|
143
|
-
await
|
|
122
|
+
await shareIfNeeded(root, directory);
|
|
144
123
|
if (!(await lstat(join(uploads, '.metadata'))).isDirectory())
|
|
145
124
|
throw new Error('invalid_attachment');
|
|
146
125
|
const metadataHandle = await open(join(uploads, '.metadata', `${id}.json`), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
@@ -292,20 +292,45 @@ export async function excludeFromGit(root, entry = '/node_modules/') {
|
|
|
292
292
|
return;
|
|
293
293
|
handles.push(git);
|
|
294
294
|
const gitPath = pinned(git, join(root, '.git'));
|
|
295
|
-
|
|
296
|
-
|
|
295
|
+
const infoPath = join(gitPath, 'info');
|
|
296
|
+
const info = await open(infoPath, flags).catch(async (error) => {
|
|
297
|
+
if (error.code !== 'ENOENT')
|
|
297
298
|
throw error;
|
|
299
|
+
await mkdir(infoPath).catch((error) => {
|
|
300
|
+
if (error.code !== 'EEXIST')
|
|
301
|
+
throw error;
|
|
302
|
+
});
|
|
303
|
+
return open(infoPath, flags);
|
|
298
304
|
});
|
|
299
|
-
const info = await open(join(gitPath, 'info'), flags);
|
|
300
305
|
handles.push(info);
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
+
const excludePath = join(pinned(info, join(root, '.git/info')), 'exclude');
|
|
307
|
+
const excludeFlags = constants.O_NOFOLLOW | constants.O_NONBLOCK;
|
|
308
|
+
const readExclude = async (handle) => {
|
|
309
|
+
const metadata = await handle.stat();
|
|
310
|
+
if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
|
|
311
|
+
throw new Error('invalid_git_exclude');
|
|
312
|
+
}
|
|
313
|
+
return handle.readFile('utf8');
|
|
314
|
+
};
|
|
315
|
+
const hasEntry = (content) => content.split('\n').some((line) => line.trim() === entry);
|
|
316
|
+
const existing = await open(excludePath, constants.O_RDONLY | excludeFlags).catch((error) => {
|
|
317
|
+
if (error.code === 'ENOENT')
|
|
318
|
+
return undefined;
|
|
319
|
+
throw error;
|
|
320
|
+
});
|
|
321
|
+
if (existing) {
|
|
322
|
+
try {
|
|
323
|
+
if (hasEntry(await readExclude(existing)))
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
await existing.close();
|
|
328
|
+
}
|
|
306
329
|
}
|
|
307
|
-
|
|
308
|
-
|
|
330
|
+
// shared
|
|
331
|
+
file = await open(excludePath, constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | excludeFlags, 0o666);
|
|
332
|
+
const current = await readExclude(file);
|
|
333
|
+
if (!hasEntry(current)) {
|
|
309
334
|
await file.writeFile(`${current && !current.endsWith('\n') ? '\n' : ''}${entry}\n`);
|
|
310
335
|
}
|
|
311
336
|
}
|
|
@@ -14,6 +14,7 @@ import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess, } from './ski
|
|
|
14
14
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
15
15
|
import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
|
|
16
16
|
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
17
|
+
import { prepareWorkspaceArtifactReceiptExclusion } from './artifact-receipts.js';
|
|
17
18
|
import { shareWorkspaceRepository } from './git.js';
|
|
18
19
|
import { checkoutChatWorktree } from './worktree-checkout.js';
|
|
19
20
|
const execute = promisify(execFile);
|
|
@@ -121,6 +122,7 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
await shareWorkspaceRepository(root);
|
|
125
|
+
await prepareWorkspaceArtifactReceiptExclusion(root);
|
|
124
126
|
await ensureWorkspaceArtifactInstructions(root);
|
|
125
127
|
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
126
128
|
await state.journal.exclusive(`workspace:${directory.workspaceId}`, async () => {
|
|
@@ -151,7 +153,9 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
151
153
|
if (!binding)
|
|
152
154
|
return;
|
|
153
155
|
if (directory?.status !== 'reclaimed') {
|
|
154
|
-
|
|
156
|
+
const root = await workspaceRoot(state, workspaceId, directoryName);
|
|
157
|
+
await prepareWorkspaceArtifactReceiptExclusion(root);
|
|
158
|
+
await ensureWorkspaceArtifactInstructions(root);
|
|
155
159
|
return;
|
|
156
160
|
}
|
|
157
161
|
// Recheck under the journal lock: a resident-directory caller holds only shared access.
|
|
@@ -176,6 +180,7 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
|
|
|
176
180
|
await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), workspacePlatformSkillAccess(state, workspaceId), { optionalPlatformDefaults: true });
|
|
177
181
|
if (directoryName === 'basic')
|
|
178
182
|
await writeWorkspaceCliContext(root, workspaceId);
|
|
183
|
+
await prepareWorkspaceArtifactReceiptExclusion(root);
|
|
179
184
|
await ensureWorkspaceArtifactInstructions(root);
|
|
180
185
|
await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
|
|
181
186
|
await state.journal.putWorkspace({
|
|
@@ -6,6 +6,7 @@ import { attachmentIdsSchema, WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_ATTACH
|
|
|
6
6
|
import { artifactMime } from './artifact-files.js';
|
|
7
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
8
|
import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
|
|
9
|
+
import { ensureSharedDirectory, SHARED_DIRECTORY_MODE } from './shared-write.js';
|
|
9
10
|
const stagingPath = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.directory-uploads`;
|
|
10
11
|
const locks = new Map();
|
|
11
12
|
async function withUploadLock(root, id, action) {
|
|
@@ -22,15 +23,11 @@ async function withUploadLock(root, id, action) {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
async function safeDirectory(root, relative, create = false) {
|
|
26
|
+
if (create)
|
|
27
|
+
return ensureSharedDirectory(root, join(root, relative));
|
|
25
28
|
let directory = root;
|
|
26
29
|
for (const part of relative.split('/')) {
|
|
27
30
|
directory = join(directory, part);
|
|
28
|
-
// shared
|
|
29
|
-
if (create)
|
|
30
|
-
await mkdir(directory, { mode: 0o755 }).catch((error) => {
|
|
31
|
-
if (error.code !== 'EEXIST')
|
|
32
|
-
throw error;
|
|
33
|
-
});
|
|
34
31
|
if (!(await lstat(directory)).isDirectory())
|
|
35
32
|
throw new Error('invalid_attachment');
|
|
36
33
|
}
|
|
@@ -61,12 +58,9 @@ export async function beginWorkspaceDirectoryAttachment(root, body) {
|
|
|
61
58
|
const id = randomUUID();
|
|
62
59
|
const stage = join(staging, id);
|
|
63
60
|
// shared
|
|
64
|
-
await mkdir(stage, { mode:
|
|
61
|
+
await mkdir(stage, { mode: SHARED_DIRECTORY_MODE });
|
|
65
62
|
try {
|
|
66
|
-
|
|
67
|
-
await mkdir(join(stage, 'data'), { mode: 0o755 });
|
|
68
|
-
// shared
|
|
69
|
-
await mkdir(join(stage, 'data', manifest.name), { mode: 0o755 });
|
|
63
|
+
await ensureSharedDirectory(root, join(stage, 'data', manifest.name));
|
|
70
64
|
// shared
|
|
71
65
|
await writeFile(join(stage, 'manifest.json'), JSON.stringify(manifest), { flag: 'wx', mode: 0o644 });
|
|
72
66
|
return { uploadId: id };
|
|
@@ -171,8 +165,9 @@ export async function completeWorkspaceDirectoryAttachment(root, id) {
|
|
|
171
165
|
workspaceAttachmentSchema.parse(attachment);
|
|
172
166
|
const target = join(uploads, id);
|
|
173
167
|
// shared
|
|
174
|
-
await mkdir(target, { mode:
|
|
168
|
+
await mkdir(target, { mode: SHARED_DIRECTORY_MODE });
|
|
175
169
|
try {
|
|
170
|
+
await ensureSharedDirectory(root, target);
|
|
176
171
|
await rename(join(stage, 'data', manifest.name), join(target, manifest.name));
|
|
177
172
|
// shared
|
|
178
173
|
await writeFile(join(metadata, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
|
|
@@ -1,22 +1,17 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { lstat, mkdir, open, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES, WORKSPACE_ATTACHMENT_MAX_COUNT, workspaceAttachmentSchema, workspaceLibraryAttachmentUploadSchema, workspaceLibraryFilePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { excludeFromGit } from './dependencies.js';
|
|
7
|
+
import { ensureSharedDirectory, shareIfNeeded, SHARED_DIRECTORY_MODE } from './shared-write.js';
|
|
7
8
|
const METADATA_MAX_BYTES = 1024 * 1024;
|
|
8
9
|
const locks = new Map();
|
|
9
10
|
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
-
async function safeDirectory(root, path
|
|
11
|
+
async function safeDirectory(root, path) {
|
|
11
12
|
let directory = root;
|
|
12
13
|
for (const part of path.split('/').filter(Boolean)) {
|
|
13
14
|
directory = join(directory, part);
|
|
14
|
-
// shared
|
|
15
|
-
if (create)
|
|
16
|
-
await mkdir(directory, { mode: 0o755 }).catch((error) => {
|
|
17
|
-
if (error.code !== 'EEXIST')
|
|
18
|
-
throw error;
|
|
19
|
-
});
|
|
20
15
|
const stat = await lstat(directory);
|
|
21
16
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
22
17
|
throw new Error('invalid_library_attachment');
|
|
@@ -24,7 +19,8 @@ async function safeDirectory(root, path, create = false) {
|
|
|
24
19
|
return directory;
|
|
25
20
|
}
|
|
26
21
|
async function metadataDirectory(root, create = false) {
|
|
27
|
-
|
|
22
|
+
const path = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata`;
|
|
23
|
+
return create ? ensureSharedDirectory(root, join(root, path)) : safeDirectory(root, path);
|
|
28
24
|
}
|
|
29
25
|
export async function readLibraryAttachmentMetadata(root, id) {
|
|
30
26
|
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
@@ -159,8 +155,8 @@ async function importAttachment(root, body) {
|
|
|
159
155
|
throw new Error('library_asset_changed');
|
|
160
156
|
const id = attachmentId(asset);
|
|
161
157
|
await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
|
|
162
|
-
const uploads = await safeDirectory(root, WORKSPACE_USER_UPLOADS_DIRECTORY, true);
|
|
163
158
|
const metadata = await metadataDirectory(root, true);
|
|
159
|
+
const uploads = dirname(metadata);
|
|
164
160
|
let record;
|
|
165
161
|
try {
|
|
166
162
|
record = await readLibraryAttachmentMetadata(root, id);
|
|
@@ -174,13 +170,13 @@ async function importAttachment(root, body) {
|
|
|
174
170
|
if (!record) {
|
|
175
171
|
const temporary = join(uploads, `.import-${randomUUID()}`);
|
|
176
172
|
const target = join(uploads, id);
|
|
177
|
-
|
|
178
|
-
await mkdir(temporary, { mode: 0o755 });
|
|
173
|
+
await mkdir(temporary, { mode: SHARED_DIRECTORY_MODE });
|
|
179
174
|
try {
|
|
175
|
+
await ensureSharedDirectory(root, temporary);
|
|
180
176
|
for (const file of files) {
|
|
181
177
|
const parent = dirname(file.path);
|
|
182
178
|
if (parent !== '.')
|
|
183
|
-
await
|
|
179
|
+
await ensureSharedDirectory(root, join(temporary, parent));
|
|
184
180
|
// shared
|
|
185
181
|
await writeFile(join(temporary, file.path), file.bytes, { flag: 'wx', mode: 0o644 });
|
|
186
182
|
}
|
|
@@ -238,11 +234,11 @@ async function importAttachment(root, body) {
|
|
|
238
234
|
record.sessionIds = [...new Set([...record.sessionIds, ...(sessionId ? [sessionId] : [])])];
|
|
239
235
|
record.clientRequestIds = [...new Set([...record.clientRequestIds, clientRequestId])];
|
|
240
236
|
record.unbound ||= !sessionId;
|
|
237
|
+
await shareIfNeeded(root, join(uploads, id));
|
|
241
238
|
const tempMetadata = join(metadata, `.import-${randomUUID()}.json`);
|
|
242
239
|
// shared
|
|
243
240
|
await writeFile(tempMetadata, JSON.stringify(record), { flag: 'wx', mode: 0o644 });
|
|
244
241
|
await rename(tempMetadata, join(metadata, `${id}.json`));
|
|
245
|
-
await chmod(join(uploads, id), 0o755);
|
|
246
242
|
return publicAttachment(record);
|
|
247
243
|
}
|
|
248
244
|
export async function importWorkspaceLibraryAttachment(root, body) {
|
|
@@ -12,6 +12,7 @@ import { writeWorkspaceCliContext } from './cli-context.js';
|
|
|
12
12
|
import { attachWorkspaceRemote } from './git.js';
|
|
13
13
|
import { markRemoteAttachPending } from './remote-attach-marker.js';
|
|
14
14
|
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
15
|
+
import { prepareWorkspaceArtifactReceiptExclusion } from './artifact-receipts.js';
|
|
15
16
|
import { repairWorkspaceShares } from './workspace-shares.js';
|
|
16
17
|
export { WorkspaceContextError } from './context-error.js';
|
|
17
18
|
const exec = promisify(execFile);
|
|
@@ -257,6 +258,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
257
258
|
throw new WorkspaceContextError('workspace_skills_unavailable');
|
|
258
259
|
}
|
|
259
260
|
await writeWorkspaceCliContext(root, binding.workspaceId);
|
|
261
|
+
await prepareWorkspaceArtifactReceiptExclusion(root);
|
|
260
262
|
await ensureWorkspaceArtifactInstructions(root);
|
|
261
263
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
262
264
|
// Registration is the one point every reused container reaches: the root repair is skipped
|
|
@@ -7,6 +7,7 @@ import { dirname, join, resolve, sep } from 'node:path';
|
|
|
7
7
|
import { Transform } from 'node:stream';
|
|
8
8
|
import { pipeline } from 'node:stream/promises';
|
|
9
9
|
import { isCredentialFile } from './git.js';
|
|
10
|
+
import { shareIfNeeded } from './shared-write.js';
|
|
10
11
|
const EXCLUDED = new Set(['.git', 'node_modules', '.playcraft-workspace.json', '.playcraft.json']);
|
|
11
12
|
const GIT_TIMEOUT_MS = 15 * 60_000;
|
|
12
13
|
const GIT_OPTIONS = ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'core.autocrlf=false',
|
|
@@ -224,9 +225,18 @@ async function installFile(root, path, source, executable) {
|
|
|
224
225
|
return undefined; throw error; });
|
|
225
226
|
if (existing?.isDirectory())
|
|
226
227
|
await rmdir(target);
|
|
227
|
-
|
|
228
|
-
const stage = join(
|
|
228
|
+
const parent = dirname(target);
|
|
229
|
+
const stage = join(parent, `.playcraft-transfer-${randomUUID()}.tmp`);
|
|
229
230
|
try {
|
|
231
|
+
// Share only newly created directories so existing permissions survive a failed import.
|
|
232
|
+
const firstCreated = await mkdir(parent, { recursive: true });
|
|
233
|
+
if (firstCreated) {
|
|
234
|
+
for (let directory = parent;; directory = dirname(directory)) {
|
|
235
|
+
await shareIfNeeded(root, directory);
|
|
236
|
+
if (directory === firstCreated)
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
230
240
|
await copyFile(source, stage);
|
|
231
241
|
await chmod(stage, executable ? 0o755 : 0o644);
|
|
232
242
|
await rename(stage, target);
|
|
@@ -1,20 +1,17 @@
|
|
|
1
1
|
import { constants } from 'node:fs';
|
|
2
2
|
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
-
import { lstat,
|
|
3
|
+
import { lstat, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { dirname, join, relative } from 'node:path';
|
|
5
5
|
import { attachmentIdsSchema, WORKSPACE_USER_UPLOADS_DIRECTORY } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { resolveWorkspaceAttachments } from './attachments.js';
|
|
7
7
|
import { excludeFromGit } from './dependencies.js';
|
|
8
|
-
|
|
8
|
+
import { ensureSharedDirectory } from './shared-write.js';
|
|
9
|
+
async function safeParents(root, path) {
|
|
9
10
|
let current = root;
|
|
10
11
|
for (const segment of path.split('/')) {
|
|
11
12
|
if (!segment || segment === '.' || segment === '..')
|
|
12
13
|
throw new Error('workspace_worktree_attachment_unavailable');
|
|
13
14
|
current = join(current, segment);
|
|
14
|
-
// shared
|
|
15
|
-
if (create)
|
|
16
|
-
await mkdir(current, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
|
|
17
|
-
throw error; });
|
|
18
15
|
const stat = await lstat(current);
|
|
19
16
|
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
20
17
|
throw new Error('workspace_worktree_attachment_unavailable');
|
|
@@ -35,7 +32,7 @@ async function readSafe(root, path, max) {
|
|
|
35
32
|
}
|
|
36
33
|
async function copyExact(source, target, path, limit) {
|
|
37
34
|
const body = await readSafe(source, path, limit);
|
|
38
|
-
await
|
|
35
|
+
await ensureSharedDirectory(target, join(target, dirname(path)));
|
|
39
36
|
const existing = await lstat(join(target, path)).catch(() => undefined);
|
|
40
37
|
if (existing) {
|
|
41
38
|
const previous = await readSafe(target, path, limit);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.77",
|
|
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.77",
|
|
61
|
+
"@playcraft/common": "0.1.77",
|
|
62
62
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
63
63
|
"chokidar": "^4.0.3",
|
|
64
64
|
"commander": "^13.1.0",
|