@playcraft/cli 0.1.71 → 0.1.73

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,7 +1,7 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { createHash, randomUUID } from 'node:crypto';
3
3
  import { constants } from 'node:fs';
4
- import { chmod, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { chmod, lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm, writeFile, } from 'node:fs/promises';
5
5
  import { dirname, join } from 'node:path';
6
6
  import { promisify } from 'node:util';
7
7
  import { WORKSPACE_REMIX_REFERENCE_MAX_BYTES, WORKSPACE_REMIX_REFERENCE_MAX_FILES, WORKSPACE_REMIX_REFERENCES_DIRECTORY, WORKSPACE_REMIX_REFERENCES_MANIFEST, remixReferenceContentHashInput, workspaceDirectoryNameSchema, workspaceRemixReferenceExportSchema, workspaceRemixReferenceSchema, } from '@playcraft/common/cloud-workspace/runtime';
@@ -12,24 +12,51 @@ const ATTRIBUTES = '.gitattributes';
12
12
  const SNAPSHOT_ATTRIBUTES = '* -filter -text -ident -working-tree-encoding -eol\n';
13
13
  const INERT_DIRECTORY = '_source_metadata';
14
14
  const MANIFEST_MAX_BYTES = 16 * 1024 * 1024;
15
- const EXCLUDED = new Set(['.git', 'node_modules', '.next', '.cache', '.tmp', '.turbo', '.pnpm-store', '.ssh', '.aws']);
16
- const INERT = new Set(['.agents', '.claude', '.codex', '.cursor', '.playcraft', '.gitignore', '.gitattributes',
17
- '.gitmodules', 'agents.md', 'claude.md', 'gemini.md', '.cursorrules', '.mcp.json']);
15
+ const EXCLUDED = new Set([
16
+ '.git',
17
+ 'node_modules',
18
+ '.next',
19
+ '.cache',
20
+ '.tmp',
21
+ '.turbo',
22
+ '.pnpm-store',
23
+ '.ssh',
24
+ '.aws',
25
+ ]);
26
+ const INERT = new Set([
27
+ '.agents',
28
+ '.claude',
29
+ '.codex',
30
+ '.cursor',
31
+ '.playcraft',
32
+ '.gitignore',
33
+ '.gitattributes',
34
+ '.gitmodules',
35
+ 'agents.md',
36
+ 'claude.md',
37
+ 'gemini.md',
38
+ '.cursorrules',
39
+ '.mcp.json',
40
+ ]);
18
41
  const locks = new Map();
19
42
  const hash = (value) => createHash('sha256').update(value).digest('hex');
20
43
  export class RemixReferenceError extends Error {
21
- constructor(code) { super(code); this.name = 'RemixReferenceError'; }
44
+ constructor(code) {
45
+ super(code);
46
+ this.name = 'RemixReferenceError';
47
+ }
22
48
  }
23
49
  async function safeDirectory(root, relative = '', create = false) {
24
50
  let current = root;
25
51
  for (const segment of relative.split('/').filter(Boolean)) {
26
52
  current = join(current, segment);
27
- // shared
28
- if (create)
53
+ if (create) {
54
+ // shared
29
55
  await mkdir(current, { mode: 0o755 }).catch((error) => {
30
56
  if (error.code !== 'EEXIST')
31
57
  throw error;
32
58
  });
59
+ }
33
60
  const info = await lstat(current);
34
61
  if (!info.isDirectory() || info.isSymbolicLink())
35
62
  throw new RemixReferenceError('remix_reference_path_conflict');
@@ -52,7 +79,10 @@ async function readFileSafely(root, path, limit) {
52
79
  length += bytesRead;
53
80
  }
54
81
  const after = await handle.stat();
55
- if (length !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs)
82
+ if (length !== before.size ||
83
+ after.size !== before.size ||
84
+ after.mtimeMs !== before.mtimeMs ||
85
+ after.ctimeMs !== before.ctimeMs)
56
86
  throw new RemixReferenceError('remix_reference_changed');
57
87
  return data.subarray(0, length);
58
88
  }
@@ -62,20 +92,28 @@ async function readFileSafely(root, path, limit) {
62
92
  }
63
93
  function identity(snapshot) {
64
94
  const { source, contentHash } = snapshot;
65
- return hash(JSON.stringify([source.environment, source.projectUuid, source.branchName, source.commit, contentHash]));
95
+ const sourceIdentity = 'builtinTemplate' in source ? ['builtinTemplate', source.builtinTemplate] : source.projectUuid;
96
+ return hash(JSON.stringify([
97
+ source.environment,
98
+ sourceIdentity,
99
+ source.branchName,
100
+ source.commit,
101
+ contentHash,
102
+ ]));
66
103
  }
67
104
  function referencePath(referenceId, commit) {
68
105
  return `${WORKSPACE_REMIX_REFERENCES_DIRECTORY}/${referenceId.slice(0, 20)}-${commit.slice(0, 12)}`;
69
106
  }
70
107
  /** Project instructions and Git filters remain readable source material without becoming active configuration. */
71
108
  function materializedPath(sourcePath) {
72
- const parts = sourcePath.split('/').map(part => part.toLowerCase());
73
- if (parts.some(part => EXCLUDED.has(part)) || isCredentialFile(sourcePath) ||
74
- parts.some(part => ['.npmrc', '.yarnrc', '.netrc', '.git-credentials'].includes(part)))
109
+ const parts = sourcePath.split('/').map((part) => part.toLowerCase());
110
+ if (parts.some((part) => EXCLUDED.has(part)) ||
111
+ isCredentialFile(sourcePath) ||
112
+ parts.some((part) => ['.npmrc', '.yarnrc', '.netrc', '.git-credentials'].includes(part)))
75
113
  return undefined;
76
114
  if (parts[0] === INERT_DIRECTORY || parts[0] === RECEIPT)
77
115
  throw new RemixReferenceError('remix_reference_path_conflict');
78
- if (parts.some(part => INERT.has(part)))
116
+ if (parts.some((part) => INERT.has(part)))
79
117
  return `${INERT_DIRECTORY}/${hash(sourcePath)}.txt`;
80
118
  return sourcePath;
81
119
  }
@@ -85,7 +123,7 @@ async function readManifest(root) {
85
123
  if (value?.version !== 1 || !Array.isArray(value.items) || value.items.length > 100)
86
124
  throw new RemixReferenceError('remix_reference_manifest_invalid');
87
125
  const items = value.items.map((item) => workspaceRemixReferenceSchema.parse(item));
88
- if (new Set(items.map(item => item.referenceId)).size !== items.length)
126
+ if (new Set(items.map((item) => item.referenceId)).size !== items.length)
89
127
  throw new RemixReferenceError('remix_reference_manifest_invalid');
90
128
  return items;
91
129
  }
@@ -98,8 +136,10 @@ async function readManifest(root) {
98
136
  }
99
137
  }
100
138
  function validateRecord(record, directoryName) {
101
- if (record.directoryName !== directoryName || record.referenceId !== identity(record) ||
102
- record.path !== referencePath(record.referenceId, record.source.commit) || record.name !== record.source.name)
139
+ if (record.directoryName !== directoryName ||
140
+ record.referenceId !== identity(record) ||
141
+ record.path !== referencePath(record.referenceId, record.source.commit) ||
142
+ record.name !== record.source.name)
103
143
  throw new RemixReferenceError('remix_reference_manifest_invalid');
104
144
  const paths = new Set();
105
145
  for (const file of record.files) {
@@ -107,10 +147,14 @@ function validateRecord(record, directoryName) {
107
147
  throw new RemixReferenceError('remix_reference_manifest_invalid');
108
148
  paths.add(file.path.toLowerCase());
109
149
  }
110
- if ((record.omittedFiles ?? []).some(file => materializedPath(file.path) !== undefined))
150
+ if ((record.omittedFiles ?? []).some((file) => materializedPath(file.path) !== undefined))
111
151
  throw new RemixReferenceError('remix_reference_manifest_invalid');
112
- const originalFiles = [...record.files.map(file => ({ ...file, path: file.sourcePath })), ...(record.omittedFiles ?? [])];
113
- if (originalFiles.length > WORKSPACE_REMIX_REFERENCE_MAX_FILES || new Set(originalFiles.map(file => file.path.toLowerCase())).size !== originalFiles.length ||
152
+ const originalFiles = [
153
+ ...record.files.map((file) => ({ ...file, path: file.sourcePath })),
154
+ ...(record.omittedFiles ?? []),
155
+ ];
156
+ if (originalFiles.length > WORKSPACE_REMIX_REFERENCE_MAX_FILES ||
157
+ new Set(originalFiles.map((file) => file.path.toLowerCase())).size !== originalFiles.length ||
114
158
  hash(remixReferenceContentHashInput(originalFiles)) !== record.contentHash ||
115
159
  originalFiles.reduce((sum, file) => sum + file.size, 0) > WORKSPACE_REMIX_REFERENCE_MAX_BYTES)
116
160
  throw new RemixReferenceError('remix_reference_manifest_invalid');
@@ -120,8 +164,10 @@ async function verifyReferenceMetadata(root, record, directoryName, alternateDir
120
164
  const target = await safeDirectory(root, record.path);
121
165
  const receipt = JSON.parse((await readFileSafely(target, RECEIPT, MANIFEST_MAX_BYTES)).toString('utf8'));
122
166
  const parsedReceipt = workspaceRemixReferenceSchema.parse(receipt);
123
- if (JSON.stringify(parsedReceipt) !== JSON.stringify(record)
124
- && (!alternateDirectory || JSON.stringify(parsedReceipt) !== JSON.stringify({ ...record, directoryName: alternateDirectory })))
167
+ if (JSON.stringify(parsedReceipt) !== JSON.stringify(record) &&
168
+ (!alternateDirectory ||
169
+ JSON.stringify(parsedReceipt) !==
170
+ JSON.stringify({ ...record, directoryName: alternateDirectory })))
125
171
  throw new RemixReferenceError('remix_reference_changed');
126
172
  return target;
127
173
  }
@@ -129,7 +175,7 @@ async function verifyReference(root, record, directoryName, alternateDirectory)
129
175
  const target = await verifyReferenceMetadata(root, record, directoryName, alternateDirectory);
130
176
  if ((await readFileSafely(target, ATTRIBUTES, 256)).toString('utf8') !== SNAPSHOT_ATTRIBUTES)
131
177
  throw new RemixReferenceError('remix_reference_changed');
132
- const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map(file => file.path)]);
178
+ const expected = new Set([RECEIPT, ATTRIBUTES, ...record.files.map((file) => file.path)]);
133
179
  const walk = async (directory, prefix = '') => {
134
180
  for (const entry of await readdir(directory, { withFileTypes: true })) {
135
181
  const path = prefix ? `${prefix}/${entry.name}` : entry.name;
@@ -165,7 +211,9 @@ export async function batchReferenceMetadata(root) {
165
211
  await verifyReferenceMetadata(root, record, record.directoryName);
166
212
  verified.push(record);
167
213
  }
168
- catch { /* An unrelated invalid reference cannot block source capture. */ }
214
+ catch {
215
+ /* An unrelated invalid reference cannot block source capture. */
216
+ }
169
217
  }
170
218
  return verified;
171
219
  }
@@ -180,8 +228,8 @@ export async function batchReferenceEnvExamples(root) {
180
228
  return examples;
181
229
  }
182
230
  for (const record of items) {
183
- const files = record.files.filter(file => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '')
184
- && !isCredentialFile(file.sourcePath));
231
+ const files = record.files.filter((file) => /^\.env\.(?:example|sample|template)$/i.test(file.path.split('/').pop() ?? '') &&
232
+ !isCredentialFile(file.sourcePath));
185
233
  if (!files.length)
186
234
  continue;
187
235
  try {
@@ -189,7 +237,9 @@ export async function batchReferenceEnvExamples(root) {
189
237
  for (const file of files)
190
238
  examples.add(`${record.path}/${file.path}`);
191
239
  }
192
- catch { /* An unselected damaged reference must not stop freezing the project. */ }
240
+ catch {
241
+ /* An unselected damaged reference must not stop freezing the project. */
242
+ }
193
243
  }
194
244
  return examples;
195
245
  }
@@ -208,13 +258,16 @@ export async function rebindBatchRemixReferences(root, sourceDirectory, memberDi
208
258
  throw error;
209
259
  }
210
260
  for (const reference of selected) {
211
- const record = items.find(item => item.referenceId === reference.referenceId);
212
- if (reference.directoryName !== sourceDirectory || !record || record.contentHash !== reference.snapshotHash)
261
+ const record = items.find((item) => item.referenceId === reference.referenceId);
262
+ if (reference.directoryName !== sourceDirectory ||
263
+ !record ||
264
+ record.contentHash !== reference.snapshotHash)
213
265
  throw new RemixReferenceError('context_remix_reference_changed');
214
266
  }
215
267
  if (selected.length) {
216
268
  const manifest = JSON.parse((await readFileSafely(rootReal, WORKSPACE_REMIX_REFERENCES_MANIFEST, MANIFEST_MAX_BYTES)).toString('utf8'));
217
- if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) && selected.some(reference => manifest.batchPolicyWithheldReferenceIds.includes(reference.referenceId)))
269
+ if (Array.isArray(manifest.batchPolicyWithheldReferenceIds) &&
270
+ selected.some((reference) => manifest.batchPolicyWithheldReferenceIds.includes(reference.referenceId)))
218
271
  throw new RemixReferenceError('batch_reference_files_withheld');
