@playcraft/cli 0.1.17 → 0.1.19

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.
@@ -1,354 +0,0 @@
1
- import { chmod, lstat, mkdir, readdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises';
2
- import { dirname, join, relative, resolve, sep } from 'node:path';
3
- import { fileSnapshotDigest, fileSnapshotManifestKey, fileSnapshotContentKey, nativeTranscriptKey, fileSnapshotManifestSchema, hashSnapshotBytes, isDisposableBackupPath, isWorkspaceAttachmentPath, putObjectBytes, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
4
- import { UNPUSHED_BUNDLE, addBundleObjects, repositoryResolves } from './git.js';
5
- const SKIP_DIRECTORY_NAMES = new Set(['.git', 'node_modules']);
6
- /** Build output, caches and scratch folders, at any depth: regenerated, so not kept in storage. */
7
- const UNSTORED_DIRECTORY_NAMES = new Set([
8
- 'dist',
9
- 'dist-all',
10
- 'coverage',
11
- '.vite',
12
- '.cache',
13
- '.turbo',
14
- '.parcel-cache',
15
- '.sass-cache',
16
- '.next',
17
- '.nuxt',
18
- '.svelte-kit',
19
- 'tmp',
20
- '.tmp',
21
- '.temp',
22
- ]);
23
- /** Scratch and cache files, at any depth. */
24
- const UNSTORED_FILE = /(?:\.tmp|\.log|\.tsbuildinfo|^\.eslintcache)$/;
25
- /**
26
- * At the workspace root: the preview build's output, the builder's scratch folder, and skill
27
- * artifacts the sandbox pulls from the registry by `.playcraft/skills.lock.json`.
28
- */
29
- const UNSTORED_ROOT_DIRECTORIES = [
30
- '.playcraft-preview-out/',
31
- '.playcraft-temp/',
32
- '.playcraft/skills/',
33
- 'ta-workspace/logs/',
34
- ];
35
- /**
36
- * Recordings, frames and reports the template's verify and record scripts write again on each run.
37
- * The ring JSON evidence the build gate reads stays, as do the agent-edited profile and catalogs.
38
- */
39
- const UNSTORED_RUN_OUTPUT = [
40
- /^recordings\/(?:.+\.(?:mp4|webm)$|\.tmp-[^/]+\/|focus\/|clean\/)/,
41
- /^validate\/(?:[^/]+\.md$|(?:ring\d+\/)?frames\/|ring\d+\/[^/]+\.mp4$)/,
42
- ];
43
- /**
44
- * Whether a path goes to checkpoint storage. Credentials, lock files, build output, caches and
45
- * scratch files stay in the instance: its local copy keeps them, so restoring inside the instance
46
- * does not remove them.
47
- */
48
- export function keptInStorage(path) {
49
- if (isWorkspaceAttachmentPath(path))
50
- return true;
51
- if (isDisposableBackupPath(path))
52
- return false;
53
- if (path.startsWith('.git/'))
54
- return true;
55
- if (UNSTORED_ROOT_DIRECTORIES.some((prefix) => path.startsWith(prefix)))
56
- return false;
57
- if (UNSTORED_RUN_OUTPUT.some((pattern) => pattern.test(path)))
58
- return false;
59
- const segments = path.split('/');
60
- if (UNSTORED_FILE.test(segments[segments.length - 1]))
61
- return false;
62
- return !segments.slice(0, -1).some((segment) => UNSTORED_DIRECTORY_NAMES.has(segment));
63
- }
64
- /**
65
- * Under `.git/`, what a `local` capture leaves to the remote: packs and their indexes, submodule
66
- * repositories (`git submodule update` fetches them again), LFS objects, and template hooks.
67
- */
68
- const REMOTE_GIT_DIRECTORIES = new Set([
69
- '.git/objects/pack',
70
- '.git/objects/info',
71
- '.git/modules',
72
- '.git/lfs',
73
- '.git/hooks',
74
- ]);
75
- const READ_ATTEMPTS = 3;
76
- const realIo = {
77
- async readdir(path) {
78
- return readdir(path, { withFileTypes: true });
79
- },
80
- async lstat(path) {
81
- return lstat(path);
82
- },
83
- readFile,
84
- readlink,
85
- };
86
- export async function captureWorkspaceTree(root, previousPaths, revisions, io = realIo, options = {}) {
87
- const entries = [];
88
- const files = [];
89
- const bodies = new Map();
90
- const seen = new Set();
91
- let status = 'complete';
92
- const skip = (relativePath, name, directory) => {
93
- if (name === '.' || name === '..')
94
- return true;
95
- if (directory && name === '.git')
96
- return !(options.git && relativePath === '.git');
97
- // A submodule's `.git` file points into `.git/modules`, which is not kept either.
98
- if (!directory && name === '.git' && relativePath !== '.git')
99
- return true;
100
- if (directory && SKIP_DIRECTORY_NAMES.has(name))
101
- return true;
102
- if (directory && options.git === 'local' && REMOTE_GIT_DIRECTORIES.has(relativePath))
103
- return true;
104
- // Git's own lock files only exist while a command runs and must not come back with a restore.
105
- return !directory && relativePath.startsWith('.git/') && name.endsWith('.lock');
106
- };
107
- await walk(root, root, io, revisions, entries, files, bodies, seen, skip, () => {
108
- status = 'incomplete';
109
- });
110
- for (const path of [...previousPaths].sort((left, right) => left.localeCompare(right))) {
111
- if (seen.has(path) || !workspaceRelativePathSchema.safeParse(path).success)
112
- continue;
113
- entries.push({ path, kind: 'deleted' });
114
- }
115
- entries.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
116
- return { status, entries, files, bodies };
117
- }
118
- export async function materializeSnapshot(snapshotDir, tree) {
119
- await rm(snapshotDir, { recursive: true, force: true });
120
- for (const file of tree.files) {
121
- const body = tree.bodies.get(file.path);
122
- if (!body || hashSnapshotBytes(body) !== file.contentHash)
123
- throw new Error('snapshot_body_missing');
124
- const target = join(snapshotDir, ...file.path.split('/'));
125
- await mkdir(dirname(target), { recursive: true, mode: 0o700 });
126
- await writeFile(target, body, { mode: 0o600 });
127
- }
128
- }
129
- export async function materializeRemoteSnapshot(root, entries) {
130
- await mkdir(root, { recursive: true, mode: 0o700 });
131
- const rootReal = await realpath(root);
132
- const skipped = [];
133
- const applicable = [];
134
- for (const entry of entries) {
135
- if (isDisposableBackupPath(entry.path)) {
136
- skipped.push(entry.path);
137
- continue;
138
- }
139
- applicable.push(entry);
140
- }
141
- for (const entry of applicable) {
142
- if (entry.kind !== 'file')
143
- continue;
144
- if (!entry.contentBase64 || !entry.contentHash)
145
- throw new Error('snapshot_mismatch');
146
- const body = Buffer.from(entry.contentBase64, 'base64');
147
- if (hashSnapshotBytes(body) !== entry.contentHash)
148
- throw new Error('snapshot_mismatch');
149
- }
150
- if (applicable.some((entry) => entry.path === '.git/HEAD')) {
151
- const bundle = applicable.find((entry) => entry.path === UNPUSHED_BUNDLE && entry.kind === 'file');
152
- if (bundle)
153
- await addBundleObjects(rootReal, Buffer.from(bundle.contentBase64 ?? '', 'base64'));
154
- await clearClonedGitMetadata(rootReal);
155
- }
156
- for (const entry of applicable) {
157
- const target = resolve(rootReal, ...entry.path.split('/'));
158
- const rel = relative(rootReal, target);
159
- if (rel === '' || rel.startsWith(`..${sep}`) || rel === '..' || rel.startsWith('..')) {
160
- throw new Error('snapshot_mismatch');
161
- }
162
- if (entry.kind === 'deleted') {
163
- await rm(target, { force: true });
164
- continue;
165
- }
166
- await mkdir(dirname(target), { recursive: true, mode: isWorkspaceAttachmentPath(entry.path) ? 0o755 : 0o700 });
167
- if (entry.kind === 'symlink') {
168
- if (!entry.target)
169
- throw new Error('snapshot_mismatch');
170
- await rm(target, { force: true });
171
- await symlink(entry.target, target);
172
- continue;
173
- }
174
- const body = Buffer.from(entry.contentBase64 ?? '', 'base64');
175
- await writeFile(target, body, { mode: entry.mode ?? 0o600 });
176
- if (entry.mode !== undefined)
177
- await chmod(target, entry.mode);
178
- }
179
- return {
180
- skipped,
181
- directoryReady: true,
182
- repositoryReady: await repositoryResolves(rootReal),
183
- environmentReady: false,
184
- };
185
- }
186
- /**
187
- * The snapshot's `.git` replaces the one a new clone made, objects aside: a ref the clone wrote
188
- * would otherwise outrank the snapshot's packed refs.
189
- */
190
- async function clearClonedGitMetadata(rootReal) {
191
- const gitDir = join(rootReal, '.git');
192
- const info = await lstat(gitDir).catch(() => undefined);
193
- if (!info?.isDirectory())
194
- return;
195
- for (const name of await readdir(gitDir)) {
196
- if (name !== 'objects')
197
- await rm(join(gitDir, name), { recursive: true, force: true });
198
- }
199
- }
200
- /**
201
- * Content goes to the workspace's shared objects, keyed by hash. A hash in `uploaded` was stored by
202
- * this process before and is not sent again; hashes stored now are added to it. The manifest still
203
- * lists every stored file, so each snapshot restores on its own. Paths not `keptInStorage` are left
204
- * out of both the objects and the manifest.
205
- */
206
- export async function publishFileSnapshot(input) {
207
- const stored = new Set();
208
- const entries = input.tree.entries.filter((entry) => keptInStorage(entry.path));
209
- for (const entry of entries) {
210
- if (entry.kind !== 'file' || !entry.contentHash)
211
- continue;
212
- if (input.uploaded?.has(entry.contentHash) || stored.has(entry.contentHash))
213
- continue;
214
- const body = input.tree.bodies.get(entry.path);
215
- if (!body || hashSnapshotBytes(body) !== entry.contentHash)
216
- throw new Error('snapshot_body_missing');
217
- await putObjectBytes(input.baseUrl, fileSnapshotContentKey(input.workspaceId, entry.contentHash), body, 'cli', input.headers);
218
- stored.add(entry.contentHash);
219
- }
220
- const native = [];
221
- for (const transcript of input.native ?? []) {
222
- const contentHash = hashSnapshotBytes(transcript.body);
223
- if (input.uploadedTranscripts?.get(transcript.path) !== contentHash) {
224
- await putObjectBytes(input.baseUrl, nativeTranscriptKey(input.workspaceId, transcript.path), transcript.body, 'cli', input.headers);
225
- input.uploadedTranscripts?.set(transcript.path, contentHash);
226
- }
227
- native.push({
228
- sessionId: transcript.sessionId,
229
- threadId: transcript.threadId,
230
- path: transcript.path,
231
- contentHash,
232
- size: transcript.body.byteLength,
233
- capturedAt: transcript.capturedAt,
234
- });
235
- }
236
- const digest = fileSnapshotDigest(entries);
237
- const manifest = fileSnapshotManifestSchema.parse({
238
- snapshotId: input.snapshotId,
239
- workspaceId: input.workspaceId,
240
- generation: input.generation,
241
- status: input.tree.status,
242
- entries,
243
- digest,
244
- objectLayout: 'workspace',
245
- ...(native.length > 0 ? { native } : {}),
246
- });
247
- const manifestKey = fileSnapshotManifestKey(input.workspaceId, input.generation, input.snapshotId);
248
- await putObjectBytes(input.baseUrl, manifestKey, Buffer.from(JSON.stringify(manifest)), 'cli', input.headers);
249
- for (const hash of stored)
250
- input.uploaded?.add(hash);
251
- return { manifestKey, checksum: digest, status: input.tree.status };
252
- }
253
- /**
254
- * Digest of what is on disk now, without the deletions recorded against the previous snapshot.
255
- * Transcripts count too: a turn that only talked still changes what a restore can resume.
256
- */
257
- export function liveTreeDigest(tree, native = []) {
258
- const files = fileSnapshotDigest(tree.entries.filter((entry) => entry.kind !== 'deleted'));
259
- if (native.length === 0)
260
- return files;
261
- const transcripts = native
262
- .map((item) => `${item.sessionId}:${item.threadId}:${item.path}:${hashSnapshotBytes(item.body)}`)
263
- .sort();
264
- return hashSnapshotBytes(Buffer.from([files, ...transcripts].join('\n')));
265
- }
266
- async function walk(rootReal, current, io, revisions, entries, files, bodies, seen, skip, markIncomplete) {
267
- const listed = await io.readdir(current);
268
- for (const entry of listed) {
269
- const child = join(current, entry.name);
270
- const relativePath = toPosixRelative(rootReal, child);
271
- if (skip(relativePath, entry.name, entry.isDirectory()))
272
- continue;
273
- if (!workspaceRelativePathSchema.safeParse(relativePath).success) {
274
- markIncomplete();
275
- continue;
276
- }
277
- if (entry.isSymbolicLink()) {
278
- const sampled = await sampleLink(child, io);
279
- seen.add(relativePath);
280
- if (!sampled) {
281
- markIncomplete();
282
- entries.push({ path: relativePath, kind: 'symlink' });
283
- continue;
284
- }
285
- entries.push({ path: relativePath, kind: 'symlink', mode: sampled.mode, target: sampled.target });
286
- continue;
287
- }
288
- if (entry.isDirectory()) {
289
- await walk(rootReal, child, io, revisions, entries, files, bodies, seen, skip, markIncomplete);
290
- continue;
291
- }
292
- if (!entry.isFile())
293
- continue;
294
- const sampled = await sampleFile(child, io);
295
- seen.add(relativePath);
296
- if (!sampled) {
297
- markIncomplete();
298
- entries.push({ path: relativePath, kind: 'file' });
299
- continue;
300
- }
301
- const record = {
302
- path: relativePath,
303
- contentHash: sampled.hash,
304
- revision: revisions[relativePath]?.revision ?? 0,
305
- };
306
- entries.push({
307
- path: relativePath,
308
- kind: 'file',
309
- mode: sampled.mode,
310
- contentHash: sampled.hash,
311
- size: sampled.body.byteLength,
312
- });
313
- files.push(record);
314
- bodies.set(relativePath, sampled.body);
315
- }
316
- }
317
- async function sampleFile(path, io) {
318
- let previous;
319
- for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
320
- try {
321
- const body = await io.readFile(path);
322
- const mode = (await io.lstat(path)).mode & 0o777;
323
- const hash = hashSnapshotBytes(body);
324
- if (previous && previous.hash === hash && previous.mode === mode)
325
- return { body, mode, hash };
326
- previous = { hash, mode, body };
327
- }
328
- catch {
329
- return undefined;
330
- }
331
- }
332
- return undefined;
333
- }
334
- async function sampleLink(path, io) {
335
- let previous;
336
- for (let attempt = 0; attempt < READ_ATTEMPTS; attempt += 1) {
337
- try {
338
- const target = await io.readlink(path);
339
- const mode = (await io.lstat(path)).mode & 0o777;
340
- if (target.includes('\0') || target.length === 0 || target.length > 512)
341
- return undefined;
342
- if (previous && previous.target === target && previous.mode === mode)
343
- return { mode, target };
344
- previous = { mode, target };
345
- }
346
- catch {
347
- return undefined;
348
- }
349
- }
350
- return undefined;
351
- }
352
- function toPosixRelative(rootReal, target) {
353
- return relative(rootReal, target).split(sep).join('/');
354
- }