@playcraft/cli 0.1.75 → 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.
@@ -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: expectedVersion,
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 { chmod, lstat, mkdir, open, rm, writeFile } from 'node:fs/promises';
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,7 +8,7 @@ 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
- import { ensureSharedDirectory } from './shared-write.js';
11
+ import { ensureSharedDirectory, shareIfNeeded, SHARED_DIRECTORY_MODE } from './shared-write.js';
12
12
  function attachmentRoot(root) {
13
13
  return ensureSharedDirectory(root, join(root, WORKSPACE_USER_UPLOADS_DIRECTORY));
14
14
  }
@@ -50,17 +50,11 @@ export async function uploadWorkspaceAttachment(root, body) {
50
50
  await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
51
51
  const uploads = await attachmentRoot(root);
52
52
  const directory = join(uploads, attachment.id);
53
- const metadataDirectory = join(uploads, '.metadata');
54
- // shared
55
- await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => {
56
- if (error.code !== 'EEXIST')
57
- throw error;
58
- });
59
- if (!(await lstat(metadataDirectory)).isDirectory())
60
- throw new Error('invalid_attachment');
53
+ const metadataDirectory = await ensureSharedDirectory(root, join(uploads, '.metadata'));
61
54
  // Codex can run as a different user from the sandbox control process.
62
55
  // shared
63
- await mkdir(directory, { mode: 0o755 });
56
+ await mkdir(directory, { mode: SHARED_DIRECTORY_MODE });
57
+ await ensureSharedDirectory(root, directory);
64
58
  // shared
65
59
  await writeFile(join(directory, attachment.name), bytes, { flag: 'wx', mode: 0o644 });
66
60
  // shared
@@ -80,14 +74,11 @@ export async function uploadWorkspaceVideoAttachment(root, request, query) {
80
74
  throw new Error('invalid_attachment');
81
75
  await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
82
76
  const uploads = await attachmentRoot(root);
83
- const id = randomUUID(), directory = join(uploads, id), metadataDirectory = join(uploads, '.metadata');
84
- // shared
85
- await mkdir(metadataDirectory, { mode: 0o755 }).catch((error) => { if (error.code !== 'EEXIST')
86
- throw error; });
87
- if (!(await lstat(metadataDirectory)).isDirectory())
88
- throw new Error('invalid_attachment');
77
+ const id = randomUUID(), directory = join(uploads, id);
78
+ const metadataDirectory = await ensureSharedDirectory(root, join(uploads, '.metadata'));
89
79
  // shared
90
- await mkdir(directory, { mode: 0o755 });
80
+ await mkdir(directory, { mode: SHARED_DIRECTORY_MODE });
81
+ await ensureSharedDirectory(root, directory);
91
82
  // shared
92
83
  const handle = await open(join(directory, name), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
93
84
  try {
@@ -128,7 +119,7 @@ export async function resolveWorkspaceAttachments(root, input) {
128
119
  const directory = join(uploads, id);
129
120
  if (!(await lstat(directory)).isDirectory())
130
121
  throw new Error('invalid_attachment');
131
- await chmod(directory, 0o755);
122
+ await shareIfNeeded(root, directory);
132
123
  if (!(await lstat(join(uploads, '.metadata'))).isDirectory())
133
124
  throw new Error('invalid_attachment');
134
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
- await mkdir(join(gitPath, 'info')).catch((error) => {
296
- if (error.code !== 'EEXIST')
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
- // shared
302
- file = await open(join(pinned(info, join(root, '.git/info')), 'exclude'), constants.O_RDWR | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o666);
303
- const metadata = await file.stat();
304
- if (!metadata.isFile() || metadata.nlink !== 1 || metadata.size > 1024 * 1024) {
305
- throw new Error('invalid_git_exclude');
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
- const current = await file.readFile('utf8');
308
- if (!current.split('\n').some((line) => line.trim() === entry)) {
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
- await ensureWorkspaceArtifactInstructions(await workspaceRoot(state, workspaceId, directoryName));
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: 0o755 });
61
+ await mkdir(stage, { mode: SHARED_DIRECTORY_MODE });
65
62
  try {
66
- // shared
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: 0o755 });
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 { chmod, lstat, mkdir, open, readdir, rename, rm, writeFile } from 'node:fs/promises';
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, create = false) {
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
- return safeDirectory(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata`, create);
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
- // shared
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 safeDirectory(temporary, parent, true);
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
- await mkdir(dirname(target), { recursive: true });
228
- const stage = join(dirname(target), `.playcraft-transfer-${randomUUID()}.tmp`);
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, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
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
- async function safeParents(root, path, create = false) {
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 safeParents(target, dirname(path), true);
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.75",
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.75",
61
- "@playcraft/common": "0.1.75",
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",