@playcraft/cli 0.1.64 → 0.1.65

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 (29) hide show
  1. package/README.md +24 -31
  2. package/dist/commands/build.js +70 -5
  3. package/dist/commands/create.js +4 -4
  4. package/dist/commands/inspect.js +4 -6
  5. package/dist/index.js +27 -19
  6. package/dist/model-adaptation/node-inspection.js +173 -0
  7. package/dist/model-adaptation/project-template.js +40 -8
  8. package/dist/model-adaptation/project.js +2 -2
  9. package/dist/model-preparation/pipeline-engine.js +157 -0
  10. package/dist/model-preparation/pipeline-files.js +108 -0
  11. package/dist/model-preparation/pipeline-worker.js +32 -0
  12. package/dist/workspace-runtime/batch/deliver.js +2 -2
  13. package/dist/workspace-runtime/codex/delivery-tools.js +36 -3
  14. package/dist/workspace-runtime/previews/publish.js +2 -2
  15. package/dist/workspace-runtime/serve.js +5 -0
  16. package/dist/workspace-runtime/server/directory-access.js +1 -1
  17. package/dist/workspace-runtime/server/dispatch.js +27 -47
  18. package/dist/workspace-runtime/server/http.js +12 -0
  19. package/dist/workspace-runtime/workspaces/build-isolation.js +75 -0
  20. package/dist/workspace-runtime/workspaces/model-build-inputs.js +27 -0
  21. package/dist/workspace-runtime/workspaces/model-preparation-store.js +134 -0
  22. package/dist/workspace-runtime/workspaces/model-preparation.js +357 -0
  23. package/dist/workspace-runtime/workspaces/platform-export.js +1 -0
  24. package/package.json +4 -4
  25. package/project-template/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  26. package/project-templates/empty/AGENTS.md +1 -1
  27. package/project-templates/phaser/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  28. package/project-templates/threejs/.claude/skills/playcraft-build-optimizer/SKILL.md +19 -39
  29. package/dist/workspace-runtime/workspaces/delivery-build.js +0 -440
