@playcraft/cli 0.1.31 → 0.1.33

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 (27) 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/storage.js +36 -8
  9. package/dist/workspace-runtime/server/dispatch.js +65 -7
  10. package/dist/workspace-runtime/server/http.js +91 -2
  11. package/dist/workspace-runtime/server/workspace-transfer.js +99 -0
  12. package/dist/workspace-runtime/workspaces/archive.js +207 -0
  13. package/dist/workspace-runtime/workspaces/artifact-files.js +4 -2
  14. package/dist/workspace-runtime/workspaces/artifact-receipts.js +3 -3
  15. package/dist/workspace-runtime/workspaces/artifacts.js +38 -16
  16. package/dist/workspace-runtime/workspaces/attachment-content.js +16 -4
  17. package/dist/workspace-runtime/workspaces/attachments.js +7 -1
  18. package/dist/workspace-runtime/workspaces/directories.js +8 -1
  19. package/dist/workspace-runtime/workspaces/directory-attachments.js +245 -0
  20. package/dist/workspace-runtime/workspaces/git.js +148 -7
  21. package/dist/workspace-runtime/workspaces/instructions.js +56 -0
  22. package/dist/workspace-runtime/workspaces/library-attachments.js +268 -0
  23. package/dist/workspace-runtime/workspaces/registry.js +2 -0
  24. package/dist/workspace-runtime/workspaces/transfer.js +288 -0
  25. package/dist/workspace-runtime/workspaces/turn-changes.js +72 -0
  26. package/package.json +3 -3
  27. 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,6 +69,7 @@ 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
