@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
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, open, readdir, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { WORKSPACE_USER_UPLOADS_DIRECTORY, WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES, WORKSPACE_ATTACHMENT_MAX_COUNT, workspaceAttachmentSchema, workspaceLibraryAttachmentUploadSchema, workspaceLibraryFilePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { excludeFromGit } from './dependencies.js';
|
|
7
|
+
const METADATA_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
const locks = new Map();
|
|
9
|
+
const hash = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
async function safeDirectory(root, path, create = false) {
|
|
11
|
+
let directory = root;
|
|
12
|
+
for (const part of path.split('/').filter(Boolean)) {
|
|
13
|
+
directory = join(directory, part);
|
|
14
|
+
if (create)
|
|
15
|
+
await mkdir(directory, { mode: 0o755 }).catch((error) => {
|
|
16
|
+
if (error.code !== 'EEXIST')
|
|
17
|
+
throw error;
|
|
18
|
+
});
|
|
19
|
+
const stat = await lstat(directory);
|
|
20
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
21
|
+
throw new Error('invalid_library_attachment');
|
|
22
|
+
}
|
|
23
|
+
return directory;
|
|
24
|
+
}
|
|
25
|
+
async function metadataDirectory(root, create = false) {
|
|
26
|
+
return safeDirectory(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/.metadata`, create);
|
|
27
|
+
}
|
|
28
|
+
export async function readLibraryAttachmentMetadata(root, id) {
|
|
29
|
+
if (!/^[a-f0-9-]{36}$/.test(id))
|
|
30
|
+
throw new Error('invalid_library_attachment');
|
|
31
|
+
const directory = await metadataDirectory(root);
|
|
32
|
+
const handle = await open(join(directory, `${id}.json`), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
33
|
+
try {
|
|
34
|
+
const stat = await handle.stat();
|
|
35
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size > METADATA_MAX_BYTES)
|
|
36
|
+
throw new Error('invalid_library_attachment');
|
|
37
|
+
const raw = JSON.parse(await handle.readFile('utf8'));
|
|
38
|
+
const { sessionIds, unbound, clientRequestIds = [], ...publicFields } = raw;
|
|
39
|
+
const attachment = workspaceAttachmentSchema.parse(publicFields);
|
|
40
|
+
if (attachment.id !== id || !attachment.origin || !['file', 'bundle'].includes(attachment.kind ?? '') || !attachment.files?.length)
|
|
41
|
+
throw new Error('invalid_library_attachment');
|
|
42
|
+
const prefix = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}`;
|
|
43
|
+
const expected = attachment.kind === 'bundle' ? prefix : `${prefix}/${attachment.files[0].path}`;
|
|
44
|
+
if (attachment.path !== expected || (attachment.kind === 'file' && attachment.files.length !== 1))
|
|
45
|
+
throw new Error('invalid_library_attachment');
|
|
46
|
+
if (!Array.isArray(sessionIds) || sessionIds.some(session => typeof session !== 'string' || session.length > 128))
|
|
47
|
+
throw new Error('invalid_library_attachment');
|
|
48
|
+
if (!Array.isArray(clientRequestIds) || clientRequestIds.some(id => typeof id !== 'string' || !/^[a-f0-9-]{36}$/.test(id)))
|
|
49
|
+
throw new Error('invalid_library_attachment');
|
|
50
|
+
return { ...attachment, sessionIds: sessionIds, unbound: unbound === true, clientRequestIds: clientRequestIds };
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
await handle.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function publicAttachment({ sessionIds: _sessionIds, unbound: _unbound, clientRequestIds: _clientRequestIds, ...attachment }) {
|
|
57
|
+
return attachment;
|
|
58
|
+
}
|
|
59
|
+
export async function openLibraryAttachmentFile(root, attachment, filePath) {
|
|
60
|
+
const selected = filePath ?? (attachment.kind === 'file' ? attachment.files?.[0]?.path : undefined);
|
|
61
|
+
const file = attachment.files?.find(file => file.path === selected);
|
|
62
|
+
if (!file || !workspaceLibraryFilePathSchema.safeParse(selected).success)
|
|
63
|
+
throw new Error('attachment_unavailable');
|
|
64
|
+
const relative = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${attachment.id}/${file.path}`;
|
|
65
|
+
await safeDirectory(root, dirname(relative));
|
|
66
|
+
const handle = await open(join(root, relative), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
67
|
+
try {
|
|
68
|
+
const stat = await handle.stat();
|
|
69
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.size !== file.size || stat.size > WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES)
|
|
70
|
+
throw new Error('attachment_unavailable');
|
|
71
|
+
return { handle, name: file.path.split('/').at(-1), mimeType: file.mimeType, size: file.size, sha256: file.sha256 };
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
await handle.close();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function resolveLibraryAttachment(root, id) {
|
|
79
|
+
const record = await readLibraryAttachmentMetadata(root, id);
|
|
80
|
+
let total = 0;
|
|
81
|
+
for (const file of record.files) {
|
|
82
|
+
const opened = await openLibraryAttachmentFile(root, record, file.path);
|
|
83
|
+
try {
|
|
84
|
+
if (hash(await opened.handle.readFile()) !== file.sha256)
|
|
85
|
+
throw new Error('library_asset_changed');
|
|
86
|
+
total += file.size;
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
await opened.handle.close();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (total !== record.size || total > WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES)
|
|
93
|
+
throw new Error('invalid_library_attachment');
|
|
94
|
+
return { ...publicAttachment(record), path: join(root, record.path) };
|
|
95
|
+
}
|
|
96
|
+
async function listLibraryRecords(root, sessionId) {
|
|
97
|
+
let directory;
|
|
98
|
+
try {
|
|
99
|
+
directory = await metadataDirectory(root);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (error.code === 'ENOENT')
|
|
103
|
+
return [];
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const attachments = [];
|
|
107
|
+
for (const name of await readdir(directory)) {
|
|
108
|
+
if (!/^[a-f0-9-]{36}\.json$/.test(name))
|
|
109
|
+
continue;
|
|
110
|
+
try {
|
|
111
|
+
const record = await readLibraryAttachmentMetadata(root, name.slice(0, -5));
|
|
112
|
+
if (record.unbound || (sessionId && record.sessionIds.includes(sessionId)))
|
|
113
|
+
attachments.push(record);
|
|
114
|
+
}
|
|
115
|
+
catch { /* Legacy upload metadata and incomplete records are not library sources. */ }
|
|
116
|
+
}
|
|
117
|
+
return attachments.sort((a, b) => b.origin.importedAt.localeCompare(a.origin.importedAt));
|
|
118
|
+
}
|
|
119
|
+
export async function listWorkspaceLibraryAttachments(root, sessionId) {
|
|
120
|
+
return (await listLibraryRecords(root, sessionId)).map(publicAttachment);
|
|
121
|
+
}
|
|
122
|
+
export async function listWorkspaceLibraryImportReceipts(root, sessionId) {
|
|
123
|
+
const receipts = new Map();
|
|
124
|
+
for (const record of await listLibraryRecords(root, sessionId)) {
|
|
125
|
+
for (const id of record.clientRequestIds)
|
|
126
|
+
receipts.set(id, [...(receipts.get(id) ?? []), record.id]);
|
|
127
|
+
}
|
|
128
|
+
return [...receipts].map(([clientRequestId, attachmentIds]) => ({ clientRequestId, attachmentIds }));
|
|
129
|
+
}
|
|
130
|
+
function attachmentId(asset) {
|
|
131
|
+
const hex = hash(JSON.stringify([asset.assetId, asset.contentHash])).slice(0, 32).split('');
|
|
132
|
+
hex[12] = '5';
|
|
133
|
+
hex[16] = ['8', '9', 'a', 'b'][parseInt(hex[16], 16) % 4];
|
|
134
|
+
const value = hex.join('');
|
|
135
|
+
return `${value.slice(0, 8)}-${value.slice(8, 12)}-${value.slice(12, 16)}-${value.slice(16, 20)}-${value.slice(20)}`;
|
|
136
|
+
}
|
|
137
|
+
async function importAttachment(root, body) {
|
|
138
|
+
const { asset, sessionId, clientRequestId } = workspaceLibraryAttachmentUploadSchema.parse(body);
|
|
139
|
+
const previousImports = (await listLibraryRecords(root, sessionId)).filter(record => record.clientRequestIds.includes(clientRequestId));
|
|
140
|
+
const replay = previousImports.find(record => record.origin.assetId === asset.assetId);
|
|
141
|
+
if (replay) {
|
|
142
|
+
await resolveLibraryAttachment(root, replay.id);
|
|
143
|
+
return publicAttachment(replay);
|
|
144
|
+
}
|
|
145
|
+
if (previousImports.length >= WORKSPACE_ATTACHMENT_MAX_COUNT)
|
|
146
|
+
throw new Error('library_attachment_limit');
|
|
147
|
+
const files = asset.files.map(file => {
|
|
148
|
+
const bytes = Buffer.from(file.content, 'base64');
|
|
149
|
+
if (bytes.toString('base64') !== file.content || hash(bytes) !== file.sha256)
|
|
150
|
+
throw new Error('library_asset_changed');
|
|
151
|
+
return { ...file, bytes };
|
|
152
|
+
});
|
|
153
|
+
const size = files.reduce((sum, file) => sum + file.bytes.length, 0);
|
|
154
|
+
if (size > WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES)
|
|
155
|
+
throw new Error('library_asset_too_large');
|
|
156
|
+
const contentHash = hash(JSON.stringify(files.map(file => [file.path, file.sha256]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)));
|
|
157
|
+
if (asset.contentHash !== contentHash)
|
|
158
|
+
throw new Error('library_asset_changed');
|
|
159
|
+
const id = attachmentId(asset);
|
|
160
|
+
await excludeFromGit(root, `/${WORKSPACE_USER_UPLOADS_DIRECTORY}/`);
|
|
161
|
+
const uploads = await safeDirectory(root, WORKSPACE_USER_UPLOADS_DIRECTORY, true);
|
|
162
|
+
const metadata = await metadataDirectory(root, true);
|
|
163
|
+
let record;
|
|
164
|
+
try {
|
|
165
|
+
record = await readLibraryAttachmentMetadata(root, id);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
if (error.code !== 'ENOENT')
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
if (record)
|
|
172
|
+
await resolveLibraryAttachment(root, id);
|
|
173
|
+
if (!record) {
|
|
174
|
+
const temporary = join(uploads, `.import-${randomUUID()}`);
|
|
175
|
+
const target = join(uploads, id);
|
|
176
|
+
await mkdir(temporary, { mode: 0o755 });
|
|
177
|
+
try {
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const parent = dirname(file.path);
|
|
180
|
+
if (parent !== '.')
|
|
181
|
+
await safeDirectory(temporary, parent, true);
|
|
182
|
+
await writeFile(join(temporary, file.path), file.bytes, { flag: 'wx', mode: 0o644 });
|
|
183
|
+
}
|
|
184
|
+
const prefix = `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}`;
|
|
185
|
+
record = {
|
|
186
|
+
id, name: asset.kind === 'file' ? files[0].path.split('/').at(-1) : asset.name,
|
|
187
|
+
kind: asset.kind, size,
|
|
188
|
+
mimeType: asset.kind === 'file' ? files[0].mimeType : 'application/vnd.playcraft.asset-bundle',
|
|
189
|
+
path: asset.kind === 'file' ? `${prefix}/${files[0].path}` : prefix,
|
|
190
|
+
origin: { kind: 'asset-library', assetId: asset.assetId, name: asset.name,
|
|
191
|
+
libraryPath: asset.libraryPath, visibility: asset.visibility, teamId: asset.teamId,
|
|
192
|
+
contentHash, importedAt: new Date().toISOString() },
|
|
193
|
+
files: files.map(file => ({ path: file.path, size: file.bytes.length, mimeType: file.mimeType, sha256: file.sha256 })),
|
|
194
|
+
sessionIds: [], unbound: !sessionId, clientRequestIds: [],
|
|
195
|
+
};
|
|
196
|
+
// Publish the complete directory before making the source visible through its metadata.
|
|
197
|
+
let alreadyPublished = false;
|
|
198
|
+
try {
|
|
199
|
+
await safeDirectory(root, `${WORKSPACE_USER_UPLOADS_DIRECTORY}/${id}`);
|
|
200
|
+
for (const file of record.files) {
|
|
201
|
+
const opened = await openLibraryAttachmentFile(root, record, file.path);
|
|
202
|
+
try {
|
|
203
|
+
if (hash(await opened.handle.readFile()) !== file.sha256)
|
|
204
|
+
throw new Error('library_asset_changed');
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
await opened.handle.close();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
alreadyPublished = true;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
if (error.code !== 'ENOENT')
|
|
214
|
+
throw error;
|
|
215
|
+
// A pre-existing incomplete directory must not be overwritten by a retry.
|
|
216
|
+
try {
|
|
217
|
+
await lstat(target);
|
|
218
|
+
throw new Error('invalid_library_attachment');
|
|
219
|
+
}
|
|
220
|
+
catch (targetError) {
|
|
221
|
+
if (targetError.code !== 'ENOENT')
|
|
222
|
+
throw targetError;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (alreadyPublished)
|
|
226
|
+
await rm(temporary, { recursive: true, force: true });
|
|
227
|
+
else
|
|
228
|
+
await rename(temporary, target);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
await rm(temporary, { recursive: true, force: true });
|
|
232
|
+
throw error;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
record.sessionIds = [...new Set([...record.sessionIds, ...(sessionId ? [sessionId] : [])])];
|
|
236
|
+
record.clientRequestIds = [...new Set([...record.clientRequestIds, clientRequestId])];
|
|
237
|
+
record.unbound ||= !sessionId;
|
|
238
|
+
const tempMetadata = join(metadata, `.import-${randomUUID()}.json`);
|
|
239
|
+
await writeFile(tempMetadata, JSON.stringify(record), { flag: 'wx', mode: 0o644 });
|
|
240
|
+
await rename(tempMetadata, join(metadata, `${id}.json`));
|
|
241
|
+
await chmod(join(uploads, id), 0o755);
|
|
242
|
+
return publicAttachment(record);
|
|
243
|
+
}
|
|
244
|
+
export async function importWorkspaceLibraryAttachment(root, body) {
|
|
245
|
+
const previous = locks.get(root) ?? Promise.resolve();
|
|
246
|
+
const current = previous.catch(() => undefined).then(() => importAttachment(root, body));
|
|
247
|
+
locks.set(root, current);
|
|
248
|
+
try {
|
|
249
|
+
return await current;
|
|
250
|
+
}
|
|
251
|
+
finally {
|
|
252
|
+
if (locks.get(root) === current)
|
|
253
|
+
locks.delete(root);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
export async function readLibraryAttachmentUpload(request) {
|
|
257
|
+
const chunks = [];
|
|
258
|
+
let size = 0;
|
|
259
|
+
const limit = 4 * Math.ceil(WORKSPACE_LIBRARY_ATTACHMENT_MAX_BYTES / 3) + 1024 * 1024;
|
|
260
|
+
for await (const chunk of request) {
|
|
261
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
262
|
+
size += bytes.length;
|
|
263
|
+
if (size > limit)
|
|
264
|
+
throw new Error('library_asset_too_large');
|
|
265
|
+
chunks.push(bytes);
|
|
266
|
+
}
|
|
267
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
268
|
+
}
|
|
@@ -10,6 +10,7 @@ import { ensureWorkspaceSkills, readWorkspaceSkills } from './skills.js';
|
|
|
10
10
|
import { loadWorkspaceSkillArtifact, workspacePlatformSkillAccess } from './skill-artifact-loader.js';
|
|
11
11
|
import { writeWorkspaceCliContext } from './cli-context.js';
|
|
12
12
|
import { attachWorkspaceRemote } from './git.js';
|
|
13
|
+
import { ensureWorkspaceArtifactInstructions } from './instructions.js';
|
|
13
14
|
export { WorkspaceContextError } from './context-error.js';
|
|
14
15
|
const exec = promisify(execFile);
|
|
15
16
|
export function revisionScope(state, workspaceId, directoryName = 'basic') {
|
|
@@ -222,6 +223,7 @@ export async function registerWorkspace(state, input, leaseFence, preparation, r
|
|
|
222
223
|
throw new WorkspaceContextError('workspace_skills_unavailable');
|
|
223
224
|
}
|
|
224
225
|
await writeWorkspaceCliContext(root, binding.workspaceId);
|
|
226
|
+
await ensureWorkspaceArtifactInstructions(root);
|
|
225
227
|
await state.journal.putWorkspace({ ...binding, directories });
|
|
226
228
|
try {
|
|
227
229
|
await readWorkspaceSkills(root, join(state.config.stateDir, 'skill-artifacts'), entry => loadWorkspaceSkillArtifact(state, binding.workspaceId, entry));
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { chmod, copyFile, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
7
|
+
import { isCredentialFile } from './git.js';
|
|
8
|
+
export const WORKSPACE_TRANSFER_MAX_BYTES = 256 * 1024 * 1024;
|
|
9
|
+
const MAX_FILES = 20_000;
|
|
10
|
+
const EXCLUDED = new Set(['.git', 'node_modules', '.playcraft-workspace.json', '.playcraft.json']);
|
|
11
|
+
export class WorkspaceTransferError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
constructor(code) {
|
|
14
|
+
super(code);
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function excluded(path) {
|
|
19
|
+
const parts = path.split('/');
|
|
20
|
+
return parts.some(part => EXCLUDED.has(part.toLowerCase()) || /^\.playcraft-(?:credential|transfer)-.*\.tmp$/i.test(part)) ||
|
|
21
|
+
parts.some((part, index) => part === '.playcraft' && /^(?:workspace-context(?:\..*)?|video-replication)$/.test(parts[index + 1] ?? '')) ||
|
|
22
|
+
isCredentialFile(path);
|
|
23
|
+
}
|
|
24
|
+
export function transferGit(root, args) {
|
|
25
|
+
const env = { PATH: process.env.PATH, HOME: tmpdir(),
|
|
26
|
+
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', GIT_ALLOW_PROTOCOL: 'file' };
|
|
27
|
+
const result = spawnSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false',
|
|
28
|
+
'-c', 'core.autocrlf=false', '-c', 'user.name=PlayCraft Workspace', '-c', 'user.email=workspace@playcraft.local', ...args], { cwd: root, encoding: 'utf8', env, timeout: 120_000, maxBuffer: 8 * 1024 * 1024 });
|
|
29
|
+
if (result.status !== 0)
|
|
30
|
+
throw new WorkspaceTransferError('workspace_transfer_git_failed');
|
|
31
|
+
return result.stdout.trim();
|
|
32
|
+
}
|
|
33
|
+
async function copySnapshot(source, target) {
|
|
34
|
+
let bytes = 0, files = 0;
|
|
35
|
+
async function copy(directory, prefix) {
|
|
36
|
+
const directoryInfo = await lstat(directory);
|
|
37
|
+
const directoryReal = await realpath(directory);
|
|
38
|
+
if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink() ||
|
|
39
|
+
(directoryReal !== source && !directoryReal.startsWith(`${source}${sep}`)))
|
|
40
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
41
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
42
|
+
const path = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
43
|
+
if (excluded(path))
|
|
44
|
+
continue;
|
|
45
|
+
const from = join(directory, entry.name), to = join(target, path);
|
|
46
|
+
const info = await lstat(from);
|
|
47
|
+
if (info.isSymbolicLink())
|
|
48
|
+
continue;
|
|
49
|
+
if (info.isDirectory()) {
|
|
50
|
+
await mkdir(to, { recursive: true });
|
|
51
|
+
await copy(from, path);
|
|
52
|
+
}
|
|
53
|
+
else if (info.isFile()) {
|
|
54
|
+
bytes += info.size;
|
|
55
|
+
files++;
|
|
56
|
+
if (bytes > WORKSPACE_TRANSFER_MAX_BYTES || files > MAX_FILES)
|
|
57
|
+
throw new WorkspaceTransferError('workspace_transfer_too_large');
|
|
58
|
+
if (!(await realpath(from)).startsWith(`${source}${sep}`))
|
|
59
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
60
|
+
const handle = await open(from, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
61
|
+
try {
|
|
62
|
+
const before = await handle.stat();
|
|
63
|
+
if (!before.isFile() || before.dev !== info.dev || before.ino !== info.ino || before.size !== info.size ||
|
|
64
|
+
before.mtimeMs !== info.mtimeMs || before.ctimeMs !== info.ctimeMs)
|
|
65
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
66
|
+
const chunks = [];
|
|
67
|
+
let size = 0;
|
|
68
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
69
|
+
size += chunk.length;
|
|
70
|
+
if (size > before.size)
|
|
71
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
72
|
+
chunks.push(chunk);
|
|
73
|
+
}
|
|
74
|
+
const after = await handle.stat();
|
|
75
|
+
if (size !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
|
|
76
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
77
|
+
await writeFile(to, Buffer.concat(chunks));
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
await handle.close();
|
|
81
|
+
}
|
|
82
|
+
await chmod(to, info.mode & 0o111 ? 0o755 : 0o644);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await copy(source, '');
|
|
87
|
+
}
|
|
88
|
+
async function matchesFile(root, path, expected) {
|
|
89
|
+
const target = await safeTarget(root, path);
|
|
90
|
+
const info = await lstat(target).catch(error => { if (['ENOENT', 'ENOTDIR'].includes(error.code))
|
|
91
|
+
return undefined; throw error; });
|
|
92
|
+
if (!expected)
|
|
93
|
+
return !info || info.isDirectory();
|
|
94
|
+
if (!info?.isFile() || Boolean(info.mode & 0o111) !== expected.executable)
|
|
95
|
+
return false;
|
|
96
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
97
|
+
try {
|
|
98
|
+
const stat = await handle.stat();
|
|
99
|
+
const hash = createHash(expected.sha.length === 64 ? 'sha256' : 'sha1');
|
|
100
|
+
hash.update(`blob ${stat.size}\0`);
|
|
101
|
+
for await (const chunk of handle.createReadStream({ autoClose: false }))
|
|
102
|
+
hash.update(chunk);
|
|
103
|
+
return hash.digest('hex') === expected.sha;
|
|
104
|
+
}
|
|
105
|
+
finally {
|
|
106
|
+
await handle.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function removeEmptyParents(root, target) {
|
|
110
|
+
let parent = dirname(target);
|
|
111
|
+
while (parent !== root) {
|
|
112
|
+
if ((await readdir(parent)).length > 0)
|
|
113
|
+
break;
|
|
114
|
+
await rmdir(parent);
|
|
115
|
+
parent = dirname(parent);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function installFile(root, path, source, executable) {
|
|
119
|
+
const target = await safeTarget(root, path);
|
|
120
|
+
const existing = await lstat(target).catch(error => { if (error.code === 'ENOENT')
|
|
121
|
+
return undefined; throw error; });
|
|
122
|
+
if (existing?.isDirectory())
|
|
123
|
+
await rmdir(target);
|
|
124
|
+
await mkdir(dirname(target), { recursive: true });
|
|
125
|
+
const stage = join(dirname(target), `.playcraft-transfer-${randomUUID()}.tmp`);
|
|
126
|
+
try {
|
|
127
|
+
await copyFile(source, stage);
|
|
128
|
+
await chmod(stage, executable ? 0o755 : 0o644);
|
|
129
|
+
await rename(stage, target);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
await rm(stage, { force: true }).catch(() => undefined);
|
|
133
|
+
await removeEmptyParents(root, target).catch(() => undefined);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await rm(stage, { force: true }).catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** A fresh history contains only the current portable files, including uncommitted changes. */
|
|
141
|
+
export async function createWorkspaceTransfer(root) {
|
|
142
|
+
const temporary = await mkdtemp(join(tmpdir(), 'playcraft-transfer-'));
|
|
143
|
+
const cleanup = () => rm(temporary, { recursive: true, force: true });
|
|
144
|
+
try {
|
|
145
|
+
const checkout = join(temporary, 'checkout');
|
|
146
|
+
await mkdir(checkout);
|
|
147
|
+
await copySnapshot(await realpath(root), checkout);
|
|
148
|
+
transferGit(checkout, ['init', '--initial-branch=main']);
|
|
149
|
+
transferGit(checkout, ['add', '--force', '--all']);
|
|
150
|
+
transferGit(checkout, ['commit', '--allow-empty', '-m', 'Workspace snapshot']);
|
|
151
|
+
const tree = transferGit(checkout, ['rev-parse', 'HEAD^{tree}']);
|
|
152
|
+
const head = transferGit(checkout, ['rev-parse', 'HEAD']);
|
|
153
|
+
const bundlePath = join(temporary, 'workspace.bundle');
|
|
154
|
+
transferGit(checkout, ['bundle', 'create', bundlePath, 'HEAD']);
|
|
155
|
+
return { bundlePath, tree, head, cleanup };
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
await cleanup();
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
function treeEntries(root) {
|
|
163
|
+
const entries = transferGit(root, ['ls-tree', '-r', '-z', 'HEAD']).split('\0').filter(Boolean).map(line => {
|
|
164
|
+
const match = /^(100644|100755) blob ([a-f0-9]{40,64})\t(.+)$/s.exec(line);
|
|
165
|
+
if (!match)
|
|
166
|
+
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
167
|
+
const path = match[3];
|
|
168
|
+
if (path.length > 1024 || path.includes('\\') || path.startsWith('/') || /[\x00-\x1f\x7f]/.test(path) ||
|
|
169
|
+
path.split('/').some(part => !part || part === '.' || part === '..') || excluded(path))
|
|
170
|
+
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
171
|
+
return { path, sha: match[2], executable: match[1] === '100755' };
|
|
172
|
+
});
|
|
173
|
+
if (entries.length > MAX_FILES)
|
|
174
|
+
throw new WorkspaceTransferError('workspace_transfer_too_large');
|
|
175
|
+
const normalized = entries.map(entry => entry.path.toLowerCase());
|
|
176
|
+
if (new Set(normalized).size !== entries.length)
|
|
177
|
+
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
178
|
+
return entries;
|
|
179
|
+
}
|
|
180
|
+
async function safeTarget(root, path) {
|
|
181
|
+
const target = resolve(root, path);
|
|
182
|
+
if (!target.startsWith(`${root}${sep}`))
|
|
183
|
+
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
184
|
+
let current = root;
|
|
185
|
+
for (const segment of path.split('/')) {
|
|
186
|
+
current = join(current, segment);
|
|
187
|
+
const info = await lstat(current).catch(error => { if (['ENOENT', 'ENOTDIR'].includes(error.code))
|
|
188
|
+
return undefined; throw error; });
|
|
189
|
+
if (info?.isSymbolicLink())
|
|
190
|
+
throw new WorkspaceTransferError('workspace_transfer_symlink');
|
|
191
|
+
}
|
|
192
|
+
return target;
|
|
193
|
+
}
|
|
194
|
+
/** Compare the entire checkout before applying local commits, so cloud edits are never overwritten. */
|
|
195
|
+
export async function applyWorkspaceTransfer(root, bundle, expectedTree) {
|
|
196
|
+
if (!/^[a-f0-9]{40,64}$/.test(expectedTree))
|
|
197
|
+
throw new WorkspaceTransferError('invalid_workspace_transfer');
|
|
198
|
+
if (!bundle.byteLength || bundle.byteLength > WORKSPACE_TRANSFER_MAX_BYTES)
|
|
199
|
+
throw new WorkspaceTransferError('workspace_transfer_too_large');
|
|
200
|
+
const rootReal = await realpath(root);
|
|
201
|
+
const temporary = await mkdtemp(join(tmpdir(), 'playcraft-import-'));
|
|
202
|
+
let current;
|
|
203
|
+
try {
|
|
204
|
+
const bundlePath = join(temporary, 'incoming.bundle'), incoming = join(temporary, 'checkout');
|
|
205
|
+
await writeFile(bundlePath, bundle, { mode: 0o600 });
|
|
206
|
+
transferGit(temporary, ['clone', '--no-checkout', '--', bundlePath, incoming]);
|
|
207
|
+
const entries = treeEntries(incoming);
|
|
208
|
+
const tree = transferGit(incoming, ['rev-parse', 'HEAD^{tree}']);
|
|
209
|
+
const head = transferGit(incoming, ['rev-parse', 'HEAD']);
|
|
210
|
+
let bytes = 0;
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
bytes += Number(transferGit(incoming, ['cat-file', '-s', entry.sha]));
|
|
213
|
+
if (bytes > WORKSPACE_TRANSFER_MAX_BYTES)
|
|
214
|
+
throw new WorkspaceTransferError('workspace_transfer_too_large');
|
|
215
|
+
}
|
|
216
|
+
current = await createWorkspaceTransfer(rootReal);
|
|
217
|
+
if (current.tree === tree)
|
|
218
|
+
return { tree, head, changedPaths: [] };
|
|
219
|
+
if (current.tree !== expectedTree)
|
|
220
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
221
|
+
const baseline = join(dirname(current.bundlePath), 'checkout');
|
|
222
|
+
const previous = new Map(treeEntries(baseline).map(entry => [entry.path, entry]));
|
|
223
|
+
const next = new Map(entries.map(entry => [entry.path, entry]));
|
|
224
|
+
const changedPaths = [...new Set([...previous.keys(), ...next.keys()])]
|
|
225
|
+
.filter(path => previous.get(path)?.sha !== next.get(path)?.sha || previous.get(path)?.executable !== next.get(path)?.executable);
|
|
226
|
+
for (const path of changedPaths) {
|
|
227
|
+
const target = await safeTarget(rootReal, path);
|
|
228
|
+
const info = await lstat(target).catch(error => { if (['ENOENT', 'ENOTDIR'].includes(error.code))
|
|
229
|
+
return undefined; throw error; });
|
|
230
|
+
if (next.has(path) && info?.isDirectory()) {
|
|
231
|
+
const children = await readdir(target, { recursive: true });
|
|
232
|
+
for (const child of children) {
|
|
233
|
+
const childPath = `${path}/${child.split(sep).join('/')}`;
|
|
234
|
+
const childInfo = await lstat(join(target, child));
|
|
235
|
+
if (!childInfo.isDirectory() && (!previous.has(childPath) || next.has(childPath)))
|
|
236
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
transferGit(incoming, ['checkout', '--force']);
|
|
241
|
+
const recheck = await createWorkspaceTransfer(rootReal);
|
|
242
|
+
try {
|
|
243
|
+
if (recheck.tree !== expectedTree)
|
|
244
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
245
|
+
}
|
|
246
|
+
finally {
|
|
247
|
+
await recheck.cleanup();
|
|
248
|
+
}
|
|
249
|
+
const applied = [];
|
|
250
|
+
try {
|
|
251
|
+
// Deleting files first also permits a file to become a directory, or the reverse.
|
|
252
|
+
for (const path of changedPaths.filter(path => !next.has(path)).sort((a, b) => b.length - a.length)) {
|
|
253
|
+
if (!await matchesFile(rootReal, path, previous.get(path)))
|
|
254
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
255
|
+
await rm(await safeTarget(rootReal, path), { force: true });
|
|
256
|
+
applied.push(path);
|
|
257
|
+
await removeEmptyParents(rootReal, join(rootReal, path));
|
|
258
|
+
}
|
|
259
|
+
for (const path of changedPaths.filter(path => next.has(path))) {
|
|
260
|
+
if (!await matchesFile(rootReal, path, previous.get(path)))
|
|
261
|
+
throw new WorkspaceTransferError('workspace_transfer_conflict');
|
|
262
|
+
const entry = next.get(path);
|
|
263
|
+
await installFile(rootReal, path, join(incoming, path), entry.executable);
|
|
264
|
+
applied.push(path);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch (error) {
|
|
268
|
+
// Roll back only our writes; never replace another writer's intervening content.
|
|
269
|
+
for (const path of applied.reverse()) {
|
|
270
|
+
if (!await matchesFile(rootReal, path, next.get(path)))
|
|
271
|
+
throw new WorkspaceTransferError('workspace_transfer_rollback_conflict');
|
|
272
|
+
const old = previous.get(path);
|
|
273
|
+
if (old)
|
|
274
|
+
await installFile(rootReal, path, join(baseline, path), old.executable);
|
|
275
|
+
else {
|
|
276
|
+
await rm(await safeTarget(rootReal, path), { force: true });
|
|
277
|
+
await removeEmptyParents(rootReal, join(rootReal, path));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
return { tree, head, changedPaths };
|
|
283
|
+
}
|
|
284
|
+
finally {
|
|
285
|
+
await current?.cleanup();
|
|
286
|
+
await rm(temporary, { recursive: true, force: true });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { TURN_FILES_CHANGED_EVENT, } from '@playcraft/common/cloud-workspace/runtime';
|
|
2
|
+
import { diffWorkspaceTrees, retainTurnSnapshots, snapshotWorkspaceTree } from './git.js';
|
|
3
|
+
const TURN_ENDS = new Set(['turn/completed', 'turn.completed', 'turn.interrupted', 'turn.failed']);
|
|
4
|
+
/**
|
|
5
|
+
* Snapshots taken when a session's turn starts, until that turn ends. Kept in memory only: a
|
|
6
|
+
* restart ends the native turn as well, and that turn then simply reports no changes.
|
|
7
|
+
*/
|
|
8
|
+
const started = new WeakMap();
|
|
9
|
+
function turnsOf(state) {
|
|
10
|
+
let turns = started.get(state);
|
|
11
|
+
if (!turns)
|
|
12
|
+
started.set(state, turns = new Map());
|
|
13
|
+
return turns;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Records the work tree before a turn is sent. A directory that cannot be snapshotted (no
|
|
17
|
+
* repository, Git failure) gets no change summary; the turn itself is never held back by it.
|
|
18
|
+
*/
|
|
19
|
+
export async function beginTurnChanges(state, sessionId, input) {
|
|
20
|
+
turnsOf(state).delete(sessionId);
|
|
21
|
+
if (input.directoryName === 'root')
|
|
22
|
+
return;
|
|
23
|
+
const before = await snapshotWorkspaceTree(input.root).catch(() => undefined);
|
|
24
|
+
if (before)
|
|
25
|
+
turnsOf(state).set(sessionId, { ...input, before });
|
|
26
|
+
}
|
|
27
|
+
/** A turn that never started has nothing to compare. */
|
|
28
|
+
export function abandonTurnChanges(state, sessionId) {
|
|
29
|
+
turnsOf(state).delete(sessionId);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* When a session's turn ends, compares the work tree with its start and records the files that
|
|
33
|
+
* changed for that session's conversation. Returns no event when nothing changed.
|
|
34
|
+
*/
|
|
35
|
+
export async function finishTurnChanges(state, event) {
|
|
36
|
+
const { threadId, turnId } = event;
|
|
37
|
+
if (!TURN_ENDS.has(event.method) || !threadId || !turnId)
|
|
38
|
+
return [];
|
|
39
|
+
const session = state.journal.listSessions().find((item) => item.threadId === threadId);
|
|
40
|
+
const start = session && turnsOf(state).get(session.sessionId);
|
|
41
|
+
if (!session || !start)
|
|
42
|
+
return [];
|
|
43
|
+
turnsOf(state).delete(session.sessionId);
|
|
44
|
+
try {
|
|
45
|
+
const after = await snapshotWorkspaceTree(start.root);
|
|
46
|
+
const { files, truncated } = await diffWorkspaceTrees(start.root, start.before, after);
|
|
47
|
+
if (!files.length)
|
|
48
|
+
return [];
|
|
49
|
+
await retainTurnSnapshots(start.root, turnId, start.before, after);
|
|
50
|
+
const changes = { threadId, turnId, before: start.before, after, files, truncated };
|
|
51
|
+
return [await state.journal.allocateSourceEvent({
|
|
52
|
+
runtimeId: state.config.runtimeId,
|
|
53
|
+
generation: state.config.generation,
|
|
54
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
55
|
+
type: TURN_FILES_CHANGED_EVENT,
|
|
56
|
+
payload: {
|
|
57
|
+
...changes,
|
|
58
|
+
sessionId: session.sessionId,
|
|
59
|
+
...(start.workspaceId ? { workspaceId: start.workspaceId } : {}),
|
|
60
|
+
...(start.directoryName ? { directoryName: start.directoryName } : {}),
|
|
61
|
+
},
|
|
62
|
+
occurredAt: new Date().toISOString(),
|
|
63
|
+
sessionId: session.sessionId,
|
|
64
|
+
workspaceId: start.workspaceId,
|
|
65
|
+
threadId,
|
|
66
|
+
turnId,
|
|
67
|
+
})];
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"@gltf-transform/core": "^4.3.0",
|
|
51
51
|
"@gltf-transform/extensions": "^4.3.0",
|
|
52
52
|
"@gltf-transform/functions": "^4.3.0",
|
|
53
|
-
"@playcraft/build": "^0.1.
|
|
54
|
-
"@playcraft/common": "^0.1.
|
|
53
|
+
"@playcraft/build": "^0.1.33",
|
|
54
|
+
"@playcraft/common": "^0.1.33",
|
|
55
55
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
56
56
|
"chokidar": "^4.0.3",
|
|
57
57
|
"commander": "^13.1.0",
|