219
272
  }
220
273
  // Selected references must be complete. Other records need only valid metadata to be rebound;
@@ -222,7 +275,7 @@ export async function rebindBatchRemixReferences(root, sourceDirectory, memberDi
222
275
  const rebound = [];
223
276
  const writable = [];
224
277
  for (const record of items) {
225
- const isSelected = selected.some(reference => reference.referenceId === record.referenceId);
278
+ const isSelected = selected.some((reference) => reference.referenceId === record.referenceId);
226
279
  try {
227
280
  if (record.directoryName !== sourceDirectory && record.directoryName !== memberDirectory)
228
281
  throw new RemixReferenceError('remix_reference_manifest_invalid');
@@ -274,7 +327,7 @@ export async function resolveWorkspaceRemixReference(root, directoryName, refere
274
327
  workspaceDirectoryNameSchema.parse(directoryName);
275
328
  try {
276
329
  const rootReal = await realpath(root);
277
- const item = (await readManifest(rootReal)).find(item => item.referenceId === referenceId);
330
+ const item = (await readManifest(rootReal)).find((item) => item.referenceId === referenceId);
278
331
  if (!item)
279
332
  throw new RemixReferenceError('context_remix_reference_unavailable');
280
333
  if (item.contentHash !== snapshotHash)
@@ -289,15 +342,38 @@ export async function resolveWorkspaceRemixReference(root, directoryName, refere
289
342
  }
290
343
  }
291
344
  async function trackReference(root, record) {
292
- const environment = { PATH: process.env.PATH, HOME: root, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0' };
345
+ const environment = {
346
+ PATH: process.env.PATH,
347
+ HOME: root,
348
+ GIT_CONFIG_NOSYSTEM: '1',
349
+ GIT_CONFIG_GLOBAL: '/dev/null',
350
+ GIT_TERMINAL_PROMPT: '0',
351
+ };
293
352
  const gitOptions = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-C', root];
294
- const paths = [WORKSPACE_REMIX_REFERENCES_MANIFEST, `${record.path}/${RECEIPT}`, `${record.path}/${ATTRIBUTES}`,
295
- ...record.files.map(file => `${record.path}/${file.path}`)];
353
+ const paths = [
354
+ WORKSPACE_REMIX_REFERENCES_MANIFEST,
355
+ `${record.path}/${RECEIPT}`,
356
+ `${record.path}/${ATTRIBUTES}`,
357
+ ...record.files.map((file) => `${record.path}/${file.path}`),
358
+ ];
296
359
  // Reject higher-priority repository attributes before Git can execute a conversion filter.
297
360
  const attributes = await new Promise((resolve, reject) => {
298
- const child = execFile('git', [...gitOptions, 'check-attr', '-z', '--stdin', 'filter', 'text', 'ident', 'working-tree-encoding', 'eol'], {
299
- timeout: 60_000, maxBuffer: 40 * 1024 * 1024, env: environment, encoding: 'utf8',
300
- }, (error, stdout) => error ? reject(error) : resolve(stdout));
361
+ const child = execFile('git', [
362
+ ...gitOptions,
363
+ 'check-attr',
364
+ '-z',
365
+ '--stdin',
366
+ 'filter',
367
+ 'text',
368
+ 'ident',
369
+ 'working-tree-encoding',
370
+ 'eol',
371
+ ], {
372
+ timeout: 60_000,
373
+ maxBuffer: 40 * 1024 * 1024,
374
+ env: environment,
375
+ encoding: 'utf8',
376
+ }, (error, stdout) => (error ? reject(error) : resolve(stdout)));
301
377
  child.stdin?.end(`${paths.join('\0')}\0`);
302
378
  });
303
379
  const fields = attributes.split('\0');
@@ -305,15 +381,24 @@ async function trackReference(root, record) {
305
381
  throw new RemixReferenceError('remix_reference_git_attributes');
306
382
  for (let index = 0; index < fields.length - 1; index += 3) {
307
383
  const [path, attribute, value] = fields.slice(index, index + 3);
308
- if (path === WORKSPACE_REMIX_REFERENCES_MANIFEST && (attribute === 'text' || attribute === 'eol'))
384
+ if (path === WORKSPACE_REMIX_REFERENCES_MANIFEST &&
385
+ (attribute === 'text' || attribute === 'eol'))
309
386
  continue;
310
387
  if (value !== 'unset' && value !== 'unspecified')
311
388
  throw new RemixReferenceError('remix_reference_git_attributes');
312
389
  }
313
390
  // A source file may have been tracked despite its old ignore rules. Track this approved snapshot explicitly.
314
- await exec('git', [...gitOptions,
315
- '--literal-pathspecs', 'add', '--force', '--', record.path, WORKSPACE_REMIX_REFERENCES_MANIFEST], {
316
- timeout: 60_000, maxBuffer: 1024 * 1024,
391
+ await exec('git', [
392
+ ...gitOptions,
393
+ '--literal-pathspecs',
394
+ 'add',
395
+ '--force',
396
+ '--',
397
+ record.path,
398
+ WORKSPACE_REMIX_REFERENCES_MANIFEST,
399
+ ], {
400
+ timeout: 60_000,
401
+ maxBuffer: 1024 * 1024,
317
402
  env: environment,
318
403
  });
319
404
  }
@@ -325,15 +410,20 @@ async function importReference(root, directoryName, input, assertCurrent) {
325
410
  const snapshot = parsed.data;
326
411
  if (snapshot.contentHash !== hash(remixReferenceContentHashInput(snapshot.files)))
327
412
  throw new RemixReferenceError('remix_reference_changed');
328
- const plannedFiles = snapshot.files.map(file => {
413
+ const plannedFiles = snapshot.files.map((file) => {
329
414
  const bytes = Buffer.from(file.content, 'base64');
330
- if (bytes.toString('base64') !== file.content || bytes.length !== file.size || hash(bytes) !== file.sha256)
415
+ if (bytes.toString('base64') !== file.content ||
416
+ bytes.length !== file.size ||
417
+ hash(bytes) !== file.sha256)
331
418
  throw new RemixReferenceError('remix_reference_changed');
332
- if (bytes.subarray(0, 128).toString('utf8').startsWith('version https://git-lfs.github.com/spec/v1'))
419
+ if (bytes
420
+ .subarray(0, 128)
421
+ .toString('utf8')
422
+ .startsWith('version https://git-lfs.github.com/spec/v1'))
333
423
  throw new RemixReferenceError('remix_reference_external_resource');
334
424
  return { ...file, bytes, targetPath: materializedPath(file.path) };
335
425
  });
336
- const files = plannedFiles.filter(file => file.targetPath !== undefined);
426
+ const files = plannedFiles.filter((file) => file.targetPath !== undefined);
337
427
  if (!files.length)
338
428
  throw new RemixReferenceError('remix_reference_empty');
339
429
  const rootReal = await realpath(root);
@@ -341,7 +431,7 @@ async function importReference(root, directoryName, input, assertCurrent) {
341
431
  const path = referencePath(referenceId, snapshot.source.commit);
342
432
  const items = await readManifest(rootReal);
343
433
  await safeDirectory(rootReal, '.playcraft', true);
344
- let record = items.find(item => item.referenceId === referenceId);
434
+ let record = items.find((item) => item.referenceId === referenceId);
345
435
  if (record) {
346
436
  await verifyReference(rootReal, record, directoryName);
347
437
  assertCurrent();
@@ -352,8 +442,11 @@ async function importReference(root, directoryName, input, assertCurrent) {
352
442
  throw new RemixReferenceError('remix_reference_limit');
353
443
  await safeDirectory(rootReal, WORKSPACE_REMIX_REFERENCES_DIRECTORY, true);
354
444
  const target = join(rootReal, path);
355
- const existing = await lstat(target).catch((error) => { if (error.code === 'ENOENT')
356
- return undefined; throw error; });
445
+ const existing = await lstat(target).catch((error) => {
446
+ if (error.code === 'ENOENT')
447
+ return undefined;
448
+ throw error;
449
+ });
357
450
  if (existing) {
358
451
  // A complete published directory is its recovery receipt when the process died before writing the manifest.
359
452
  if (!existing.isDirectory() || existing.isSymbolicLink())
@@ -364,10 +457,24 @@ async function importReference(root, directoryName, input, assertCurrent) {
364
457
  await verifyReference(rootReal, record, directoryName);
365
458
  }
366
459
  else {
367
- record = workspaceRemixReferenceSchema.parse({ referenceId, directoryName, path, name: snapshot.source.name,
368
- source: snapshot.source, contentHash: snapshot.contentHash, importedAt: new Date().toISOString(),
369
- files: files.map(file => ({ path: file.targetPath, sourcePath: file.path, sha256: file.sha256, size: file.size })),
370
- omittedFiles: plannedFiles.filter(file => file.targetPath === undefined).map(file => ({ path: file.path, sha256: file.sha256, size: file.size })) });
460
+ record = workspaceRemixReferenceSchema.parse({
461
+ referenceId,
462
+ directoryName,
463
+ path,
464
+ name: snapshot.source.name,
465
+ source: snapshot.source,
466
+ contentHash: snapshot.contentHash,
467
+ importedAt: new Date().toISOString(),
468
+ files: files.map((file) => ({
469
+ path: file.targetPath,
470
+ sourcePath: file.path,
471
+ sha256: file.sha256,
472
+ size: file.size,
473
+ })),
474
+ omittedFiles: plannedFiles
475
+ .filter((file) => file.targetPath === undefined)
476
+ .map((file) => ({ path: file.path, sha256: file.sha256, size: file.size })),
477
+ });
371
478
  const stage = await mkdtemp(join(dirname(rootReal), '.remix-reference-'));
372
479
  try {
373
480
  for (const file of files) {
@@ -411,7 +518,9 @@ async function importReference(root, directoryName, input, assertCurrent) {
411
518
  }
412
519
  export async function importWorkspaceRemixReference(root, directoryName, snapshot, assertCurrent = () => { }) {
413
520
  const previous = locks.get(root) ?? Promise.resolve();
414
- const current = previous.catch(() => undefined).then(() => importReference(root, directoryName, snapshot, assertCurrent));
521
+ const current = previous
522
+ .catch(() => undefined)
523
+ .then(() => importReference(root, directoryName, snapshot, assertCurrent));
415
524
  locks.set(root, current);
416
525
  try {
417
526
  return await current;
@@ -0,0 +1,74 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { starterTemplateTreeHashInput, workspaceStarterTemplateSnapshotSchema } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { mergeStarterTemplate } from '../batch/source-capture.js';
6
+ import { SHARED_DIRECTORY_MODE, sharePathWithGroup, workspaceGroup } from './shared-write.js';
7
+ /** Prepare the trusted starter in a fresh staging directory; the checkout is never overwritten. */
8
+ export async function prepareStarterTemplate(root, input, check) {
9
+ const snapshot = workspaceStarterTemplateSnapshotSchema.parse(input);
10
+ const hash = (body) => createHash('sha256').update(body).digest('hex');
11
+ const files = new Map();
12
+ try {
13
+ mergeStarterTemplate(files, { snapshot, treeHash: hash(starterTemplateTreeHashInput(snapshot.files)) });
14
+ }
15
+ catch {
16
+ throw new Error('remix_reference_incomplete');
17
+ }
18
+ const realRoot = await realpath(root);
19
+ const group = await workspaceGroup(realRoot);
20
+ const tempRoot = join(realRoot, '.tmp');
21
+ let created = false;
22
+ try {
23
+ await mkdir(tempRoot, { mode: SHARED_DIRECTORY_MODE });
24
+ created = true;
25
+ }
26
+ catch (error) {
27
+ if (error.code !== 'EEXIST')
28
+ throw error;
29
+ }
30
+ if (created)
31
+ await sharePathWithGroup(tempRoot, group, SHARED_DIRECTORY_MODE);
32
+ const temp = await lstat(tempRoot);
33
+ const sharedTemp = temp.isDirectory() && !temp.isSymbolicLink() && await realpath(tempRoot) === tempRoot &&
34
+ temp.gid === group && (temp.mode & 0o070) === 0o070;
35
+ const workspace = await lstat(realRoot);
36
+ if (!sharedTemp && (workspace.gid !== group || (workspace.mode & 0o070) !== 0o070))
37
+ throw new Error('remix_reference_path_conflict');
38
+ const stage = await mkdtemp(join(sharedTemp ? tempRoot : realRoot, sharedTemp ? 'starter-template-' : '.starter-template-'));
39
+ try {
40
+ await sharePathWithGroup(stage, group, SHARED_DIRECTORY_MODE);
41
+ const stageStat = await lstat(stage);
42
+ if (stageStat.gid !== group || (stageStat.mode & 0o070) !== 0o070)
43
+ throw new Error('remix_reference_path_conflict');
44
+ const directories = new Set([stage]);
45
+ for (const [path, file] of files) {
46
+ check();
47
+ const target = join(stage, path);
48
+ const parts = path.split('/').slice(0, -1);
49
+ let parent = stage;
50
+ for (const part of parts) {
51
+ parent = join(parent, part);
52
+ if (directories.has(parent))
53
+ continue;
54
+ await mkdir(parent, { mode: SHARED_DIRECTORY_MODE });
55
+ await sharePathWithGroup(parent, group, SHARED_DIRECTORY_MODE);
56
+ const current = await lstat(parent);
57
+ if (current.gid !== group || (current.mode & 0o070) !== 0o070)
58
+ throw new Error('remix_reference_path_conflict');
59
+ directories.add(parent);
60
+ }
61
+ // shared: the staged starter is handed to both sandbox identities.
62
+ await writeFile(target, file.body, { flag: 'wx', mode: file.mode === 0o755 ? 0o750 : 0o640 });
63
+ await chmod(target, file.mode === 0o755 ? 0o750 : 0o640);
64
+ if ((await lstat(target)).gid !== group)
65
+ throw new Error('remix_reference_path_conflict');
66
+ }
67
+ check();
68
+ return { path: stage, templateId: snapshot.templateId, commit: snapshot.commit };
69
+ }
70
+ catch (error) {
71
+ await rm(stage, { recursive: true, force: true });
72
+ throw error;
73
+ }
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -25,6 +25,7 @@
25
25
  "dev": "tsc -w",
26
26
  "build": "node scripts/generate-asset-skills.mjs && node scripts/generate-video-skills.mjs && node scripts/generate-parameter-skill.mjs && node scripts/generate-level-editor-skill.mjs && node scripts/generate-preview-runtime.mjs && tsc",
27
27
  "start": "node dist/index.js",
28
+ "typecheck": "tsc -p tsconfig.json --noEmit",
28
29
  "test": "pnpm build && vitest run",
29
30
  "test:agent": "node scripts/run-agent-tests.mjs --list",
30
31
  "test:agent:designer": "node scripts/run-agent-tests.mjs game-designer",
@@ -56,8 +57,8 @@
56
57
  "@gltf-transform/core": "^4.3.0",
57
58
  "@gltf-transform/extensions": "^4.3.0",
58
59
  "@gltf-transform/functions": "^4.3.0",
59
- "@playcraft/build": "0.1.71",
60
- "@playcraft/common": "0.1.71",
60
+ "@playcraft/build": "0.1.73",
61
+ "@playcraft/common": "0.1.73",
61
62
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
62
63
  "chokidar": "^4.0.3",
63
64
  "commander": "^13.1.0",