@@ -1,440 +0,0 @@
1
- import { randomUUID } from 'node:crypto';
2
- import { constants } from 'node:fs';
3
- import { copyFile, cp, 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';
6
- import { createPlatformAdapter, getChannelRuleProfile, } from '@playcraft/build';
7
- import { DELIVERY_QUEUE_LIMIT, isWorkspaceArtifactOutputPath, } from '@playcraft/common/cloud-workspace/runtime';
8
- import { revisionScope, workspaceRoot } from './registry.js';
9
- import { excludeFromGit, ensureWorkspaceDependencies } from './dependencies.js';
10
- import { listWorkspaceBuildInputs } from './git.js';
11
- import { runDeveloperCommand } from './developer-command.js';
12
- import { openScratchToDevelopment } from './directory-access.js';
13
- import { ensureSharedDirectory } from './shared-write.js';
14
- import { driftScope, permissionDriftFromOutput, repairWorkspaceShares } from './workspace-shares.js';
15
- import { DeliveryStore } from './delivery-store.js';
16
- import { DeliveryValidationError, validateDeliveryArtifact } from './delivery.js';
17
- export const DELIVERY_BUILD_TIMEOUT_MS = 10 * 60_000;
18
- /** Staging under the workspace from earlier runtimes; kept out of Git so leftovers never commit. */
19
- const STAGING_DIRECTORY = '.playcraft-delivery';
20
- /** Never copied into the build copy: history, outputs and caches that the build neither reads nor owns. */
21
- const ISOLATE_SKIP = new Set(['.git', 'dist', '.turbo', STAGING_DIRECTORY]);
22
- export class DeliveryBuildError extends Error {
23
- code;
24
- constructor(code) {
25
- super(code);
26
- this.code = code;
27
- this.name = 'DeliveryBuildError';
28
- }
29
- }
30
- const queues = new WeakMap();
31
- const running = new WeakMap();
32
- export function trackDeliveryBuild(state, task) {
33
- const tasks = running.get(state) ?? new Set();
34
- running.set(state, tasks);
35
- const tracked = task.finally(() => tasks.delete(tracked));
36
- tasks.add(tracked);
37
- }
38
- export async function settleDeliveryBuilds(state) {
39
- await Promise.all([...(running.get(state) ?? [])]);
40
- }
41
- const scopeKey = (workspaceId, directoryName) => `${workspaceId}\0${directoryName}`;
42
- function buildQueues(state) {
43
- const known = queues.get(state) ?? new Map();
44
- queues.set(state, known);
45
- return known;
46
- }
47
- function queuedRecord(input, createdAt, queuePosition) {
48
- return {
49
- buildId: input.buildId,
50
- workspaceId: input.workspaceId,
51
- directoryName: input.directoryName,
52
- channel: input.payload.channel,
53
- format: input.payload.format,
54
- status: 'queued',
55
- ...(input.actor ? { actor: input.actor } : {}),
56
- queuePosition,
57
- createdAt,
58
- };
59
- }
60
- async function refreshQueuedRecords(state, key) {
61
- const queue = buildQueues(state).get(key);
62
- if (!queue)
63
- return;
64
- const waiting = queue.queue.filter(item => !item.started && !item.canceled);
65
- const workspaceId = waiting[0]?.input.workspaceId ?? queue.active?.pending.input.workspaceId;
66
- if (!workspaceId)
67
- return;
68
- const store = new DeliveryStore(state.config.stateDir, workspaceId);
69
- await Promise.all(waiting.map((item, index) => {
70
- if (item.started || item.canceled)
71
- return Promise.resolve();
72
- return store.write(queuedRecord(item.input, item.createdAt, index + 1)).catch(() => undefined);
73
- }));
74
- }
75
- function pump(state, key, queue) {
76
- if (queue.active)
77
- return;
78
- const next = queue.queue.shift();
79
- if (!next)
80
- return;
81
- next.started = true;
82
- queue.active = { buildId: next.input.buildId, pending: next };
83
- void executeBuild(state, next.input, next.controller)
84
- .then(outcome => next.resolve(outcome), error => next.reject(error))
85
- .finally(() => {
86
- queue.active = undefined;
87
- pump(state, key, queue);
88
- void refreshQueuedRecords(state, key);
89
- });
90
- }
91
- export function runDeliveryBuild(state, input) {
92
- if (input.directoryName === 'root')
93
- return Promise.reject(new DeliveryBuildError('invalid_workspace_directory'));
94
- const key = scopeKey(input.workspaceId, input.directoryName);
95
- const scopes = buildQueues(state);
96
- const queue = scopes.get(key) ?? { queue: [] };
97
- if ((queue.active ? 1 : 0) + queue.queue.length >= DELIVERY_QUEUE_LIMIT) {
98
- return Promise.reject(new DeliveryBuildError('delivery_queue_full'));
99
- }
100
- let resolve;
101
- let reject;
102
- const promise = new Promise((resolvePromise, rejectPromise) => {
103
- resolve = resolvePromise;
104
- reject = rejectPromise;
105
- });
106
- const pending = {
107
- input,
108
- controller: new AbortController(),
109
- promise,
110
- resolve,
111
- reject,
112
- createdAt: new Date().toISOString(),
113
- canceled: false,
114
- started: false,
115
- };
116
- queue.queue.push(pending);
117
- scopes.set(key, queue);
118
- pump(state, key, queue);
119
- void refreshQueuedRecords(state, key);
120
- return promise;
121
- }
122
- export async function cancelDeliveryBuild(state, workspaceId, directoryName, buildId) {
123
- const key = scopeKey(workspaceId, directoryName);
124
- const queue = buildQueues(state).get(key);
125
- if (!queue)
126
- return false;
127
- if (queue.active?.buildId === buildId) {
128
- queue.active.pending.controller.abort();
129
- return true;
130
- }
131
- const index = queue.queue.findIndex(item => item.input.buildId === buildId);
132
- if (index < 0)
133
- return false;
134
- const [pending] = queue.queue.splice(index, 1);
135
- if (!pending)
136
- return false;
137
- pending.canceled = true;
138
- const record = {
139
- ...queuedRecord(pending.input, pending.createdAt, 0),
140
- status: 'canceled',
141
- finishedAt: new Date().toISOString(),
142
- error: 'delivery_canceled',
143
- };
144
- await new DeliveryStore(state.config.stateDir, workspaceId).write(record).catch(() => undefined);
145
- pending.resolve({ record });
146
- void refreshQueuedRecords(state, key);
147
- return true;
148
- }
149
- export function activeDeliveryBuild(state, workspaceId, directoryName) {
150
- return buildQueues(state).get(scopeKey(workspaceId, directoryName))?.active?.buildId;
151
- }
152
- function shellQuote(value) {
153
- return `'${value.replace(/'/g, `'\\''`)}'`;
154
- }
155
- function outputExtension(format) {
156
- return format === 'zip' ? '.zip' : '.html';
157
- }
158
- async function listStagedFiles(staging, prefix = '') {
159
- const entries = await readdir(join(staging, prefix), { withFileTypes: true });
160
- const files = [];
161
- for (const entry of entries) {
162
- if (entry.isSymbolicLink() || entry.name.startsWith('.'))
163
- continue;
164
- const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
165
- if (entry.isDirectory()) {
166
- files.push(...await listStagedFiles(staging, relative));
167
- if (files.length > 512)
168
- throw new DeliveryBuildError('delivery_output_missing');
169
- }
170
- else if (entry.isFile()) {
171
- files.push(relative);
172
- }
173
- }
174
- return files;
175
- }
176
- function choosePrimary(files, format) {
177
- const extension = outputExtension(format);
178
- const candidates = files.filter(file => file.toLowerCase().endsWith(extension));
179
- if (!candidates.length)
180
- throw new DeliveryBuildError('delivery_output_missing');
181
- const preferred = format === 'zip' ? 'playable.zip' : 'index.html';
182
- return candidates.find(file => basename(file).toLowerCase() === preferred) ?? candidates[0];
183
- }
184
- /**
185
- * Copies a single artifact, or the whole staging tree for fragment/mixed channels. Each file is
186
- * staged next to its final name and renamed, so a partially written package never appears in dist.
187
- */
188
- async function copyToDist(root, staging, channel, format) {
189
- const files = await listStagedFiles(staging);
190
- const primary = choosePrimary(files, format);
191
- const mode = getChannelRuleProfile(channel).deliveryMode;
192
- const selected = files.length > 1 && (mode === 'fragment-assets' || mode === 'mixed') ? files : [primary];
193
- const paths = [];
194
- let sizeBytes = 0;
195
- for (const file of selected) {
196
- const relative = `dist/delivery/${channel}/${file}`;
197
- if (!isWorkspaceArtifactOutputPath(relative))
198
- throw new DeliveryBuildError('delivery_output_missing');
199
- const target = join(root, relative);
200
- const directory = dirname(target);
201
- await ensureSharedDirectory(root, directory);
202
- const temporary = join(directory, `.${basename(file)}.${randomUUID()}.tmp`);
203
- try {
204
- await copyFile(join(staging, file), temporary);
205
- await rename(temporary, target);
206
- }
207
- catch {
208
- await rm(temporary, { force: true }).catch(() => undefined);
209
- throw new DeliveryBuildError('delivery_copy_failed');
210
- }
211
- sizeBytes += (await stat(target)).size;
212
- paths.push(relative);
213
- }
214
- return { primary: `dist/delivery/${channel}/${primary}`, paths, sizeBytes };
215
- }
216
- /** Copy-on-write clone where the filesystem supports it (APFS, btrfs, XFS); a plain copy elsewhere. */
217
- const CLONE = constants.COPYFILE_FICLONE;
218
- const COPY_CONCURRENCY = 16;
219
- function skipped(path) {
220
- const parts = path.split(/[\\/]/);
221
- return ISOLATE_SKIP.has(parts[0]) || parts.includes('node_modules');
222
- }
223
- async function copyInput(root, destination, path) {
224
- const source = join(root, path);
225
- const target = join(destination, path);
226
- let entry;
227
- try {
228
- entry = await lstat(source);
229
- }
230
- catch (error) {
231
- // A tracked file deleted in the work tree is not a build input.
232
- if (error.code === 'ENOENT')
233
- return;
234
- throw error;
235
- }
236
- await mkdir(dirname(target), { recursive: true });
237
- if (entry.isSymbolicLink())
238
- await symlink(await readlink(source), target);
239
- else if (entry.isDirectory())
240
- await cp(source, target, { recursive: true, verbatimSymlinks: true, mode: CLONE });
241
- else
242
- await copyFile(source, target, CLONE);
243
- }
244
- /**
245
- * Copies the workspace into `destination` for one build, like the platform export runner, with the
246
- * top-level `node_modules` linked (the build only reads it). Builders rewrite project files in place
247
- * (`builds.config.js` and its `.bak`, `package.json` scripts, migrated imports), so the build must
248
- * never run in the workspace itself, where autosave would commit those rewrites.
249
- *
250
- * Only the files Git would carry are copied: tracked ones and untracked ones `.gitignore` keeps, so
251
- * caches, logs and local outputs stay behind. Without a repository the whole tree is copied.
252
- */
253
- export async function isolateWorkspaceForBuild(root, destination) {
254
- // The build runs as the development identity and writes its scratch folder inside this copy.
255
- await ensureSharedDirectory(root, destination);
256
- const modules = join(root, 'node_modules');
257
- const modulesEntry = await lstat(modules).catch(() => undefined);
258
- if (modulesEntry?.isDirectory() || modulesEntry?.isSymbolicLink()) {
259
- await symlink(modulesEntry.isSymbolicLink() ? await readlink(modules) : modules, join(destination, 'node_modules'), 'dir');
260
- }
261
- const inputs = await listWorkspaceBuildInputs(root).catch(() => undefined);
262
- const paths = inputs
263
- ?? (await readdir(root)).filter((name) => name !== 'node_modules');
264
- const queue = paths.filter((path) => !skipped(path));
265
- const workers = Array.from({ length: Math.min(COPY_CONCURRENCY, queue.length) }, async () => {
266
- for (let path = queue.shift(); path !== undefined; path = queue.shift()) {
267
- if (inputs)
268
- await copyInput(root, destination, path);
269
- else {
270
- await cp(join(root, path), join(destination, path), {
271
- recursive: true,
272
- verbatimSymlinks: true,
273
- mode: CLONE,
274
- // Nested dependency folders stay out as well; the build resolves through the linked root.
275
- filter: (source) => !relative(join(root, path), source).split(sep).includes('node_modules'),
276
- });
277
- }
278
- }
279
- });
280
- await Promise.all(workers);
281
- }
282
- /**
283
- * Runs one channel build in a disposable copy of the workspace. The build writes into a disposable
284
- * output directory; only the final artifact is copied into dist so the builder's `--clean` can never
285
- * erase another delivery.
286
- */
287
- async function executeBuild(state, input, controller) {
288
- if (input.directoryName === 'root')
289
- throw new DeliveryBuildError('invalid_workspace_directory');
290
- const root = await workspaceRoot(state, input.workspaceId, input.directoryName);
291
- const store = new DeliveryStore(state.config.stateDir, input.workspaceId);
292
- const startedAt = new Date().toISOString();
293
- const base = {
294
- buildId: input.buildId,
295
- workspaceId: input.workspaceId,
296
- directoryName: input.directoryName,
297
- channel: input.payload.channel,
298
- format: input.payload.format,
299
- status: 'running',
300
- ...(input.actor ? { actor: input.actor } : {}),
301
- createdAt: startedAt,
302
- startedAt,
303
- };
304
- await store.write(base);
305
- let scratch;
306
- let staging = '';
307
- let status = 'running';
308
- let error;
309
- let validationError;
310
- let outputPath;
311
- let outputPaths;
312
- let sizeBytes;
313
- let sizeLimitBytes;
314
- let report;
315
- let logChain = Promise.resolve();
316
- let record;
317
- try {
318
- scratch = await mkdtemp(join(tmpdir(), 'playcraft-delivery-'));
319
- // The build runs as the development identity while this scratch tree belongs to the controller.
320
- await openScratchToDevelopment(scratch, (await lstat(root)).gid);
321
- staging = join(scratch, 'output');
322
- const project = join(scratch, 'project');
323
- await ensureSharedDirectory(root, staging);
324
- await excludeFromGit(root, `/${STAGING_DIRECTORY}/`);
325
- await excludeFromGit(root, '/dist/');
326
- const dependencies = await ensureWorkspaceDependencies(state, revisionScope(state, input.workspaceId, input.directoryName) ?? input.workspaceId, root);
327
- if (!dependencies.ok) {
328
- if (dependencies.logTail) {
329
- await store.appendLog(input.buildId, `\n${dependencies.logTail}\n`);
330
- }
331
- throw new DeliveryBuildError('delivery_dependencies_failed');
332
- }
333
- try {
334
- await isolateWorkspaceForBuild(root, project);
335
- }
336
- catch {
337
- throw new DeliveryBuildError('delivery_isolation_failed');
338
- }
339
- const planned = process.env.PLAYCRAFT_DELIVERY_BUILD_COMMAND || 'playcraft build';
340
- const command = `umask 002 && NO_COLOR=1 CI=1 ${planned} . --platform ${input.payload.channel} --format ${input.payload.format} --output ${shellQuote(staging)} --mode full`;
341
- let outputTail = '';
342
- const runBuild = () => runDeveloperCommand(state, {
343
- processId: `delivery-${input.buildId}`,
344
- cwd: project,
345
- command,
346
- timeoutMs: DELIVERY_BUILD_TIMEOUT_MS,
347
- signal: controller.signal,
348
- onOutput: (chunk) => {
349
- const text = String(chunk);
350
- if (!text)
351
- return;
352
- outputTail = (outputTail + text).slice(-64 * 1024);
353
- logChain = logChain.then(() => store.appendLog(input.buildId, text)).catch(() => undefined);
354
- },
355
- });
356
- let outcome = await runBuild();
357
- let permissionDrift = false;
358
- if (outcome.code !== 0 && !outcome.canceled && !outcome.timedOut) {
359
- // The build runs as the development identity; an entry only that identity owns repairs its
360
- // own shared subtree, then the build is retried exactly once.
361
- const drift = permissionDriftFromOutput(outputTail);
362
- if (drift) {
363
- permissionDrift = true;
364
- await repairWorkspaceShares(state, root, await driftScope(root, drift.path)).catch(() => undefined);
365
- outcome = await runBuild();
366
- permissionDrift = outcome.code !== 0;
367
- }
368
- }
369
- if (outcome.code === undefined) {
370
- status = 'failed';
371
- error = 'delivery_process_unknown';
372
- }
373
- else if (outcome.canceled) {
374
- status = 'canceled';
375
- error = 'delivery_canceled';
376
- }
377
- else if (outcome.timedOut) {
378
- status = 'failed';
379
- error = 'delivery_timeout';
380
- }
381
- else if (outcome.code !== 0) {
382
- status = 'failed';
383
- error = permissionDrift ? 'workspace_permission_drift' : 'delivery_build_failed';
384
- }
385
- else {
386
- const output = await copyToDist(root, staging, input.payload.channel, input.payload.format);
387
- outputPath = output.primary;
388
- outputPaths = output.paths;
389
- sizeBytes = output.sizeBytes;
390
- sizeLimitBytes = createPlatformAdapter({
391
- platform: input.payload.channel,
392
- format: input.payload.format,
393
- }).getSizeLimit();
394
- try {
395
- const result = await validateDeliveryArtifact(root, {
396
- channel: input.payload.channel,
397
- format: input.payload.format,
398
- path: outputPath,
399
- });
400
- report = result.report;
401
- sizeLimitBytes = result.sizeLimitBytes;
402
- }
403
- catch (cause) {
404
- validationError = cause instanceof DeliveryValidationError ? cause.code : 'delivery_report_unavailable';
405
- }
406
- status = 'succeeded';
407
- }
408
- }
409
- catch (cause) {
410
- status = 'failed';
411
- error = cause instanceof DeliveryBuildError ? cause.code : 'delivery_build_failed';
412
- }
413
- finally {
414
- // `rm` removes the node_modules link, never its target.
415
- if (scratch)
416
- await rm(scratch, { recursive: true, force: true }).catch(() => undefined);
417
- await logChain;
418
- if (report) {
419
- await store.writeReport(input.buildId, report).catch(() => {
420
- validationError = validationError ?? 'delivery_report_unavailable';
421
- });
422
- }
423
- const recordValue = {
424
- ...base,
425
- status,
426
- finishedAt: new Date().toISOString(),
427
- ...(outputPath ? { outputPath } : {}),
428
- ...(outputPaths && outputPaths.length > 1 ? { outputPaths } : {}),
429
- ...(sizeBytes !== undefined ? { sizeBytes } : {}),
430
- ...(sizeLimitBytes !== undefined ? { sizeLimitBytes } : {}),
431
- ...(report ? { summary: report.summary, buildChain: report.buildChain } : {}),
432
- ...(error ? { error } : {}),
433
- ...(validationError ? { validationError } : {}),
434
- logBytes: await store.logBytes(input.buildId),
435
- };
436
- record = recordValue;
437
- await store.write(recordValue);
438
- }
439
- return { record, ...(report ? { report } : {}) };
440
- }