74
  await state.journal.exclusive(`workspace:${directory.workspaceId}`, async () => {
73
75
  state.acceptedLeaseFence = Math.max(state.acceptedLeaseFence, leaseFence);
@@ -87,8 +89,12 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
87
89
  await state.journal.exclusive(`directory:${workspaceId}:${directoryName}`, async () => {
88
90
  const binding = state.journal.getWorkspace(workspaceId);
89
91
  const directory = binding?.directories?.find(item => item.directoryName === directoryName);
90
- if (!binding || directory?.status !== 'reclaimed')
92
+ if (!binding)
91
93
  return;
94
+ if (directory?.status !== 'reclaimed') {
95
+ await ensureWorkspaceArtifactInstructions(await workspaceRoot(state, workspaceId, directoryName));
96
+ return;
97
+ }
92
98
  if (binding.targetKind === 'standalone' || !state.gitCredentials)
93
99
  throw new WorkspaceContextError('workspace_recovery_required');
94
100
  const preparation = await state.gitCredentials.prepareDirectory(workspaceId, directoryName);
@@ -98,6 +104,7 @@ export async function restoreWorkspaceDirectory(state, workspaceId, directoryNam
98
104
  if (!await lstat(root).catch(() => undefined))
99
105
  await prepareWorkspaceCheckout(state, target, preparation, state.acceptedLeaseFence, directoryName, true);
100
106
  await ensureWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'));
107
+ await ensureWorkspaceArtifactInstructions(root);
101
108
  await openDirectoryToDevelopment(root, (await lstat(state.config.workspaceRoot)).gid);
102
109
  await state.journal.putWorkspace({ ...binding, directories: binding.directories?.map(item => item.directoryName === directoryName ? { ...item, status: 'ready' } : item) });
103
110
  try {
@@ -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',
@@ -43,6 +44,7 @@ export async function readWorkspaceGitStatus(root) {
43
44
  ahead = Number.parseInt(right ?? '0', 10) || 0;
44
45
  }
45
46
  const porcelain = await git(rootReal, ['status', '--porcelain=v1', '--untracked-files=all', '-z']);
47
+ const merge = await mergeState(rootReal);
46
48
  // Line counts are a convenience; a repository without commits still lists its files.
47
49
  const [staged, unstaged] = await Promise.all([
48
50
  gitAllowFail(rootReal, ['diff', '--cached', '--numstat', '-z']),
@@ -56,6 +58,7 @@ export async function readWorkspaceGitStatus(root) {
56
58
  ahead,
57
59
  behind,
58
60
  hasUpstream: upstream.code === 0,
61
+ ...merge,
59
62
  files: parsePorcelain(porcelain).map((entry) => ({
60
63
  ...entry,
61
64
  ...(stagedCounts.has(entry.path) ? { staged: stagedCounts.get(entry.path) } : {}),
@@ -63,14 +66,75 @@ export async function readWorkspaceGitStatus(root) {
63
66
  })),
64
67
  });
65
68
  }
69
+ const SNAPSHOT_TIMEOUT_MS = 120_000;
66
70
  /**
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.
71
+ * Writes the work tree, as `git add -A` would stage it, to a tree object without touching the
72
+ * repository's own index or history. A copy of the index keeps its stat cache, so unchanged files
73
+ * are not hashed again. Ignored files and credential files are left out of the snapshot.
74
+ */
75
+ export async function snapshotWorkspaceTree(root) {
76
+ const rootReal = await existingWorkspaceGit(root);
77
+ const env = { GIT_INDEX_FILE: join(rootReal, '.git', `playcraft-snapshot-${randomUUID()}.index`) };
78
+ const run = async (args) => {
79
+ const result = await runGitProcess(rootReal, [...GIT_PREFIX, `--git-dir=${join(rootReal, '.git')}`,
80
+ `--work-tree=${rootReal}`, ...args], SNAPSHOT_TIMEOUT_MS, env);
81
+ if (result.code !== 0)
82
+ throw new GitWorkspaceError('git_command_failed', result.stderr.trim() || `git_exit_${result.code}`);
83
+ return result.stdout;
84
+ };
85
+ try {
86
+ await copyFile(join(rootReal, '.git', 'index'), env.GIT_INDEX_FILE).catch((error) => {
87
+ if (error.code !== 'ENOENT')
88
+ throw error;
89
+ });
90
+ await run(['add', '-A']);
91
+ const credentials = (await run(['ls-files', '-z'])).split('\0').filter((path) => path && isCredentialFile(path));
92
+ if (credentials.length)
93
+ await run(['rm', '--cached', '--quiet', '--', ...credentials]);
94
+ return (await run(['write-tree'])).trim();
95
+ }
96
+ finally {
97
+ await rm(env.GIT_INDEX_FILE, { force: true });
98
+ }
99
+ }
100
+ /** Keeps a turn's snapshots from being pruned, so its changes stay readable after Git cleans up. */
101
+ export async function retainTurnSnapshots(root, turnId, before, after) {
102
+ const rootReal = await existingWorkspaceGit(root);
103
+ const name = turnId.replace(/[^A-Za-z0-9_-]/g, '_');
104
+ await git(rootReal, ['update-ref', `refs/playcraft/turns/${name}/before`, before]);
105
+ await git(rootReal, ['update-ref', `refs/playcraft/turns/${name}/after`, after]);
106
+ }
107
+ /** The files that differ between two snapshot trees, with line counts for text files. */
108
+ export async function diffWorkspaceTrees(root, before, after) {
109
+ const rootReal = await existingWorkspaceGit(root);
110
+ const diff = (format) => git(rootReal, ['diff-tree', '-r', '-z', '--no-renames', format, before, after], 60000);
111
+ const [statuses, counts] = await Promise.all([diff('--name-status'), diff('--numstat')]);
112
+ const lines = new Map();
113
+ for (const entry of counts.split('\0')) {
114
+ const match = /^(\d+|-)\t(\d+|-)\t(.+)$/s.exec(entry);
115
+ if (!match)
116
+ continue;
117
+ const binary = match[1] === '-';
118
+ lines.set(match[3], { added: binary ? 0 : Number(match[1]), removed: binary ? 0 : Number(match[2]), binary });
119
+ }
120
+ const parts = statuses.split('\0');
121
+ const files = [];
122
+ for (let index = 0; index + 1 < parts.length; index += 2) {
123
+ const status = parts[index] === 'A' ? 'added' : parts[index] === 'D' ? 'deleted' : 'modified';
124
+ const path = parts[index + 1];
125
+ files.push({ path, status, ...(lines.get(path) ?? { added: 0, removed: 0, binary: false }) });
126
+ }
127
+ return { files: files.slice(0, TURN_CHANGED_FILES_MAX), truncated: files.length > TURN_CHANGED_FILES_MAX };
128
+ }
129
+ /**
130
+ * One file as stored in the last commit, in the index or in a turn snapshot. Reads the blob as is,
131
+ * without filters, so the browser compares exactly what Git holds.
69
132
  */
70
133
  export async function readWorkspaceGitFile(root, path, ref) {
71
134
  const rootReal = await ensureWorkspaceGit(root);
72
135
  assertWorkspacePaths([path]);
73
- const object = ref === 'HEAD' ? `HEAD:${path}` : `:${path}`;
136
+ const stage = { INDEX: 0, MERGE_BASE: 1, MERGE_OURS: 2, MERGE_THEIRS: 3 }[ref];
137
+ const object = stage === undefined ? `${ref}:${path}` : `:${stage}:${path}`;
74
138
  const absent = gitFileContentSchema.parse({ path, ref, exists: false, binary: false, content: '' });
75
139
  const size = await gitAllowFail(rootReal, ['cat-file', '-s', object]);
76
140
  if (size.code !== 0)
@@ -210,6 +274,9 @@ function pushRejection(stdout) {
210
274
  export async function commitWorkspaceGit(root, payload, boundBranch) {
211
275
  const rootReal = await ensureWorkspaceGit(root);
212
276
  await assertBoundBranch(rootReal, boundBranch);
277
+ // Adding a conflicted file would mark it resolved with its conflict markers in it.
278
+ if ((await unmergedPaths(rootReal)).length)
279
+ throw new GitWorkspaceError('git_conflicts_unresolved', 'unmerged');
213
280
  if (payload.stagedOnly) {
214
281
  // The index is the commit; nothing is added.
215
282
  }
@@ -273,6 +340,10 @@ export function isCredentialFile(path) {
273
340
  export async function saveWorkspaceGit(root, input) {
274
341
  const rootReal = await ensureWorkspaceGit(root);
275
342
  await assertBoundBranch(rootReal, input.boundBranch);
343
+ // A save during a merge would stage conflict markers as resolutions and conclude the merge. The
344
+ // user (or an agent) finishes the merge with an explicit commit; saves resume after it.
345
+ if ((await mergeState(rootReal)).merging)
346
+ return { pushed: false, withheld: [] };
276
347
  await git(rootReal, ['add', '-A'], 60000);
277
348
  const staged = (await git(rootReal, ['diff', '--cached', '--name-only', '-z'], 60000)).split('\0').filter(Boolean);
278
349
  const withheld = staged.filter(isCredentialFile);
@@ -292,6 +363,68 @@ export async function saveWorkspaceGit(root, input) {
292
363
  await recordPushedHead(rootReal);
293
364
  return { ...(commit ? { commit } : {}), pushed: true, withheld };
294
365
  }
366
+ /**
367
+ * `git pull` with a merge, as VS Code runs it: fetch the bound branch from origin, then merge it.
368
+ * Uncommitted work is kept unless the incoming commits change the same files, in which case Git
369
+ * refuses before touching anything. Conflicts leave the merge in progress for the user to resolve.
370
+ */
371
+ export async function pullWorkspaceGit(root, boundBranch) {
372
+ const rootReal = await ensureWorkspaceGit(root);
373
+ await assertBoundBranch(rootReal, boundBranch);
374
+ if ((await mergeState(rootReal)).merging)
375
+ throw new GitWorkspaceError('git_merge_in_progress', 'merge_in_progress');
376
+ if ((await gitAllowFail(rootReal, ['remote', 'get-url', 'origin'])).code !== 0) {
377
+ throw new GitWorkspaceError('remote_not_configured', 'origin');
378
+ }
379
+ const branch = (await gitAllowFail(rootReal, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout.trim();
380
+ const born = (await gitAllowFail(rootReal, ['rev-parse', '-q', '--verify', 'HEAD^{commit}'])).code === 0;
381
+ if (!branch || !born)
382
+ throw new GitWorkspaceError('git_command_failed', 'git_branch_required');
383
+ // A fork clone has no fetch refspec for its own branch, so the tracking ref is named here.
384
+ const remoteRef = `refs/remotes/origin/${branch}`;
385
+ const fetched = await gitAllowFail(rootReal, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`], 90000);
386
+ if (fetched.code !== 0)
387
+ throw new GitWorkspaceError(httpRefusal(fetched.stderr) ?? 'git_fetch_failed', 'git_fetch_failed');
388
+ if ((await gitAllowFail(rootReal, ['merge-base', '--is-ancestor', remoteRef, 'HEAD'])).code === 0) {
389
+ return { outcome: 'up_to_date', conflicts: [] };
390
+ }
391
+ // The short name gives the merge commit the message `git pull` would write.
392
+ const merged = await gitAllowFail(rootReal, [...(await commitIdentity(rootReal)), 'merge', '--no-edit', `origin/${branch}`], 60000);
393
+ if (merged.code === 0)
394
+ return { outcome: 'merged', conflicts: [] };
395
+ const conflicts = await unmergedPaths(rootReal);
396
+ if (conflicts.length)
397
+ return { outcome: 'conflicts', conflicts };
398
+ if (/would be overwritten by merge|untracked working tree files would be (?:overwritten|removed)/i.test(`${merged.stdout}\n${merged.stderr}`)) {
399
+ throw new GitWorkspaceError('git_pull_local_changes', 'local_changes');
400
+ }
401
+ throw new GitWorkspaceError('git_command_failed', merged.stderr.trim() || 'git_merge_failed');
402
+ }
403
+ /** `git merge --abort`: the work tree and index return to how they were before the merge began. */
404
+ export async function abortWorkspaceMerge(root) {
405
+ const rootReal = await ensureWorkspaceGit(root);
406
+ if (!(await mergeState(rootReal)).merging)
407
+ throw new GitWorkspaceError('git_merge_not_in_progress', 'no_merge');
408
+ await git(rootReal, ['merge', '--abort'], 60000);
409
+ }
410
+ /** Paths with unmerged index entries, that is, conflicts not yet marked resolved. */
411
+ async function unmergedPaths(rootReal) {
412
+ const listed = await gitAllowFail(rootReal, ['diff', '--name-only', '--diff-filter=U', '-z']);
413
+ return listed.code === 0 ? listed.stdout.split('\0').filter(Boolean) : [];
414
+ }
415
+ async function mergeState(rootReal) {
416
+ try {
417
+ await stat(join(rootReal, '.git', 'MERGE_HEAD'));
418
+ }
419
+ catch (error) {
420
+ if (error.code === 'ENOENT')
421
+ return {};
422
+ throw error;
423
+ }
424
+ const message = await readFile(join(rootReal, '.git', 'MERGE_MSG'), 'utf8').catch(() => '');
425
+ const text = message.split('\n').filter((line) => !line.startsWith('#')).join('\n').trim().slice(0, 4096);
426
+ return { merging: true, ...(text ? { mergeMessage: text } : {}) };
427
+ }
295
428
  /**
296
429
  * Connects a standalone checkout to the repository it was just given, keeping all its history and
297
430
  * uncommitted work. The new repository holds one empty commit on `branch`; it becomes the base of an
@@ -382,6 +515,14 @@ export async function ensureWorkspaceGit(root) {
382
515
  await excludeFromGit(rootReal, '/.tmp/user_uploads/');
383
516
  return rootReal;
384
517
  }
518
+ /** Turn snapshots never create a repository; a directory without one simply has no turn changes. */
519
+ async function existingWorkspaceGit(root) {
520
+ const rootReal = await realpath(root);
521
+ if (!(await gitMarkerExists(rootReal)))
522
+ throw new GitWorkspaceError('git_command_failed', 'not_a_repository');
523
+ await assertWorkspaceGitRoot(rootReal);
524
+ return rootReal;
525
+ }
385
526
  async function gitMarkerExists(rootReal) {
386
527
  try {
387
528
  await stat(join(rootReal, '.git'));
@@ -481,9 +622,9 @@ function gitEnv() {
481
622
  env.GIT_TERMINAL_PROMPT = '0';
482
623
  return env;
483
624
  }
484
- function runGitProcess(cwd, args, timeoutMs) {
625
+ function runGitProcess(cwd, args, timeoutMs, env) {
485
626
  return new Promise((resolve, reject) => {
486
- const child = spawn('git', args, { cwd, env: gitEnv(), windowsHide: true });
627
+ const child = spawn('git', args, { cwd, env: { ...gitEnv(), ...env }, windowsHide: true });
487
628
  // Chunks are decoded together; a character split across two chunks would otherwise be corrupted.
488
629
  const stdout = [];
489
630
  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
+ }