@playcraft/cli 0.1.30 → 0.1.32

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.
Files changed (30) hide show
  1. package/README.md +18 -0
  2. package/dist/commands/workspace.js +35 -0
  3. package/dist/index.js +2 -0
  4. package/dist/workspace/messages.js +35 -0
  5. package/dist/workspace/transfer-client.js +174 -0
  6. package/dist/workspace-runtime/previews/output.js +36 -0
  7. package/dist/workspace-runtime/previews/publish.js +4 -1
  8. package/dist/workspace-runtime/production/native.js +8 -1
  9. package/dist/workspace-runtime/production/stages.js +6 -3
  10. package/dist/workspace-runtime/production/storage.js +36 -8
  11. package/dist/workspace-runtime/server/dispatch.js +22 -5
  12. package/dist/workspace-runtime/server/http.js +91 -2
  13. package/dist/workspace-runtime/server/workspace-transfer.js +99 -0
  14. package/dist/workspace-runtime/workspaces/archive.js +207 -0
  15. package/dist/workspace-runtime/workspaces/artifact-files.js +4 -2
  16. package/dist/workspace-runtime/workspaces/artifact-receipts.js +3 -3
  17. package/dist/workspace-runtime/workspaces/artifacts.js +38 -16
  18. package/dist/workspace-runtime/workspaces/attachment-content.js +16 -4
  19. package/dist/workspace-runtime/workspaces/attachments.js +7 -1
  20. package/dist/workspace-runtime/workspaces/directories.js +15 -5
  21. package/dist/workspace-runtime/workspaces/directory-access.js +12 -4
  22. package/dist/workspace-runtime/workspaces/directory-attachments.js +245 -0
  23. package/dist/workspace-runtime/workspaces/git.js +76 -7
  24. package/dist/workspace-runtime/workspaces/instructions.js +56 -0
  25. package/dist/workspace-runtime/workspaces/library-attachments.js +268 -0
  26. package/dist/workspace-runtime/workspaces/registry.js +11 -1
  27. package/dist/workspace-runtime/workspaces/transfer.js +288 -0
  28. package/dist/workspace-runtime/workspaces/turn-changes.js +72 -0
  29. package/package.json +3 -3
  30. package/project-templates/empty/AGENTS.md +14 -0
@@ -4,6 +4,8 @@ import { extname } from 'node:path';
4
4
  import { pipeline } from 'node:stream/promises';
