@playcraft/cli 0.1.55-beta → 0.1.57-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.
- package/dist/assets/client.js +6 -4
- package/dist/commands/build.js +38 -0
- package/dist/commands/tools-3d.js +15 -0
- package/dist/commands/tools-generation.js +3 -2
- package/dist/utils/agent-api-client.js +31 -8
- package/dist/utils/agent-generation-provenance.js +45 -0
- package/dist/utils/generation-workspace-scope.js +50 -0
- package/dist/utils/image-generation-retry.js +7 -0
- package/dist/utils/tool-operation-journal.js +34 -4
- package/dist/workspace-runtime/batch/prepare.js +9 -52
- package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
- package/dist/workspace-runtime/codex/launcher-client.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +23 -4
- package/dist/workspace-runtime/previews/finalize.js +64 -0
- package/dist/workspace-runtime/previews/publish.js +31 -8
- package/dist/workspace-runtime/serve.js +8 -30
- package/dist/workspace-runtime/server/http.js +5 -5
- package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
- package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
- package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
- package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
- package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
- package/dist/workspace-runtime/workspaces/git.js +13 -0
- package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
- package/dist/workspace-runtime/workspaces/parameters.js +61 -21
- package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
- package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
- package/package.json +3 -3
- package/project-template-v2/.playcraft/skills.lock.json +15 -8
- package/project-templates/empty/AGENTS.md +1 -1
- package/templates/preview-runtime/v1/README.md +12 -0
- package/templates/preview-runtime/v1/manifest.json +8 -8
- package/templates/preview-runtime/v1/runtime.d.mts +1 -1
- package/templates/preview-runtime/v1/runtime.d.ts +1 -1
- package/templates/preview-runtime/v1/runtime.mjs +591 -54
- package/templates/preview-runtime/v1/types/adapters.d.ts +6 -1
- package/templates/preview-runtime/v1/types/preview.d.ts +2 -0
- package/templates/preview-runtime/v1/types/protocol.d.ts +56 -0
- package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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 &&
|
|
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 &&
|
|
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.
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
209
|
-
*
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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(() => {
|
|
@@ -15,16 +15,21 @@ const SHARE_WAIT_MS = 60_000;
|
|
|
15
15
|
* prompts, and writes plain lines to the log. Package scripts run: the workspace is the user's own
|
|
16
16
|
* sandbox and the install runs as the development user.
|
|
17
17
|
*/
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
export function dependencyInstallCommand(state, options = {}) {
|
|
19
|
+
const store = process.env.PLAYCRAFT_PNPM_STORE ?? (process.platform === 'linux' ? '/var/cache/playcraft/pnpm-store' : join(state.config.stateDir, 'pnpm-store'));
|
|
20
|
+
const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
|
|
21
|
+
return `pnpm install --${options.frozenLockfile ? 'frozen' : 'no-frozen'}-lockfile --store-dir ${quotedStore} --reporter=append-only --config.confirm-modules-purge=false`;
|
|
22
|
+
}
|
|
23
|
+
/** pnpm owns installation; other package managers' lockfiles remain untouched. */
|
|
20
24
|
export async function planInstall(root) {
|
|
21
25
|
const manifest = await readOptional(join(root, 'package.json'));
|
|
22
26
|
if (manifest === undefined)
|
|
23
27
|
return { kind: 'skip' };
|
|
24
|
-
const lock =
|
|
28
|
+
const lock = await readOptional(join(root, 'pnpm-lock.yaml'));
|
|
25
29
|
return {
|
|
26
30
|
kind: 'install',
|
|
27
|
-
lockHash: createHash('sha256').update(manifest).update('\0').update(lock).digest('hex'),
|
|
31
|
+
lockHash: createHash('sha256').update(manifest).update('\0').update(lock ?? '').digest('hex'),
|
|
32
|
+
hasLockfile: lock !== undefined,
|
|
28
33
|
};
|
|
29
34
|
}
|
|
30
35
|
const running = new WeakMap();
|
|
@@ -108,7 +113,16 @@ export async function recoverInterruptedInstalls(journal) {
|
|
|
108
113
|
}
|
|
109
114
|
}
|
|
110
115
|
async function installOnce(state, workspaceId, root) {
|
|
111
|
-
|
|
116
|
+
let plan;
|
|
117
|
+
try {
|
|
118
|
+
plan = await planInstall(root);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
await state.journal.putWorkspaceDependencies({
|
|
122
|
+
workspaceId, status: 'failed', error: 'dependency_inputs_unreadable', finishedAt: new Date().toISOString(),
|
|
123
|
+
});
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
112
126
|
const current = state.journal.getWorkspaceDependencies(workspaceId);
|
|
113
127
|
if (plan.kind === 'skip') {
|
|
114
128
|
if (current?.status !== 'none') {
|
|
@@ -174,9 +188,7 @@ async function installOnce(state, workspaceId, root) {
|
|
|
174
188
|
*/
|
|
175
189
|
async function runInstall(state, workspaceId, root) {
|
|
176
190
|
// Tests replace the install; a real registry is never reached from them.
|
|
177
|
-
const
|
|
178
|
-
const quotedStore = `'${store.replaceAll("'", "'\\''")}'`;
|
|
179
|
-
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || INSTALL_COMMAND.replace('/var/cache/playcraft/pnpm-store', quotedStore);
|
|
191
|
+
const planned = process.env.PLAYCRAFT_DEPENDENCY_INSTALL_COMMAND || dependencyInstallCommand(state);
|
|
180
192
|
const logDir = join(state.config.stateDir, 'dependency-logs');
|
|
181
193
|
await mkdir(logDir, { recursive: true, mode: 0o700 });
|
|
182
194
|
const logPath = join(logDir, `${workspaceId}.log`);
|
|
@@ -265,8 +277,10 @@ async function readOptional(path) {
|
|
|
265
277
|
try {
|
|
266
278
|
return await readFile(path, 'utf8');
|
|
267
279
|
}
|
|
268
|
-
catch {
|
|
269
|
-
|
|
280
|
+
catch (error) {
|
|
281
|
+
if (error.code === 'ENOENT')
|
|
282
|
+
return undefined;
|
|
283
|
+
throw error;
|
|
270
284
|
}
|
|
271
285
|
}
|
|
272
286
|
async function isDirectory(path) {
|
|
@@ -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 ||
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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() ||
|
|
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 ||
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) +
|
|
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 ||
|
|
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 => {
|
|
187
|
-
|
|
207
|
+
await unlink(temporary).catch((error) => {
|
|
208
|
+
if (error.code !== 'ENOENT')
|
|
209
|
+
throw error;
|
|
210
|
+
});
|
|
188
211
|
}
|
|
189
212
|
}
|