@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.
- 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 +65 -7
- 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 +148 -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
|
@@ -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 >
|
|
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 >
|
|
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
|
|
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
|
-
*
|
|
68
|
-
*
|
|
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
|
|
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
|
+
}
|