5
5
  import { attachmentIdsSchema, attachmentUploadSchema, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { artifactMime, artifactPath } from './artifact-files.js';
7
+ import { openLibraryAttachmentFile, readLibraryAttachmentMetadata } from './library-attachments.js';
8
+ import { openWorkspaceDirectoryFile, readWorkspaceDirectoryMetadata } from './directory-attachments.js';
7
9
  const additionalMediaTypes = {
8
10
  '.aac': 'audio/aac', '.opus': 'audio/ogg', '.oga': 'audio/ogg', '.aif': 'audio/aiff', '.aiff': 'audio/aiff',
9
11
  '.bmp': 'image/bmp', '.ico': 'image/vnd.microsoft.icon', '.heic': 'image/heic', '.heif': 'image/heif',
@@ -24,13 +26,13 @@ function attachmentRange(header, size) {
24
26
  const end = first === undefined ? size - 1 : Math.min(last ?? size - 1, size - 1);
25
27
  return start >= size || start > end ? null : { start, end };
26
28
  }
27
- async function openAttachment(root, id) {
29
+ async function openAttachment(root, id, filePath) {
28
30
  const metadataPath = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata/${id}.json`);
29
31
  const metadataFile = await open(metadataPath, constants.O_RDONLY | constants.O_NOFOLLOW);
30
32
  let metadata;
31
33
  try {
32
34
  const stat = await metadataFile.stat();
33
- if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192)
35
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
34
36
  throw new Error('attachment_unavailable');
35
37
  metadata = JSON.parse(await metadataFile.readFile('utf8'));
36
38
  if (metadata.id !== id || !attachmentUploadSchema.shape.name.safeParse(metadata.name).success)
@@ -39,6 +41,16 @@ async function openAttachment(root, id) {
39
41
  finally {
40
42
  await metadataFile.close();
41
43
  }
44
+ if (metadata.origin?.kind === 'asset-library') {
45
+ const library = await readLibraryAttachmentMetadata(root, id);
46
+ return openLibraryAttachmentFile(root, library, filePath);
47
+ }
48
+ if (metadata.kind === 'directory') {
49
+ const directory = await readWorkspaceDirectoryMetadata(root, id);
50
+ return openWorkspaceDirectoryFile(root, directory, filePath);
51
+ }
52
+ if (filePath !== undefined)
53
+ throw new Error('attachment_unavailable');
42
54
  const path = await artifactPath(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${metadata.name}`);
43
55
  const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
44
56
  try {
@@ -56,7 +68,7 @@ async function openAttachment(root, id) {
56
68
  }
57
69
  }
58
70
  /** Serve only registered references; no caller-supplied file paths reach the filesystem. */
59
- export async function serveWorkspaceAttachment(root, attachmentId, request, response) {
71
+ export async function serveWorkspaceAttachment(root, attachmentId, request, response, filePath) {
60
72
  if (!attachmentIdsSchema.safeParse([attachmentId]).success) {
61
73
  response.writeHead(400, { 'content-type': 'application/json', 'cache-control': 'no-store' });
62
74
  response.end(JSON.stringify({ error: 'invalid_attachment' }));
@@ -64,7 +76,7 @@ export async function serveWorkspaceAttachment(root, attachmentId, request, resp
64
76
  }
65
77
  let content;
66
78
  try {
67
- content = await openAttachment(root, attachmentId);
79
+ content = await openAttachment(root, attachmentId, filePath);
68
80
  }
69
81
  catch {
70
82
  response.writeHead(404, { 'content-type': 'application/json', 'cache-control': 'no-store' });
@@ -6,6 +6,8 @@ import { WORKSPACE_USER_UPLOADS_DIRECTORY, attachmentIdsSchema, attachmentUpload
6
6
  import { NativePreparationError } from '../codex/native-adapter.js';
7
7
  import { excludeFromGit } from './dependencies.js';
8
8
  import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
9
+ import { resolveLibraryAttachment } from './library-attachments.js';
10
+ import { resolveWorkspaceDirectoryAttachment } from './directory-attachments.js';
9
11
  async function attachmentRoot(root) {
10
12
  // Reject redirected upload directories, including symlinks that stay inside the workspace.
11
13
  let directory = root;
@@ -136,13 +138,17 @@ export async function resolveWorkspaceAttachments(root, input) {
136
138
  let metadata;
137
139
  try {
138
140
  const stat = await metadataHandle.stat();
139
- if (!stat.isFile() || stat.nlink !== 1 || stat.size > 8192)
141
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
140
142
  throw new Error('invalid_attachment');
141
143
  metadata = JSON.parse(await metadataHandle.readFile('utf8'));
142
144
  }
143
145
  finally {
144
146
  await metadataHandle.close();
145
147
  }
148
+ if (metadata.origin?.kind === 'asset-library')
149
+ return resolveLibraryAttachment(root, id);
150
+ if (metadata.kind === 'directory')
151
+ return resolveWorkspaceDirectoryAttachment(root, id);
146
152
  attachmentUploadSchema.shape.name.parse(metadata.name);
147
153
  if (metadata.id !== id)
148
154
  throw new Error('invalid_attachment');
@@ -11,6 +11,7 @@ import { readDependencies, scheduleDependencyInstall } from './dependencies.js';
11
11
  import { revisionScope } from './registry.js';
12
12
  import { ensureWorkspaceSkills } from './skills.js';
13
13
  import { checkoutBatchCommit, freezeBatchCommit } from './batch-commit.js';
14
+ import { ensureWorkspaceArtifactInstructions } from './instructions.js';
14
15
  const execute = promisify(execFile);
15
16
  /** Trusted Runtime registration clones an immutable local commit; it never follows basic's current branch head. */
16
17
  export async function registerWorkspaceDirectory(state, input, leaseFence, sourceDirectoryName) {
@@ -68,12 +69,16 @@ export async function registerWorkspaceDirectory(state, input, leaseFence, sourc
68
69
  await rm(staging, { recursive: true, force: true });
69
70
  }
70
71
  }
72
+ await ensureWorkspaceArtifactInstructions(root);
71
73
  await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
72
- state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
73
- await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
74
- await state.journal.putWorkspace({ ...binding, directories: [...(binding.directories ?? []).filter(item => item.directoryName !== directory.directoryName), { ...directory, status: 'ready' }] });
74
+ await state.journal.exclusive(`workspace:${directory.workspaceId}`, async () => {
75
+ state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
76
+ await state.journal.setAcceptedLeaseFence(state.acceptedLeaseFence);
77
+ const latest = state.journal.getWorkspace(directory.workspaceId) ?? binding;
78
+ await state.journal.putWorkspace({ ...latest, directories: [...(latest.directories ?? []).filter(item => item.directoryName !== directory.directoryName), { ...directory, status: 'ready' }] });
79
+ });
75
80
  if (state.gitCredentials)
76
- await state.gitCredentials.configure(binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
81
+ await state.gitCredentials.configure(state.journal.getWorkspace(directory.workspaceId) ?? binding, await state.gitCredentials.prepareDirectory(directory.workspaceId, directory.directoryName), directory.directoryName);
77
82
  return directory;
78
83
  });
79
84
  }
@@ -84,8 +89,12 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
84
89
  await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
85
90
  const binding = state.journal.getWorkspace(workspaceId);
86
91
  const directory = binding?.directories?.find(item => item.directoryName === directoryName);
87
- if (!binding || directory?.status !== 'reclaimed')
92
+ if (!binding)
88
93
  return;
94
+ if (directory?.status !== 'reclaimed') {
95
+ await ensureWorkspaceArtifactInstructions(await workspaceRoot(state, workspaceId, directoryName));
96
+ return;
97
+ }
89
98
  if (binding.targetKind === 'standalone' || !state.gitCredentials)
90
99
  throw new WorkspaceContextError('workspace_recovery_required');
91
100
  const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
@@ -95,6 +104,7 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
95
104
  if (!await lstat(root).catch(() => undefined))
96
105
  await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
97
106
  await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
107
+ await ensureWorkspaceArtifactInstructions(root);
98
108
  await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
99
109
  await state.journal.putWorkspace({ ...binding, directories: binding.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'ready' } : item) });
100
110
  try {
@@ -11,18 +11,26 @@ export async function grantWorkspaceTraversal(root, groupId) {
11
11
  }
12
12
  /** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
13
13
  export async function openDirectoryToDevelopment(root, groupId) {
14
+ const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
14
15
  const visit = async (path) => {
15
16
  const stat = await lstat(path);
16
17
  if (stat.isSymbolicLink())
17
18
  return;
18
- if (stat.gid !== groupId)
19
+ const canModify = currentUid === undefined || currentUid === 0 || stat.uid === currentUid;
20
+ if (canModify && stat.gid !== groupId)
19
21
  await chown(path, stat.uid, groupId);
20
22
  if (stat.isDirectory()) {
21
- await chmod(path, (stat.mode & 0o777) | 0o2770);
22
- for (const entry of await readdir(path))
23
+ if (canModify)
24
+ await chmod(path, (stat.mode & 0o777) | 0o2770);
25
+ const entries = await readdir(path).catch(error => {
26
+ if (!canModify && error.code === 'EACCES')
27
+ return [];
28
+ throw error;
29
+ });
30
+ for (const entry of entries)
23
31
  await visit(join(path, entry));
24
32
  }
25
- else if (stat.isFile())
33
+ else if (stat.isFile() && canModify)
26
34
  await chmod(path, (stat.mode & 0o777) | 0o660);
27
35
  };
28
36
  await visit(root);
@@ -0,0 +1,245 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdir, open, readdir, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { dirname, join } from 'node:path';
5
+ import { attachmentIdsSchema, WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_ATTACHMENT_MAX_BYTES, WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES, WORKSPACE_DIRECTORY_ATTACHMENT_MAX_BYTES, workspaceAttachmentSchema, workspaceDirectoryAttachmentUploadSchema, workspaceDirectoryAttachmentFilePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
+ import { artifactMime } from './artifact-files.js';
7
+ import { excludeFromGit } from './dependencies.js';
8
+ import { hashAttachment, probeAttachmentVideo } from './video-attachments.js';
9
+ const stagingPath = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.directory-uploads`;
10
+ const locks = new Map();
11
+ async function withUploadLock(root, id, action) {
12
+ const key = `${root}\0${id}`;
13
+ const previous = locks.get(key) ?? Promise.resolve();
14
+ const current = previous.catch(() => undefined).then(action);
15
+ locks.set(key, current);
16
+ try {
17
+ return await current;
18
+ }
19
+ finally {
20
+ if (locks.get(key) === current)
21
+ locks.delete(key);
22
+ }
23
+ }
24
+ async function safeDirectory(root, relative, create = false) {
25
+ let directory = root;
26
+ for (const part of relative.split('/')) {
27
+ directory = join(directory, part);
28
+ if (create)
29
+ await mkdir(directory, { mode: 0o755 }).catch((error) => {
30
+ if (error.code !== 'EEXIST')
31
+ throw error;
32
+ });
33
+ if (!(await lstat(directory)).isDirectory())
34
+ throw new Error('invalid_attachment');
35
+ }
36
+ return directory;
37
+ }
38
+ function validId(id) {
39
+ if (!attachmentIdsSchema.safeParse([id]).success)
40
+ throw new Error('invalid_attachment');
41
+ }
42
+ async function readManifest(root, id) {
43
+ validId(id);
44
+ const stage = await safeDirectory(root, `${stagingPath}/${id}`);
45
+ const handle = await open(join(stage, 'manifest.json'), constants.O_RDONLY | constants.O_NOFOLLOW);
46
+ try {
47
+ const stat = await handle.stat();
48
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
49
+ throw new Error('invalid_attachment');
50
+ return workspaceDirectoryAttachmentUploadSchema.parse(JSON.parse(await handle.readFile('utf8')));
51
+ }
52
+ finally {
53
+ await handle.close();
54
+ }
55
+ }
56
+ export async function beginWorkspaceDirectoryAttachment(root, body) {
57
+ const manifest = workspaceDirectoryAttachmentUploadSchema.parse(body);
58
+ await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
59
+ const staging = await safeDirectory(root, stagingPath, true);
60
+ const id = randomUUID();
61
+ const stage = join(staging, id);
62
+ await mkdir(stage, { mode: 0o755 });
63
+ try {
64
+ await mkdir(join(stage, 'data'), { mode: 0o755 });
65
+ await mkdir(join(stage, 'data', manifest.name), { mode: 0o755 });
66
+ await writeFile(join(stage, 'manifest.json'), JSON.stringify(manifest), { flag: 'wx', mode: 0o644 });
67
+ return { uploadId: id };
68
+ }
69
+ catch (error) {
70
+ await rm(stage, { recursive: true, force: true });
71
+ throw error;
72
+ }
73
+ }
74
+ /** Each member is streamed and created exclusively; no completed member can be overwritten. */
75
+ export async function uploadWorkspaceDirectoryFile(root, id, path, request) {
76
+ return withUploadLock(root, id, async () => {
77
+ workspaceDirectoryAttachmentFilePathSchema.parse(path);
78
+ const manifest = await readManifest(root, id);
79
+ const member = manifest.files.find(file => file.path === path);
80
+ if (!member)
81
+ throw new Error('invalid_attachment');
82
+ const length = request.headers['content-length'];
83
+ if (length !== undefined && (!/^\d+$/.test(length) || Number(length) !== member.size))
84
+ throw new Error('invalid_attachment');
85
+ const relative = `${stagingPath}/${id}/data/${manifest.name}/${path}`;
86
+ await safeDirectory(root, dirname(relative), true);
87
+ const handle = await open(join(root, relative), constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | constants.O_NOFOLLOW, 0o644);
88
+ try {
89
+ let size = 0;
90
+ for await (const chunk of request) {
91
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
92
+ if (size + bytes.length > member.size)
93
+ throw new Error('invalid_attachment');
94
+ let offset = 0;
95
+ while (offset < bytes.length)
96
+ offset += (await handle.write(bytes, offset, bytes.length - offset, size + offset)).bytesWritten;
97
+ size += bytes.length;
98
+ }
99
+ if (size !== member.size)
100
+ throw new Error('invalid_attachment');
101
+ await handle.sync();
102
+ if (member.mimeType.startsWith('video/')) {
103
+ const media = await probeAttachmentVideo(handle);
104
+ if (media.mimeType !== member.mimeType && !(member.mimeType === 'video/quicktime' && media.mimeType === 'video/mp4'))
105
+ throw new Error('invalid_attachment');
106
+ }
107
+ return { path, size };
108
+ }
109
+ catch (error) {
110
+ await rm(join(root, relative), { force: true });
111
+ throw error;
112
+ }
113
+ finally {
114
+ await handle.close();
115
+ }
116
+ });
117
+ }
118
+ async function assertExactFiles(directory, expected, prefix = '') {
119
+ for (const name of await readdir(directory)) {
120
+ const relative = prefix ? `${prefix}/${name}` : name;
121
+ const path = join(directory, name);
122
+ const stat = await lstat(path);
123
+ if (stat.isDirectory())
124
+ await assertExactFiles(path, expected, relative);
125
+ else if (!stat.isFile() || stat.nlink !== 1 || !expected.delete(relative))
126
+ throw new Error('invalid_attachment');
127
+ }
128
+ if (!prefix && expected.size)
129
+ throw new Error('invalid_attachment');
130
+ }
131
+ async function directoryFiles(root, relative, manifest) {
132
+ const directory = await safeDirectory(root, relative);
133
+ await assertExactFiles(directory, new Set(manifest.files.map(file => file.path)));
134
+ const files = [];
135
+ for (const file of manifest.files) {
136
+ await safeDirectory(directory, dirname(file.path) === '.' ? '' : dirname(file.path));
137
+ const handle = await open(join(directory, file.path), constants.O_RDONLY | constants.O_NOFOLLOW);
138
+ try {
139
+ const stat = await handle.stat();
140
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size !== file.size)
141
+ throw new Error('invalid_attachment');
142
+ const media = file.mimeType.startsWith('video/') ? await probeAttachmentVideo(handle) : undefined;
143
+ if (media && media.mimeType !== file.mimeType && !(file.mimeType === 'video/quicktime' && media.mimeType === 'video/mp4'))
144
+ throw new Error('invalid_attachment');
145
+ const inferred = artifactMime(file.path);
146
+ files.push({ path: file.path, size: stat.size, sha256: await hashAttachment(handle),
147
+ mimeType: media?.mimeType ?? (inferred === 'application/octet-stream' ? file.mimeType : inferred) });
148
+ }
149
+ finally {
150
+ await handle.close();
151
+ }
152
+ }
153
+ return files;
154
+ }
155
+ export async function completeWorkspaceDirectoryAttachment(root, id) {
156
+ return withUploadLock(root, id, async () => {
157
+ const manifest = await readManifest(root, id);
158
+ const stage = await safeDirectory(root, `${stagingPath}/${id}`);
159
+ const files = await directoryFiles(root, `${stagingPath}/${id}/data/${manifest.name}`, manifest);
160
+ const metadata = await safeDirectory(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata`, true);
161
+ const uploads = await safeDirectory(root, WORKSPACE_USER_UPLOADS_DIRECTORY);
162
+ const attachment = { id, name: manifest.name, kind: 'directory',
163
+ size: files.reduce((size, file) => size + file.size, 0), mimeType: 'application/vnd.playcraft.directory',
164
+ path: `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${manifest.name}`, files };
165
+ workspaceAttachmentSchema.parse(attachment);
166
+ const target = join(uploads, id);
167
+ await mkdir(target, { mode: 0o755 });
168
+ try {
169
+ await rename(join(stage, 'data', manifest.name), join(target, manifest.name));
170
+ await writeFile(join(metadata, `${id}.json`), JSON.stringify(attachment), { flag: 'wx', mode: 0o644 });
171
+ }
172
+ catch (error) {
173
+ await rm(target, { recursive: true, force: true });
174
+ throw error;
175
+ }
176
+ await rm(stage, { recursive: true, force: true });
177
+ return attachment;
178
+ });
179
+ }
180
+ export async function cancelWorkspaceDirectoryAttachment(root, id) {
181
+ validId(id);
182
+ return withUploadLock(root, id, async () => {
183
+ const parent = await safeDirectory(root, stagingPath);
184
+ const stage = join(parent, id);
185
+ try {
186
+ if (!(await lstat(stage)).isDirectory())
187
+ throw new Error('invalid_attachment');
188
+ }
189
+ catch (error) {
190
+ if (error.code !== 'ENOENT')
191
+ throw error;
192
+ }
193
+ await rm(stage, { recursive: true, force: true });
194
+ return { uploadId: id, cancelled: true };
195
+ });
196
+ }
197
+ export async function readWorkspaceDirectoryMetadata(root, id) {
198
+ validId(id);
199
+ const metadata = await safeDirectory(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata`);
200
+ const handle = await open(join(metadata, `${id}.json`), constants.O_RDONLY | constants.O_NOFOLLOW);
201
+ try {
202
+ const stat = await handle.stat();
203
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
204
+ throw new Error('invalid_attachment');
205
+ const attachment = workspaceAttachmentSchema.parse(JSON.parse(await handle.readFile('utf8')));
206
+ if (attachment.id !== id || attachment.kind !== 'directory' || attachment.origin || !attachment.files ||
207
+ attachment.path !== `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}/${attachment.name}` || attachment.mimeType !== 'application/vnd.playcraft.directory')
208
+ throw new Error('invalid_attachment');
209
+ workspaceDirectoryAttachmentUploadSchema.parse({ name: attachment.name, files: attachment.files.map(({ sha256: _digest, ...file }) => file) });
210
+ if (attachment.size !== attachment.files.reduce((size, file) => size + file.size, 0) || attachment.size > WORKSPACE_DIRECTORY_ATTACHMENT_MAX_BYTES)
211
+ throw new Error('invalid_attachment');
212
+ return attachment;
213
+ }
214
+ finally {
215
+ await handle.close();
216
+ }
217
+ }
218
+ export async function openWorkspaceDirectoryFile(root, attachment, path) {
219
+ workspaceDirectoryAttachmentFilePathSchema.parse(path);
220
+ const file = attachment.files?.find(file => file.path === path);
221
+ if (!file || !attachment.path)
222
+ throw new Error('attachment_unavailable');
223
+ const directory = await safeDirectory(root, attachment.path);
224
+ if (dirname(file.path) !== '.')
225
+ await safeDirectory(directory, dirname(file.path));
226
+ const handle = await open(join(directory, file.path), constants.O_RDONLY | constants.O_NOFOLLOW);
227
+ try {
228
+ const stat = await handle.stat();
229
+ const limit = file.mimeType.startsWith('video/') ? WORKSPACE_VIDEO_ATTACHMENT_MAX_BYTES : WORKSPACE_ATTACHMENT_MAX_BYTES;
230
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size !== file.size || stat.size > limit || await hashAttachment(handle) !== file.sha256)
231
+ throw new Error('attachment_unavailable');
232
+ return { handle, name: file.path.split('/').at(-1), mimeType: file.mimeType, size: file.size };
233
+ }
234
+ catch (error) {
235
+ await handle.close();
236
+ throw error;
237
+ }
238
+ }
239
+ export async function resolveWorkspaceDirectoryAttachment(root, id) {
240
+ const attachment = await readWorkspaceDirectoryMetadata(root, id);
241
+ const files = await directoryFiles(root, attachment.path, { name: attachment.name, files: attachment.files.map(({ sha256: _digest, ...file }) => file) });
242
+ if (files.some((file, index) => file.sha256 !== attachment.files[index].sha256 || file.mimeType !== attachment.files[index].mimeType))
243
+ throw new Error('attachment_unavailable');
244
+ return { ...attachment, path: join(root, attachment.path) };
245
+ }
@@ -1,9 +1,10 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
2
3
  import { excludeFromGit } from './dependencies.js';
3
- import { lstat, readFile, realpath, stat, unlink } from 'node:fs/promises';
4
+ import { copyFile, lstat, readFile, realpath, rm, stat, unlink } from 'node:fs/promises';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { basename, dirname, join, relative, resolve, sep } from 'node:path';
6
- import { WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
+ import { TURN_CHANGED_FILES_MAX, WORKSPACE_FILE_CONTENT_MAX, gitDiffSchema, gitFileContentSchema, gitGraphSchema, gitStatusSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
8
  const GIT_DIFF_MAX_BYTES = 1024 * 1024;
8
9
  const GIT_PREFIX = [
9
10
  '-c',
@@ -63,14 +64,74 @@ export async function readWorkspaceGitStatus(root) {
63
64
  })),
64
65
  });
65
66
  }
67
+ const SNAPSHOT_TIMEOUT_MS = 120_000;
66
68
  /**
67
- * One file as stored in the last commit or in the index. Reads the blob as is, without filters,
68
- * so the browser compares exactly what Git holds.
69
+ * Writes the work tree, as `git add -A` would stage it, to a tree object without touching the
70
+ * repository's own index or history. A copy of the index keeps its stat cache, so unchanged files
71
+ * are not hashed again. Ignored files and credential files are left out of the snapshot.
72
+ */
73
+ export async function snapshotWorkspaceTree(root) {
74
+ const rootReal = await existingWorkspaceGit(root);
75
+ const env = { GIT_INDEX_FILE: join(rootReal, '.git', `playcraft-snapshot-${randomUUID()}.index`) };
76
+ const run = async (args) => {
77
+ const result = await runGitProcess(rootReal, [...GIT_PREFIX, `--git-dir=${join(rootReal, '.git')}`,
78
+ `--work-tree=${rootReal}`, ...args], SNAPSHOT_TIMEOUT_MS, env);
79
+ if (result.code !== 0)
80
+ throw new GitWorkspaceError('git_command_failed', result.stderr.trim() || `git_exit_${result.code}`);
81
+ return result.stdout;
82
+ };
83
+ try {
84
+ await copyFile(join(rootReal, '.git', 'index'), env.GIT_INDEX_FILE).catch((error) => {
85
+ if (error.code !== 'ENOENT')
86
+ throw error;
87
+ });
88
+ await run(['add', '-A']);
89
+ const credentials = (await run(['ls-files', '-z'])).split('\0').filter((path) => path && isCredentialFile(path));
90
+ if (credentials.length)
91
+ await run(['rm', '--cached', '--quiet', '--', ...credentials]);
92
+ return (await run(['write-tree'])).trim();
93
+ }
94
+ finally {
95
+ await rm(env.GIT_INDEX_FILE, { force: true });
96
+ }
97
+ }
98
+ /** Keeps a turn's snapshots from being pruned, so its changes stay readable after Git cleans up. */
99
+ export async function retainTurnSnapshots(root, turnId, before, after) {
100
+ const rootReal = await existingWorkspaceGit(root);
101
+ const name = turnId.replace(/[^A-Za-z0-9_-]/g, '_');
102
+ await git(rootReal, ['update-ref', `refs/playcraft/turns/${name}/before`, before]);
103
+ await git(rootReal, ['update-ref', `refs/playcraft/turns/${name}/after`, after]);
104
+ }
105
+ /** The files that differ between two snapshot trees, with line counts for text files. */
106
+ export async function diffWorkspaceTrees(root, before, after) {
107
+ const rootReal = await existingWorkspaceGit(root);
108
+ const diff = (format) => git(rootReal, ['diff-tree', '-r', '-z', '--no-renames', format, before, after], 60000);
109
+ const [statuses, counts] = await Promise.all([diff('--name-status'), diff('--numstat')]);
110
+ const lines = new Map();
111
+ for (const entry of counts.split('\0')) {
112
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/s.exec(entry);
113
+ if (!match)
114
+ continue;
115
+ const binary = match[1] === '-';
116
+ lines.set(match[3], { added: binary ? 0 : Number(match[1]), removed: binary ? 0 : Number(match[2]), binary });
117
+ }
118
+ const parts = statuses.split('\0');
119
+ const files = [];
120
+ for (let index = 0; index + 1 < parts.length; index += 2) {
121
+ const status = parts[index] === 'A' ? 'added' : parts[index] === 'D' ? 'deleted' : 'modified';
122
+ const path = parts[index + 1];
123
+ files.push({ path, status, ...(lines.get(path) ?? { added: 0, removed: 0, binary: false }) });
124
+ }
125
+ return { files: files.slice(0, TURN_CHANGED_FILES_MAX), truncated: files.length > TURN_CHANGED_FILES_MAX };
126
+ }
127
+ /**
128
+ * One file as stored in the last commit, in the index or in a turn snapshot. Reads the blob as is,
129
+ * without filters, so the browser compares exactly what Git holds.
69
130
  */
70
131
  export async function readWorkspaceGitFile(root, path, ref) {
71
132
  const rootReal = await ensureWorkspaceGit(root);
72
133
  assertWorkspacePaths([path]);
73
- const object = ref === 'HEAD' ? `HEAD:${path}` : `:${path}`;
134
+ const object = ref === 'INDEX' ? `:${path}` : `${ref}:${path}`;
74
135
  const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
75
136
  const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
76
137
  if (size.code !== 0)
@@ -382,6 +443,14 @@ export async function ensureWorkspaceGit(root) {
382
443
  await excludeFromGit(rootReal, '/.tmp/user_uploads/');
383
444
  return rootReal;
384
445
  }
446
+ /** Turn snapshots never create a repository; a directory without one simply has no turn changes. */
447
+ async function existingWorkspaceGit(root) {
448
+ const rootReal = await realpath(root);
449
+ if (!(await gitMarkerExists(rootReal)))
450
+ throw new GitWorkspaceError('git_command_failed', 'not_a_repository');
451
+ await assertWorkspaceGitRoot(rootReal);
452
+ return rootReal;
453
+ }
385
454
  async function gitMarkerExists(rootReal) {
386
455
  try {
387
456
  await stat(join(rootReal, '.git'));
@@ -481,9 +550,9 @@ function gitEnv() {
481
550
  env.GIT_TERMINAL_PROMPT = '0';
482
551
  return env;
483
552
  }
484
- function runGitProcess(cwd, args, timeoutMs) {
553
+ function runGitProcess(cwd, args, timeoutMs, env) {
485
554
  return new Promise((resolve, reject) => {
486
- const child = spawn('git', args, { cwd, env: gitEnv(), windowsHide: true });
555
+ const child = spawn('git', args, { cwd, env: { ...gitEnv(), ...env }, windowsHide: true });
487
556
  // Chunks are decoded together; a character split across two chunks would otherwise be corrupted.
488
557
  const stdout = [];
489
558
  const stderr = [];
@@ -0,0 +1,56 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { resolveCliPackageRoot } from '../../scaffold/compose.js';
6
+ const START = '<!-- playcraft:workspace-artifacts:start -->';
7
+ const END = '<!-- playcraft:workspace-artifacts:end -->';
8
+ /** Refresh only the platform-owned section; project and user instructions remain intact. */
9
+ export async function ensureWorkspaceArtifactInstructions(root) {
10
+ const template = await readFile(join(resolveCliPackageRoot(), 'project-templates/empty/AGENTS.md'), 'utf8');
11
+ const start = template.indexOf(START), end = template.indexOf(END, start);
12
+ if (start < 0 || end < start)
13
+ throw new Error('workspace_instructions_invalid');
14
+ const section = template.slice(start, end + END.length);
15
+ const path = join(root, 'AGENTS.md');
16
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK).catch(error => {
17
+ if (error.code === 'ENOENT')
18
+ return undefined;
19
+ throw error;
20
+ });
21
+ let previous = '', mode = 0o664;
22
+ try {
23
+ if (handle) {
24
+ const stat = await handle.stat();
25
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > 1024 * 1024)
26
+ throw new Error('workspace_instructions_invalid');
27
+ mode = stat.mode & 0o777;
28
+ previous = await handle.readFile('utf8');
29
+ }
30
+ }
31
+ finally {
32
+ await handle?.close();
33
+ }
34
+ const existingStart = previous.indexOf(START), existingEnd = previous.indexOf(END);
35
+ if ((existingStart < 0) !== (existingEnd < 0) || existingEnd < existingStart ||
36
+ previous.indexOf(START, existingStart + START.length) >= 0 || previous.indexOf(END, existingEnd + END.length) >= 0)
37
+ throw new Error('workspace_instructions_invalid');
38
+ const next = existingStart >= 0
39
+ ? previous.slice(0, existingStart) + section + previous.slice(existingEnd + END.length)
40
+ : `${previous}${previous ? (previous.endsWith('\n') ? '\n' : '\n\n') : '# PlayCraft Workspace\n\n'}${section}\n`;
41
+ if (next === previous)
42
+ return;
43
+ const temporary = join(root, `.AGENTS.${randomUUID()}.tmp`);
44
+ try {
45
+ await writeFile(temporary, next, { flag: 'wx', mode });
46
+ await chmod(temporary, mode);
47
+ const current = await lstat(path).catch(error => { if (error.code === 'ENOENT')
48
+ return undefined; throw error; });
49
+ if (current && (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1))
50
+ throw new Error('workspace_instructions_invalid');
51
+ await rename(temporary, path);
52
+ }
53
+ finally {
54
+ await rm(temporary, { force: true });
55
+ }
56
+ }