@playcraft/cli 0.1.31 → 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.
- package/README.md +18 -0
- package/dist/commands/workspace.js +35 -0
- package/dist/index.js +2 -0
- package/dist/workspace/messages.js +35 -0
- package/dist/workspace/transfer-client.js +174 -0
- package/dist/workspace-runtime/previews/output.js +36 -0
- package/dist/workspace-runtime/previews/publish.js +4 -1
- package/dist/workspace-runtime/production/storage.js +36 -8
- package/dist/workspace-runtime/server/dispatch.js +22 -5
- package/dist/workspace-runtime/server/http.js +91 -2
- package/dist/workspace-runtime/server/workspace-transfer.js +99 -0
- package/dist/workspace-runtime/workspaces/archive.js +207 -0
- package/dist/workspace-runtime/workspaces/artifact-files.js +4 -2
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +3 -3
- package/dist/workspace-runtime/workspaces/artifacts.js +38 -16
- package/dist/workspace-runtime/workspaces/attachment-content.js +16 -4
- package/dist/workspace-runtime/workspaces/attachments.js +7 -1
- package/dist/workspace-runtime/workspaces/directories.js +8 -1
- package/dist/workspace-runtime/workspaces/directory-attachments.js +245 -0
- package/dist/workspace-runtime/workspaces/git.js +76 -7
- package/dist/workspace-runtime/workspaces/instructions.js +56 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +268 -0
- package/dist/workspace-runtime/workspaces/registry.js +2 -0
- package/dist/workspace-runtime/workspaces/transfer.js +288 -0
- package/dist/workspace-runtime/workspaces/turn-changes.js +72 -0
- package/package.json +3 -3
- package/project-templates/empty/AGENTS.md +14 -0
|
@@ -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
|
-
*
|
|
68
|
-
*
|
|
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 === '
|
|
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
|
+
}
|