@playcraft/cli 0.1.55-beta → 0.1.56-beta

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.
Files changed (25) hide show
  1. package/dist/commands/build.js +38 -0
  2. package/dist/workspace-runtime/previews/finalize.js +64 -0
  3. package/dist/workspace-runtime/previews/publish.js +31 -8
  4. package/dist/workspace-runtime/server/http.js +5 -5
  5. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  6. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  7. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  8. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  9. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  10. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  11. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  12. package/dist/workspace-runtime/workspaces/git.js +13 -0
  13. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  14. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  15. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  16. package/package.json +3 -3
  17. package/project-templates/empty/AGENTS.md +1 -1
  18. package/templates/preview-runtime/v1/README.md +8 -0
  19. package/templates/preview-runtime/v1/manifest.json +7 -7
  20. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  21. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  22. package/templates/preview-runtime/v1/runtime.mjs +238 -47
  23. package/templates/preview-runtime/v1/types/adapters.d.ts +3 -1
  24. package/templates/preview-runtime/v1/types/preview.d.ts +1 -0
  25. package/templates/preview-runtime/v1/types/protocol.d.ts +37 -0
@@ -6,6 +6,8 @@ import sharp from 'sharp';
6
6
  import { WORKSPACE_ARTIFACT_OUTPUT_DIRECTORY, isWorkspaceArtifactOutputPath, isWorkspaceArtifactOutputVersion, workspaceArtifactContentQuerySchema, workspaceArtifactListSchema, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { artifactBundleDigest, artifactDirectory, artifactFormat, artifactPath, artifactResources, snapshotArtifactBundle, snapshotArtifactFile } from './artifact-files.js';
8
8
  import { ARTIFACT_RECEIPTS_DIRECTORY, readArtifactReceipt } from './artifact-receipts.js';
9
+ import { validateWorkspacePlayable } from '../previews/finalize.js';
10
+ import { PREVIEW_HTML_MAX_BYTES, WORKSPACE_PLAYABLE_OUTPUT_PATH } from '@playcraft/common/cloud-workspace/runtime';
9
11
  const serial = new Map();
10
12
  function digest(text) { return createHash('sha256').update(text).digest('hex'); }
11
13
  function artifactId(workspaceId, path) { return `artifact_${digest(`${workspaceId}\0${path}`).slice(0, 40)}`; }
@@ -23,13 +25,18 @@ function outputArtifacts(artifacts) {
23
25
  export class WorkspaceArtifactStore {
24
26
  stateDir;
25
27
  workspaceId;
26
- constructor(stateDir, workspaceId) {
28
+ directoryName;
29
+ constructor(stateDir, workspaceId, directoryName = 'basic') {
27
30
  this.stateDir = stateDir;
28
31
  this.workspaceId = workspaceId;
32
+ this.directoryName = directoryName;
33
+ if (!/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
34
+ throw new Error('invalid_workspace_directory');
29
35
  }
36
+ get scopeId() { return this.directoryName === 'basic' ? this.workspaceId : `${this.workspaceId}:${this.directoryName}`; }
30
37
  async directory() {
31
38
  await mkdir(this.stateDir, { recursive: true, mode: 0o700 });
32
- return artifactDirectory(this.stateDir, `artifacts/${digest(this.workspaceId)}`);
39
+ return artifactDirectory(this.stateDir, `artifacts/${digest(this.scopeId)}`);
33
40
  }
34
41
  async read(directory) {
35
42
  const handle = await open(join(directory, 'catalog.json'), constants.O_RDONLY | constants.O_NOFOLLOW).catch(error => {
@@ -43,10 +50,13 @@ export class WorkspaceArtifactStore {
43
50
  const stat = await handle.stat();
44
51
  if (!stat.isFile() || stat.size > 32 * 1024 * 1024)
45
52
  throw new Error('artifact_catalog_invalid');
46
- const { receipts, observations = {}, ...body } = JSON.parse(await handle.readFile('utf8'));
53
+ const { receipts, observations = {}, playable, ...body } = JSON.parse(await handle.readFile('utf8'));
47
54
  if (!Array.isArray(receipts) || receipts.some(id => typeof id !== 'string'))
48
55
  throw new Error('artifact_catalog_invalid');
49
- return { ...workspaceArtifactListSchema.parse(body), receipts, observations };
56
+ if (playable && (!Array.isArray(playable.pending) || playable.pending.some(value => typeof value !== 'string')
57
+ || playable.rejectedDigest !== undefined && !/^[a-f0-9]{64}$/.test(playable.rejectedDigest)))
58
+ throw new Error('artifact_catalog_invalid');
59
+ return { ...workspaceArtifactListSchema.parse(body), receipts, observations, ...(playable ? { playable } : {}) };
50
60
  }
51
61
  finally {
52
62
  await handle.close();
@@ -67,10 +77,10 @@ export class WorkspaceArtifactStore {
67
77
  throw error; });
68
78
  }
69
79
  }
70
- async discover(root, source = { kind: 'discovered' }, sourcePaths = []) {
71
- const key = `${this.stateDir}\0${this.workspaceId}`;
80
+ async discover(root, source = { kind: 'discovered' }, sourcePaths = [], playable = {}) {
81
+ const key = `${this.stateDir}\0${this.scopeId}`;
72
82
  const previous = serial.get(key) ?? Promise.resolve();
73
- const task = previous.catch(() => undefined).then(() => this.scan(root, source, sourcePaths));
83
+ const task = previous.catch(() => undefined).then(() => this.scan(root, source, sourcePaths, playable));
74
84
  serial.set(key, task);
75
85
  try {
76
86
  return await task;
@@ -84,11 +94,30 @@ export class WorkspaceArtifactStore {
84
94
  const catalog = await this.read(await this.directory());
85
95
  return workspaceArtifactListSchema.parse({ artifacts: outputArtifacts(catalog.artifacts), scannedAt: catalog.scannedAt });
86
96
  }
87
- async scan(root, source, sourcePaths) {
97
+ async rejectedPlayable(bytes) {
98
+ return (await this.read(await this.directory())).playable?.rejectedDigest === createHash('sha256').update(bytes).digest('hex');
99
+ }
100
+ async hasPendingPlayableExecution(executionId) {
101
+ return (await this.read(await this.directory())).playable?.pending.some(id => id.split('/').length > 2 && id !== executionId) ?? false;
102
+ }
103
+ async isCurrentPlayable(bytes) {
104
+ const catalog = await this.read(await this.directory());
105
+ const artifact = catalog.artifacts.find(item => item.kind === 'playable' && item.versions.some(version => version.path === WORKSPACE_PLAYABLE_OUTPUT_PATH));
106
+ return artifact?.versions.find(item => item.id === artifact.currentVersionId)?.sha256 === createHash('sha256').update(bytes).digest('hex');
107
+ }
108
+ async scan(root, source, sourcePaths, playable) {
88
109
  const directory = await this.directory();
89
110
  const objects = await artifactDirectory(directory, 'objects');
90
111
  const catalog = await this.read(directory);
91
112
  const original = JSON.stringify(outputArtifacts(catalog.artifacts));
113
+ const gate = catalog.playable ??= { pending: [] };
114
+ if (playable.mode === 'skip' || playable.mode === 'failure')
115
+ gate.initialized = true;
116
+ if (playable.executionId) {
117
+ gate.pending = gate.pending.filter(id => id !== playable.executionId && (playable.mode === 'hold' || !id.startsWith(`${playable.executionId}/`)));
118
+ if (playable.mode === 'hold')
119
+ gate.pending.push(playable.executionId);
120
+ }
92
121
  const receipts = await this.receipts(root, catalog);
93
122
  for (const receipt of receipts) {
94
123
  try {
@@ -107,7 +136,12 @@ export class WorkspaceArtifactStore {
107
136
  ...(receipt.width ? { width: receipt.width } : {}), ...(receipt.height ? { height: receipt.height } : {}),
108
137
  ...(receipt.duration !== undefined ? { duration: receipt.duration } : {}),
109
138
  };
110
- this.add(catalog, version);
139
+ const kind = await this.versionKind(objects, version);
140
+ await this.add(catalog, version, kind, objects);
141
+ if (kind === 'playable') {
142
+ gate.rejectedDigest = undefined;
143
+ gate.initialized = true;
144
+ }
111
145
  catalog.receipts.push(receipt.id);
112
146
  }
113
147
  catch { /* An incomplete receipt stays pending; the next scan retries its original bytes. */ }
@@ -120,12 +154,17 @@ export class WorkspaceArtifactStore {
120
154
  for (const path of candidates) {
121
155
  if (members.has(path))
122
156
  continue;
157
+ if (path === WORKSPACE_PLAYABLE_OUTPUT_PATH && ['hold', 'skip'].includes(playable.mode ?? ''))
158
+ continue;
159
+ if (path === WORKSPACE_PLAYABLE_OUTPUT_PATH && gate.initialized && !['success', 'failure'].includes(playable.mode ?? ''))
160
+ continue;
123
161
  try {
124
- const current = visible.find(artifact => artifact.id === artifactId(this.workspaceId, path));
162
+ const current = visible.find(artifact => artifact.id === artifactId(this.scopeId, path));
125
163
  const version = current?.versions.find(entry => entry.id === current.currentVersionId);
126
164
  const knownWrite = sourcePaths.includes(path) && Boolean(source.operationId) && version?.source.operationId !== source.operationId;
127
165
  const observation = catalog.observations[path];
128
- if (!knownWrite && version && observation?.versionId === version.id && observation.signature === await fileSignature(root, version.files.map(file => file.path)))
166
+ if (!knownWrite && version && (path !== WORKSPACE_PLAYABLE_OUTPUT_PATH || current?.kind === 'playable')
167
+ && observation?.versionId === version.id && observation.signature === await fileSignature(root, version.files.map(file => file.path)))
129
168
  continue;
130
169
  const thumbnail = version?.files.find(file => file.role === 'thumbnail');
131
170
  const thumbnailPath = thumbnail && await lstat(join(root, thumbnail.path)).then(stat => stat.isFile(), () => false) ? thumbnail.path : undefined;
@@ -137,18 +176,37 @@ export class WorkspaceArtifactStore {
137
176
  for (const file of files)
138
177
  if (file.role !== 'primary')
139
178
  members.add(file.path);
140
- if (!knownWrite && version && artifactBundleDigest(version.files) === artifactBundleDigest(files)) {
179
+ if (!knownWrite && version && (path !== WORKSPACE_PLAYABLE_OUTPUT_PATH || current?.kind === 'playable')
180
+ && artifactBundleDigest(version.files) === artifactBundleDigest(files)) {
141
181
  catalog.observations[path] = { signature, versionId: version.id };
142
182
  continue;
143
183
  }
144
184
  const primary = files[0];
185
+ if (path === WORKSPACE_PLAYABLE_OUTPUT_PATH) {
186
+ if (playable.mode === 'failure') {
187
+ gate.rejectedDigest = primary.sha256;
188
+ gate.initialized = true;
189
+ continue;
190
+ }
191
+ if (playable.mode === 'success'
192
+ ? gate.pending.some(id => id.split('/').length > 2)
193
+ : gate.pending.length > 0)
194
+ continue;
195
+ if (gate.rejectedDigest === primary.sha256 && !playable.force)
196
+ continue;
197
+ }
145
198
  const next = {
146
199
  id: `version_${knownWrite ? source.operationId : randomUUID()}`, createdAt: new Date().toISOString(),
147
200
  path, mimeType: primary.mimeType, size: primary.size, sha256: primary.sha256, files,
148
201
  source: sourcePaths.includes(path) ? source : { kind: 'discovered' },
149
202
  ...await this.dimensions(objects, primary),
150
203
  };
151
- this.add(catalog, next);
204
+ const kind = await this.versionKind(objects, next);
205
+ await this.add(catalog, next, kind, objects);
206
+ if (kind === 'playable') {
207
+ gate.rejectedDigest = undefined;
208
+ gate.initialized = true;
209
+ }
152
210
  catalog.observations[path] = { signature, versionId: next.id };
153
211
  }
154
212
  catch { /* Files being written or temporarily missing are retried, never published as complete. */ }
@@ -163,13 +221,51 @@ export class WorkspaceArtifactStore {
163
221
  await this.write(directory, catalog);
164
222
  return { list, changed: original !== JSON.stringify(list.artifacts) };
165
223
  }
166
- add(catalog, version) {
167
- const id = artifactId(this.workspaceId, version.path);
224
+ async versionKind(objects, version) {
225
+ if (version.path !== WORKSPACE_PLAYABLE_OUTPUT_PATH)
226
+ return artifactFormat(version.path)[0];
227
+ if (version.files.length !== 1)
228
+ throw new Error('preview_not_single_file');
229
+ const primary = version.files[0];
230
+ const file = await open(join(objects, primary.sha256), constants.O_RDONLY | constants.O_NOFOLLOW);
231
+ try {
232
+ const stat = await file.stat();
233
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > PREVIEW_HTML_MAX_BYTES)
234
+ throw new Error('preview_not_single_file');
235
+ const bytes = await file.readFile();
236
+ if (bytes.length !== primary.size || createHash('sha256').update(bytes).digest('hex') !== primary.sha256)
237
+ throw new Error('artifact_snapshot_invalid');
238
+ validateWorkspacePlayable(bytes);
239
+ }
240
+ finally {
241
+ await file.close();
242
+ }
243
+ return 'playable';
244
+ }
245
+ async add(catalog, version, kind, objects) {
246
+ const id = artifactId(this.scopeId, version.path);
168
247
  let artifact = catalog.artifacts.find(item => item.id === id);
169
248
  if (!artifact) {
170
- artifact = { id, kind: artifactFormat(version.path)[0], name: basename(version.path), currentVersionId: version.id, updatedAt: version.createdAt, versions: [] };
249
+ artifact = { id, kind, name: basename(version.path), currentVersionId: version.id, updatedAt: version.createdAt, versions: [] };
171
250
  catalog.artifacts.push(artifact);
172
251
  }
252
+ if (kind === 'playable' && artifact.kind !== 'playable') {
253
+ const valid = await Promise.all(artifact.versions.map(async (item) => {
254
+ try {
255
+ return await this.versionKind(objects, item) === 'playable' ? item : undefined;
256
+ }
257
+ catch {
258
+ return undefined;
259
+ }
260
+ }));
261
+ artifact.versions = valid.filter((item) => Boolean(item));
262
+ }
263
+ artifact.kind = kind;
264
+ if (this.directoryName !== 'basic')
265
+ version.directoryName = this.directoryName;
266
+ const current = artifact.versions.find(item => item.id === artifact.currentVersionId);
267
+ if (kind === 'playable' && current && artifactBundleDigest(current.files) === artifactBundleDigest(version.files))
268
+ return;
173
269
  if (!artifact.versions.some(item => item.id === version.id)) {
174
270
  // Display ordering is monotonic; the original generation time stays in source.generatedAt.
175
271
  const latest = Math.max(0, ...artifact.versions.map(item => Date.parse(item.createdAt)));
@@ -191,7 +287,9 @@ export class WorkspaceArtifactStore {
191
287
  return [];
192
288
  }
193
289
  const seen = new Set(catalog.receipts), receipts = [];
194
- for (const name of await readdir(directory)) {
290
+ // A receipt tree the current identity cannot read is skipped rather than failing the scan.
291
+ const names = await readdir(directory).catch(() => []);
292
+ for (const name of names) {
195
293
  if (!/^[a-f0-9-]{36}\.json$/.test(name) || seen.has(name.slice(0, -5)))
196
294
  continue;
197
295
  try {
@@ -5,11 +5,15 @@ async function shareWithWorkspaceGroup(root, handle, directory) {
5
5
  if (!workspace.isDirectory() || workspace.isSymbolicLink()
6
6
  || (directory ? !target.isDirectory() : !target.isFile() || target.nlink !== 1))
7
7
  throw new Error('invalid_workspace_asset');
8
+ const shared = directory ? 0o2770 : 0o660;
9
+ // Already shared: the other identity owns this entry and cannot re-shape it from here.
10
+ if (target.gid === workspace.gid && (target.mode & 0o7777) === shared)
11
+ return;
8
12
  if (target.gid !== workspace.gid)
9
13
  await handle.chown(target.uid, workspace.gid);
10
14
  // Match directory-access.ts: the development group can read and edit public project files.
11
15
  // Private transfers, chunks, transaction records and backups never use these helpers.
12
- await handle.chmod(directory ? 0o2770 : 0o660);
16
+ await handle.chmod(shared);
13
17
  }
14
18
  export async function shareWorkspaceAssetFile(root, handle) {
15
19
  await shareWithWorkspaceGroup(root, handle, false);
@@ -1,15 +1,22 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { copyFile, mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
3
- import { basename, dirname, join } from 'node:path';
2
+ import { constants } from 'node:fs';
3
+ import { copyFile, cp, chmod, lstat, mkdir, mkdtemp, readdir, readlink, rename, rm, stat, symlink } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, dirname, join, relative, sep } from 'node:path';
4
6
  import { createPlatformAdapter, getChannelRuleProfile, } from '@playcraft/build';
5
7
  import { DELIVERY_QUEUE_LIMIT, isWorkspaceArtifactOutputPath, } from '@playcraft/common/cloud-workspace/runtime';
6
8
  import { workspaceRoot } from './registry.js';
7
9
  import { excludeFromGit, ensureWorkspaceDependencies } from './dependencies.js';
10
+ import { listWorkspaceBuildInputs } from './git.js';
8
11
  import { runDeveloperCommand } from './developer-command.js';
12
+ import { openScratchToDevelopment } from './directory-access.js';
9
13
  import { DeliveryStore } from './delivery-store.js';
10
14
  import { DeliveryValidationError, validateDeliveryArtifact } from './delivery.js';
11
15
  export const DELIVERY_BUILD_TIMEOUT_MS = 10 * 60_000;
16
+ /** Staging under the workspace from earlier runtimes; kept out of Git so leftovers never commit. */
12
17
  const STAGING_DIRECTORY = '.playcraft-delivery';
18
+ /** Never copied into the build copy: history, outputs and caches that the build neither reads nor owns. */
19
+ const ISOLATE_SKIP = new Set(['.git', 'dist', '.turbo', STAGING_DIRECTORY]);
13
20
  export class DeliveryBuildError extends Error {
14
21
  code;
15
22
  constructor(code) {
@@ -204,9 +211,77 @@ async function copyToDist(root, staging, channel, format) {
204
211
  }
205
212
  return { primary: `dist/delivery/${channel}/${primary}`, paths, sizeBytes };
206
213
  }
214
+ /** Copy-on-write clone where the filesystem supports it (APFS, btrfs, XFS); a plain copy elsewhere. */
215
+ const CLONE = constants.COPYFILE_FICLONE;
216
+ const COPY_CONCURRENCY = 16;
217
+ function skipped(path) {
218
+ const parts = path.split(/[\\/]/);
219
+ return ISOLATE_SKIP.has(parts[0]) || parts.includes('node_modules');
220
+ }
221
+ async function copyInput(root, destination, path) {
222
+ const source = join(root, path);
223
+ const target = join(destination, path);
224
+ let entry;
225
+ try {
226
+ entry = await lstat(source);
227
+ }
228
+ catch (error) {
229
+ // A tracked file deleted in the work tree is not a build input.
230
+ if (error.code === 'ENOENT')
231
+ return;
232
+ throw error;
233
+ }
234
+ await mkdir(dirname(target), { recursive: true });
235
+ if (entry.isSymbolicLink())
236
+ await symlink(await readlink(source), target);
237
+ else if (entry.isDirectory())
238
+ await cp(source, target, { recursive: true, verbatimSymlinks: true, mode: CLONE });
239
+ else
240
+ await copyFile(source, target, CLONE);
241
+ }
207
242
  /**
208
- * Runs one channel build. The build writes into a disposable staging directory; only the final
209
- * artifact is copied into dist so the builder's `--clean` can never erase another delivery.
243
+ * Copies the workspace into `destination` for one build, like the platform export runner, with the
244
+ * top-level `node_modules` linked (the build only reads it). Builders rewrite project files in place
245
+ * (`builds.config.js` and its `.bak`, `package.json` scripts, migrated imports), so the build must
246
+ * never run in the workspace itself, where autosave would commit those rewrites.
247
+ *
248
+ * Only the files Git would carry are copied: tracked ones and untracked ones `.gitignore` keeps, so
249
+ * caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
250
+ */
251
+ export async function isolateWorkspaceForBuild(root, destination) {
252
+ await mkdir(destination, { recursive: true });
253
+ // The build runs as the development identity and writes its scratch folder inside this copy.
254
+ await chmod(destination, 0o2770);
255
+ const modules = join(root, 'node_modules');
256
+ const modulesEntry = await lstat(modules).catch(() => undefined);
257
+ if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
258
+ await symlink(modulesEntry.isSymbolicLink() ? await readlink(modules) : modules, join(destination, 'node_modules'), 'dir');
259
+ }
260
+ const inputs = await listWorkspaceBuildInputs(root).catch(() => undefined);
261
+ const paths = inputs
262
+ ?? (await readdir(root)).filter((name) => name !== 'node_modules');
263
+ const queue = paths.filter((path) => !skipped(path));
264
+ const workers = Array.from({ length: Math.min(COPY_CONCURRENCY, queue.length) }, async () => {
265
+ for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
266
+ if (inputs)
267
+ await copyInput(root, destination, path);
268
+ else {
269
+ await cp(join(root, path), join(destination, path), {
270
+ recursive: true,
271
+ verbatimSymlinks: true,
272
+ mode: CLONE,
273
+ // Nested dependency folders stay out as well; the build resolves through the linked root.
274
+ filter: (source) => !relative(join(root, path), source).split(sep).includes('node_modules'),
275
+ });
276
+ }
277
+ }
278
+ });
279
+ await Promise.all(workers);
280
+ }
281
+ /**
282
+ * Runs one channel build in a disposable copy of the workspace. The build writes into a disposable
283
+ * output directory; only the final artifact is copied into dist so the builder's `--clean` can never
284
+ * erase another delivery.
210
285
  */
211
286
  async function executeBuild(state, input, controller) {
212
287
  if (input.directoryName === 'root')
@@ -226,7 +301,8 @@ async function executeBuild(state, input, controller) {
226
301
  startedAt,
227
302
  };
228
303
  await store.write(base);
229
- const staging = join(root, STAGING_DIRECTORY, input.buildId);
304
+ let scratch;
305
+ let staging = '';
230
306
  let status = 'running';
231
307
  let error;
232
308
  let validationError;
@@ -238,7 +314,13 @@ async function executeBuild(state, input, controller) {
238
314
  let logChain = Promise.resolve();
239
315
  let record;
240
316
  try {
317
+ scratch = await mkdtemp(join(tmpdir(), 'playcraft-delivery-'));
318
+ // The build runs as the development identity while this scratch tree belongs to the controller.
319
+ await openScratchToDevelopment(scratch, (await lstat(root)).gid);
320
+ staging = join(scratch, 'output');
321
+ const project = join(scratch, 'project');
241
322
  await mkdir(staging, { recursive: true });
323
+ await chmod(staging, 0o2770);
242
324
  await excludeFromGit(root, `/${STAGING_DIRECTORY}/`);
243
325
  await excludeFromGit(root, '/dist/');
244
326
  const dependencies = await ensureWorkspaceDependencies(state, input.workspaceId, root);
@@ -248,11 +330,17 @@ async function executeBuild(state, input, controller) {
248
330
  }
249
331
  throw new DeliveryBuildError('delivery_dependencies_failed');
250
332
  }
333
+ try {
334
+ await isolateWorkspaceForBuild(root, project);
335
+ }
336
+ catch {
337
+ throw new DeliveryBuildError('delivery_isolation_failed');
338
+ }
251
339
  const planned = process.env.PLAYCRAFT_DELIVERY_BUILD_COMMAND || 'playcraft build';
252
340
  const command = `umask 002 && NO_COLOR=1 CI=1 ${planned} . --platform ${input.payload.channel} --format ${input.payload.format} --output ${shellQuote(staging)} --mode full`;
253
341
  const outcome = await runDeveloperCommand(state, {
254
342
  processId: `delivery-${input.buildId}`,
255
- cwd: root,
343
+ cwd: project,
256
344
  command,
257
345
  timeoutMs: DELIVERY_BUILD_TIMEOUT_MS,
258
346
  signal: controller.signal,
@@ -304,7 +392,9 @@ async function executeBuild(state, input, controller) {
304
392
  error = cause instanceof DeliveryBuildError ? cause.code : 'delivery_build_failed';
305
393
  }
306
394
  finally {
307
- await rm(staging, { recursive: true, force: true }).catch(() => undefined);
395
+ // `rm` removes the node_modules link, never its target.
396
+ if (scratch)
397
+ await rm(scratch, { recursive: true, force: true }).catch(() => undefined);
308
398
  await logChain;
309
399
  if (report) {
310
400
  await store.writeReport(input.buildId, report).catch(() => {
@@ -9,6 +9,19 @@ export async function grantWorkspaceTraversal(root, groupId) {
9
9
  await chown(root, stat.uid, groupId);
10
10
  await chmod(root, (stat.mode & 0o777) | 0o2010);
11
11
  }
12
+ /**
13
+ * The controller creates scratch trees under /tmp, but builds run as the development identity
14
+ * through the launcher. Without group ownership and group write the command cannot even stat its
15
+ * own cwd, and every build fails with EACCES before it starts.
16
+ */
17
+ export async function openScratchToDevelopment(path, groupId) {
18
+ const stat = await lstat(path);
19
+ if (!stat.isDirectory() || stat.isSymbolicLink())
20
+ throw new Error('invalid_scratch_directory');
21
+ if (stat.gid !== groupId)
22
+ await chown(path, stat.uid, groupId);
23
+ await chmod(path, 0o2770);
24
+ }
12
25
  /** The sandbox development group can edit engine directories. Symlinks never grant access outside the tree. */
13
26
  export async function openDirectoryToDevelopment(root, groupId) {
14
27
  const currentUid = typeof process.getuid === 'function' ? process.getuid() : undefined;
@@ -31,6 +31,19 @@ export async function probeGitBinary() {
31
31
  return error instanceof GitWorkspaceError && error.code === 'git_binary_missing' ? false : false;
32
32
  }
33
33
  }
34
+ /**
35
+ * Files a build may read: tracked ones plus untracked ones that `.gitignore` does not exclude, the
36
+ * same inputs a clone of the pushed branch would have. Undefined when the workspace has no repository.
37
+ */
38
+ export async function listWorkspaceBuildInputs(root) {
39
+ const rootReal = await realpath(root);
40
+ if (!(await gitMarkerExists(rootReal)))
41
+ return undefined;
42
+ const listed = await gitAllowFail(rootReal, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'], 60000);
43
+ if (listed.code !== 0)
44
+ return undefined;
45
+ return [...new Set(listed.stdout.split('\0').filter(Boolean))];
46
+ }
34
47
  export async function readWorkspaceGitStatus(root) {
35
48
  const rootReal = await ensureWorkspaceGit(root);
36
49
  const branchResult = await gitAllowFail(rootReal, ['symbolic-ref', '--short', 'HEAD']);
@@ -5,7 +5,7 @@ import { dirname, join, posix } from 'node:path';
5
5
  import sharp from 'sharp';
6
6
  import { AssetGenerationVideoValidator, parameterAssetFilePath, parameterAssetMimeType, parameterAssetValueError, } from '@playcraft/common/cloud-workspace/runtime';
7
7
  import { artifactPath } from './artifact-files.js';
8
- import { registerThemeAsset } from './theme-asset-index.js';
8
+ import { registerThemeAsset, themeAssetIndexFingerprint } from './theme-asset-index.js';
9
9
  export class ParameterAssetError extends Error {
10
10
  code;
11
11
  constructor(code) {
@@ -83,9 +83,13 @@ export async function validateParameterAssetFile(root, dataPath, value, asset) {
83
83
  }
84
84
  }
85
85
  const after = await file.stat(), pathStat = await lstat(await artifactPath(root, relative));
86
- if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size
87
- || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs
88
- || after.dev !== pathStat.dev || after.ino !== pathStat.ino)
86
+ if (before.dev !== after.dev ||
87
+ before.ino !== after.ino ||
88
+ before.size !== after.size ||
89
+ before.mtimeMs !== after.mtimeMs ||
90
+ before.ctimeMs !== after.ctimeMs ||
91
+ after.dev !== pathStat.dev ||
92
+ after.ino !== pathStat.ino)
89
93
  throw new ParameterAssetError('parameter_asset_file_changed');
90
94
  }
91
95
  catch (error) {
@@ -99,7 +103,7 @@ export async function validateParameterAssetFile(root, dataPath, value, asset) {
99
103
  return relative;
100
104
  }
101
105
  /** Prepare all registry edits before changing any project file. Unknown registries stay untouched. */
102
- export async function prepareParameterAssetRegistration(root, dataPath, bindings) {
106
+ export async function prepareParameterAssetRegistration(root, dataPath, bindings, requiredImageValues = []) {
103
107
  if (!bindings.length)
104
108
  return undefined;
105
109
  if (!/^src\/theme\/[A-Za-z0-9_-]+\/theme\.data\.json5$/.test(dataPath))
@@ -123,7 +127,14 @@ export async function prepareParameterAssetRegistration(root, dataPath, bindings
123
127
  importPath = './' + importPath;
124
128
  next = registerThemeAsset(next, value, importPath);
125
129
  }
126
- return next === source ? undefined : { path: index, previousHash: digest(source), content: next };
130
+ return next === source
131
+ ? undefined
132
+ : {
133
+ path: index,
134
+ previousHash: digest(source),
135
+ previousFingerprint: themeAssetIndexFingerprint(source, requiredImageValues),
136
+ content: next,
137
+ };
127
138
  }
128
139
  /** Stage complete bytes and original metadata before atomically replacing the unchanged registry. */
129
140
  export async function writeParameterAssetRegistration(root, plan) {
@@ -141,7 +152,10 @@ export async function writeParameterAssetRegistration(root, plan) {
141
152
  let published = false;
142
153
  try {
143
154
  const before = await file.stat();
144
- if (!before.isFile() || before.nlink !== 1 || before.size > 1024 * 1024 || digest(await file.readFile()) !== plan.previousHash)
155
+ if (!before.isFile() ||
156
+ before.nlink !== 1 ||
157
+ before.size > 1024 * 1024 ||
158
+ digest(await file.readFile()) !== plan.previousHash)
145
159
  throw new ParameterAssetError('parameter_asset_index_conflict');
146
160
  const parent = await lstat(dirname(target));
147
161
  const bytes = Buffer.from(plan.content);
@@ -161,14 +175,21 @@ export async function writeParameterAssetRegistration(root, plan) {
161
175
  await staged.close();
162
176
  staged = undefined;
163
177
  const current = await lstat(await artifactPath(root, plan.path)), currentParent = await lstat(dirname(target));
164
- if (before.dev !== current.dev || before.ino !== current.ino || before.mtimeMs !== current.mtimeMs || before.ctimeMs !== current.ctimeMs
165
- || parent.dev !== currentParent.dev || parent.ino !== currentParent.ino
166
- || digest(await readFile(await artifactPath(root, plan.path))) !== plan.previousHash
167
- || digest(await readFile(temporary)) !== digest(bytes))
178
+ if (before.dev !== current.dev ||
179
+ before.ino !== current.ino ||
180
+ before.mtimeMs !== current.mtimeMs ||
181
+ before.ctimeMs !== current.ctimeMs ||
182
+ parent.dev !== currentParent.dev ||
183
+ parent.ino !== currentParent.ino ||
184
+ digest(await readFile(await artifactPath(root, plan.path))) !== plan.previousHash ||
185
+ digest(await readFile(temporary)) !== digest(bytes))
168
186
  throw new ParameterAssetError('parameter_asset_index_conflict');
169
- const temporaryPath = plan.path.slice(0, plan.path.lastIndexOf('/') + 1) + temporary.slice(temporary.lastIndexOf('/') + 1);
187
+ const temporaryPath = plan.path.slice(0, plan.path.lastIndexOf('/') + 1) +
188
+ temporary.slice(temporary.lastIndexOf('/') + 1);
170
189
  const checkedStage = await lstat(await artifactPath(root, temporaryPath));
171
- if (checkedStage.dev !== stagedStat.dev || checkedStage.ino !== stagedStat.ino || checkedStage.nlink !== 1)
190
+ if (checkedStage.dev !== stagedStat.dev ||
191
+ checkedStage.ino !== stagedStat.ino ||
192
+ checkedStage.nlink !== 1)
172
193
  throw new ParameterAssetError('parameter_asset_index_conflict');
173
194
  await rename(temporary, target);
174
195
  published = true;
@@ -183,7 +204,9 @@ export async function writeParameterAssetRegistration(root, plan) {
183
204
  await staged?.close();
184
205
  await file.close();
185
206
  if (!published)
186
- await unlink(temporary).catch(error => { if (error.code !== 'ENOENT')
187
- throw error; });
207
+ await unlink(temporary).catch((error) => {
208
+ if (error.code !== 'ENOENT')
209
+ throw error;
210
+ });
188
211
  }
189
212
  }