@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.
Files changed (43) hide show
  1. package/dist/assets/client.js +6 -4
  2. package/dist/commands/build.js +38 -0
  3. package/dist/commands/tools-3d.js +15 -0
  4. package/dist/commands/tools-generation.js +3 -2
  5. package/dist/utils/agent-api-client.js +31 -8
  6. package/dist/utils/agent-generation-provenance.js +45 -0
  7. package/dist/utils/generation-workspace-scope.js +50 -0
  8. package/dist/utils/image-generation-retry.js +7 -0
  9. package/dist/utils/tool-operation-journal.js +34 -4
  10. package/dist/workspace-runtime/batch/prepare.js +9 -52
  11. package/dist/workspace-runtime/codex/delivery-tools.js +7 -5
  12. package/dist/workspace-runtime/codex/generation-provenance-broker.js +80 -0
  13. package/dist/workspace-runtime/codex/launcher-client.js +18 -0
  14. package/dist/workspace-runtime/codex/model-broker.js +23 -4
  15. package/dist/workspace-runtime/previews/finalize.js +64 -0
  16. package/dist/workspace-runtime/previews/publish.js +31 -8
  17. package/dist/workspace-runtime/serve.js +8 -30
  18. package/dist/workspace-runtime/server/http.js +5 -5
  19. package/dist/workspace-runtime/workspaces/artifact-events.js +136 -23
  20. package/dist/workspace-runtime/workspaces/artifact-files.js +3 -1
  21. package/dist/workspace-runtime/workspaces/artifact-receipts.js +27 -4
  22. package/dist/workspace-runtime/workspaces/artifacts.js +115 -17
  23. package/dist/workspace-runtime/workspaces/asset-publication-access.js +5 -1
  24. package/dist/workspace-runtime/workspaces/delivery-build.js +97 -7
  25. package/dist/workspace-runtime/workspaces/dependencies.js +24 -10
  26. package/dist/workspace-runtime/workspaces/directory-access.js +13 -0
  27. package/dist/workspace-runtime/workspaces/git.js +13 -0
  28. package/dist/workspace-runtime/workspaces/parameter-assets.js +38 -15
  29. package/dist/workspace-runtime/workspaces/parameters.js +61 -21
  30. package/dist/workspace-runtime/workspaces/platform-export.js +185 -0
  31. package/dist/workspace-runtime/workspaces/theme-asset-index.js +123 -13
  32. package/package.json +3 -3
  33. package/project-template-v2/.playcraft/skills.lock.json +15 -8
  34. package/project-templates/empty/AGENTS.md +1 -1
  35. package/templates/preview-runtime/v1/README.md +12 -0
  36. package/templates/preview-runtime/v1/manifest.json +8 -8
  37. package/templates/preview-runtime/v1/runtime.d.mts +1 -1
  38. package/templates/preview-runtime/v1/runtime.d.ts +1 -1
  39. package/templates/preview-runtime/v1/runtime.mjs +591 -54
  40. package/templates/preview-runtime/v1/types/adapters.d.ts +6 -1
  41. package/templates/preview-runtime/v1/types/preview.d.ts +2 -0
  42. package/templates/preview-runtime/v1/types/protocol.d.ts +56 -0
  43. package/templates/preview-runtime/v1/types/runtime.d.ts +2 -1
@@ -3,9 +3,10 @@ import { createHash } from 'node:crypto';
3
3
  import { posix, relative, resolve, sep } from 'node:path';
4
4
  import JSON5 from 'json5';
5
5
  import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
6
- import { InvalidWorkspacePathError, writeWorkspaceFile, } from './files.js';
6
+ import { InvalidWorkspacePathError, writeWorkspaceFile } from './files.js';
7
7
  import { editJson5Paths, ParameterEditError } from './json5-edit.js';
8
- import { prepareParameterAssetRegistration, writeParameterAssetRegistration } from './parameter-assets.js';
8
+ import { prepareParameterAssetRegistration, writeParameterAssetRegistration, } from './parameter-assets.js';
9
+ import { themeAssetIndexFingerprint } from './theme-asset-index.js';
9
10
  const MANIFEST_PATH = '.playcraft/workspace.json';
10
11
  const DEFAULT_SCHEMA_PATH = 'src/theme/theme.schema.json5';
11
12
  const THEME_INDEX_PATH = 'src/theme/index.ts';
@@ -22,7 +23,9 @@ export async function readWorkspaceParameters(root, revisions) {
22
23
  throw new ParameterEditError('parameter_source_changed');
23
24
  }
24
25
  async function sourceFingerprint(root, located, ownIndexWrite) {
25
- const selectedIndex = located.dataPath ? posix.join(posix.dirname(located.dataPath), 'index.ts') : undefined;
26
+ const selectedIndex = located.dataPath
27
+ ? posix.join(posix.dirname(located.dataPath), 'index.ts')
28
+ : undefined;
26
29
  const sources = await Promise.all([
27
30
  located.schemaPath ? readOptionalText(root, located.schemaPath) : undefined,
28
31
  located.schemaPath ? readOptionalText(root, platformI18nPath(located.schemaPath)) : undefined,
@@ -30,20 +33,40 @@ async function sourceFingerprint(root, located, ownIndexWrite) {
30
33
  readOptionalText(root, THEME_INDEX_PATH),
31
34
  selectedIndex ? readOptionalText(root, selectedIndex) : undefined,
32
35
  ]);
33
- const hashes = sources.map(text => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
36
+ const hashes = sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
37
+ const rawIndexHash = hashes[4];
38
+ if (sources[4] !== undefined) {
39
+ try {
40
+ const data = located.dataPath ? await readOptionalText(root, located.dataPath) : undefined;
41
+ hashes[4] = themeAssetIndexFingerprint(sources[4], themeImageReferences(sources[0] === undefined ? {} : JSON5.parse(sources[0]), data === undefined ? {} : JSON5.parse(data)));
42
+ }
43
+ catch {
44
+ // Invalid configuration cannot authorize a relaxed resource identity.
45
+ hashes[4] = rawIndexHash;
46
+ }
47
+ }
34
48
  if (ownIndexWrite) {
35
49
  // Only our exact staged registry bytes may differ from the baseline. An external edit,
36
50
  // including a schema/manifest change during the write callback, still fails the old guard.
37
- if (selectedIndex !== ownIndexWrite.path || hashes[4] !== ownIndexWrite.contentHash)
51
+ if (selectedIndex !== ownIndexWrite.path || rawIndexHash !== ownIndexWrite.contentHash)
38
52
  throw new ParameterEditError('parameter_source_conflict');
39
- hashes[4] = ownIndexWrite.previousHash;
53
+ hashes[4] = ownIndexWrite.previousFingerprint;
40
54
  }
41
- return createHash('sha256').update(JSON.stringify({
55
+ return createHash('sha256')
56
+ .update(JSON.stringify({
42
57
  schemaPath: located.schemaPath ?? null,
43
58
  dataPath: located.dataPath ?? null,
44
59
  themeId: located.themeId ?? null,
45
60
  sources: hashes,
46
- })).digest('hex');
61
+ }))
62
+ .digest('hex');
63
+ }
64
+ function themeImageReferences(schema, data) {
65
+ return parameterCatalog(schema, data).flatMap((field) => field.asset?.kind === 'image' &&
66
+ typeof field.value === 'string' &&
67
+ field.value.startsWith('./assets/')
68
+ ? [field.value]
69
+ : []);
47
70
  }
48
71
  async function readParameterDocuments(root, revisions, located) {
49
72
  if (located.errors.some((item) => item.reason === 'parameter_manifest_invalid')) {
@@ -79,7 +102,9 @@ async function readParameterDocuments(root, revisions, located) {
79
102
  schema: schemaFile.value,
80
103
  schemaHash: schemaFile.contentHash,
81
104
  schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
82
- ...(platformFile.status === 'ok' ? { platformI18n: platformFile.value, platformI18nPath: i18nPath } : {}),
105
+ ...(platformFile.status === 'ok'
106
+ ? { platformI18n: platformFile.value, platformI18nPath: i18nPath }
107
+ : {}),
83
108
  };
84
109
  if (platformFile.status === 'invalid') {
85
110
  return parameterDocumentSchema.parse({
@@ -128,8 +153,8 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
128
153
  if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
129
154
  throw new ParameterEditError('parameter_data_missing');
130
155
  }
131
- if ((parsed.sourceFingerprint && parsed.sourceFingerprint !== document.sourceFingerprint)
132
- || (observed && observed.dataPath !== document.dataPath)) {
156
+ if ((parsed.sourceFingerprint && parsed.sourceFingerprint !== document.sourceFingerprint) ||
157
+ (observed && observed.dataPath !== document.dataPath)) {
133
158
  throw new ParameterEditError('parameter_source_conflict');
134
159
  }
135
160
  const changes = 'changes' in parsed ? parsed.changes : [{ fieldPath: parsed.fieldPath, value: parsed.value }];
@@ -150,27 +175,33 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
150
175
  throw new ParameterEditError('parameter_document_invalid');
151
176
  }
152
177
  const current = await locateParameterFiles(root);
153
- if (await sourceFingerprint(root, current) !== document.sourceFingerprint) {
178
+ if ((await sourceFingerprint(root, current)) !== document.sourceFingerprint) {
154
179
  throw new ParameterEditError('parameter_source_conflict');
155
180
  }
156
181
  const catalog = parameterCatalog(document.schema, document.data);
157
- const bindings = changes.flatMap(change => {
182
+ const bindings = changes.flatMap((change) => {
158
183
  if (change.op === 'remove' || typeof change.value !== 'string' || !change.value)
159
184
  return [];
160
- const field = catalog.find(entry => entry.key === change.fieldPath.join('.'));
185
+ const field = catalog.find((entry) => entry.key === change.fieldPath.join('.'));
161
186
  return field?.asset ? [{ value: change.value, asset: field.asset }] : [];
162
187
  });
163
- const registration = await prepareParameterAssetRegistration(root, document.dataPath, bindings);
188
+ const registration = await prepareParameterAssetRegistration(root, document.dataPath, bindings, themeImageReferences(document.schema, document.data));
164
189
  // Register first: a failed sparse save can leave an unused asset, never a dangling saved reference.
165
190
  const additionalWrites = [];
166
191
  let ownIndexWrite;
167
192
  if (registration) {
168
193
  const written = await writeParameterAssetRegistration(root, registration);
169
194
  additionalWrites.push(written);
170
- ownIndexWrite = { path: written.path, previousHash: registration.previousHash, contentHash: written.contentHash };
195
+ ownIndexWrite = {
196
+ path: written.path,
197
+ previousHash: registration.previousHash,
198
+ previousFingerprint: registration.previousFingerprint,
199
+ contentHash: written.contentHash,
200
+ };
171
201
  await options.onAdditionalWrite?.(written);
172
202
  }
173
- if (await sourceFingerprint(root, await locateParameterFiles(root), ownIndexWrite) !== document.sourceFingerprint)
203
+ if ((await sourceFingerprint(root, await locateParameterFiles(root), ownIndexWrite)) !==
204
+ document.sourceFingerprint)
174
205
  throw new ParameterEditError('parameter_source_conflict');
175
206
  const latest = await readOptionalText(root, document.dataPath);
176
207
  if (latest !== source)
@@ -232,8 +263,12 @@ function declaredPaths(value) {
232
263
  if (Object.keys(record).some((key) => !['schemaPath', 'dataPath', 'themeId'].includes(key))) {
233
264
  return undefined;
234
265
  }
235
- const schemaPath = record.schemaPath === undefined ? undefined : workspaceRelativePathSchema.safeParse(record.schemaPath);
236
- const dataPath = record.dataPath === undefined ? undefined : workspaceRelativePathSchema.safeParse(record.dataPath);
266
+ const schemaPath = record.schemaPath === undefined
267
+ ? undefined
268
+ : workspaceRelativePathSchema.safeParse(record.schemaPath);
269
+ const dataPath = record.dataPath === undefined
270
+ ? undefined
271
+ : workspaceRelativePathSchema.safeParse(record.dataPath);
237
272
  const themeId = record.themeId === undefined
238
273
  ? undefined
239
274
  : typeof record.themeId === 'string' && record.themeId.trim() && record.themeId.length <= 128
@@ -258,7 +293,8 @@ async function readDeclaredTheme(root) {
258
293
  const text = contents.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');
259
294
  const match = text.match(/export\s+\{\s*default\s*\}\s+from\s+['"]\.\/([^'"]+)['"]/);
260
295
  const themeId = match?.[1]?.trim();
261
- return themeId && workspaceRelativePathSchema.safeParse(`${THEME_ROOT}/${themeId}/theme.data.json5`).success
296
+ return themeId &&
297
+ workspaceRelativePathSchema.safeParse(`${THEME_ROOT}/${themeId}/theme.data.json5`).success
262
298
  ? themeId
263
299
  : undefined;
264
300
  }
@@ -292,7 +328,11 @@ async function readOptionalJson5(root, relativePath) {
292
328
  if (text === undefined)
293
329
  return { status: 'missing' };
294
330
  try {
295
- return { status: 'ok', value: JSON5.parse(text), contentHash: createHash('sha256').update(text).digest('hex') };
331
+ return {
332
+ status: 'ok',
333
+ value: JSON5.parse(text),
334
+ contentHash: createHash('sha256').update(text).digest('hex'),
335
+ };
296
336
  }
297
337
  catch {
298
338
  return { status: 'invalid', reason: 'parameter_document_invalid' };
@@ -0,0 +1,185 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { z } from 'zod';
6
+ import { buildStageAt } from '@playcraft/common/build-progress';
7
+ import { PLATFORM_ROUTES, platformEndpoint } from '@playcraft/common/cloud-workspace/runtime';
8
+ import { submitTrustedOperation } from '../server/submit-operation.js';
9
+ import { businessEndpoint } from '../../utils/environment-endpoints.js';
10
+ import { ensureCliCredentials } from './cli-credentials.js';
11
+ import { readWorkspaceGitStatus } from './git.js';
12
+ import { sessionWorkspaceRoot } from './registry.js';
13
+ const run = promisify(execFile);
14
+ const REQUEST_TIMEOUT_MS = 60_000;
15
+ /** Validation findings beyond this are summarized by count; the Agent fixes the first ones first. */
16
+ const MAX_FINDINGS = 30;
17
+ /** A tool error the Agent sees as its reason; `code` is stable, the detail explains it. */
18
+ export class PlatformExportError extends Error {
19
+ code;
20
+ constructor(code, detail) {
21
+ super(detail ? `${code}: ${detail}` : code);
22
+ this.code = code;
23
+ }
24
+ }
25
+ /** The project and branch a workspace directory exports, or undefined when it has no project. */
26
+ export async function platformExportTarget(state, scope) {
27
+ const binding = state.journal.getWorkspace(scope.workspaceId);
28
+ if (!binding?.projectId)
29
+ return undefined;
30
+ const root = await sessionWorkspaceRoot(state, scope.workspaceId, scope.directoryName);
31
+ return { projectId: binding.projectId, root };
32
+ }
33
+ /**
34
+ * A standalone workspace has no remote to export from. The platform gives it a project (as its
35
+ * first save does) and registers it again; the registration reaches this runtime before the reply.
36
+ */
37
+ async function promoteStandalone(state, scope) {
38
+ if (state.journal.getWorkspace(scope.workspaceId)?.targetKind !== 'standalone' || scope.directoryName !== 'basic')
39
+ return;
40
+ const config = state.config;
41
+ const response = await fetch(platformEndpoint(config.publicBaseUrl, PLATFORM_ROUTES.workspacePromotions), {
42
+ method: 'POST',
43
+ redirect: 'error',
44
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
45
+ headers: { authorization: `Bearer ${config.controlToken}`, 'content-type': 'application/json', 'playcraft-env': config.environment },
46
+ body: JSON.stringify({ runtimeId: config.runtimeId, generation: config.generation, runtimeEpoch: state.runtimeEpoch,
47
+ workspaceId: scope.workspaceId }),
48
+ });
49
+ if (!response.ok) {
50
+ const body = await response.json().catch(() => undefined);
51
+ throw new PlatformExportError('workspace_promotion_failed', typeof body?.reason === 'string' ? body.reason : String(response.status));
52
+ }
53
+ await response.body?.cancel().catch(() => undefined);
54
+ }
55
+ const credentialSchema = z.object({ apiUrl: z.string().url(), token: z.string().min(1) });
56
+ async function request(state, path, init) {
57
+ await ensureCliCredentials(state).catch(() => { throw new PlatformExportError('cli_credentials_unavailable'); });
58
+ const credential = credentialSchema.parse(JSON.parse(await readFile(join(state.config.workspaceRoot, '.playcraft.json'), 'utf8')));
59
+ const response = await fetch(businessEndpoint(credential.apiUrl, path), {
60
+ method: init.method,
61
+ redirect: 'error',
62
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
63
+ headers: {
64
+ 'X-Sandbox-Token': credential.token,
65
+ 'playcraft-env': state.config.environment,
66
+ ...(init.body !== undefined ? { 'content-type': 'application/json' } : {}),
67
+ },
68
+ ...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
69
+ });
70
+ const text = await response.text();
71
+ let body;
72
+ try {
73
+ body = JSON.parse(text);
74
+ }
75
+ catch {
76
+ body = undefined;
77
+ }
78
+ const error = body && typeof body === 'object' && 'error' in body ? body.error : undefined;
79
+ if (!response.ok || (typeof error === 'string' && !('id' in body))) {
80
+ const message = body && typeof body === 'object' && 'message' in body ? body.message : error;
81
+ throw new PlatformExportError('platform_export_rejected', `${response.status} ${typeof message === 'string' ? message : Array.isArray(message) ? message.join('; ') : text.slice(0, 300)}`);
82
+ }
83
+ return body;
84
+ }
85
+ /**
86
+ * Saves the directory (commit + push), then exports that commit for the channels through the
87
+ * platform, as the delivery panel's Export does. Each channel repeats its latest export of the
88
+ * branch, or uses the defaults without one. A standalone workspace first becomes a project.
89
+ */
90
+ export async function startPlatformExport(state, input) {
91
+ await promoteStandalone(state, input);
92
+ const target = await platformExportTarget(state, input);
93
+ if (!target)
94
+ throw new PlatformExportError('platform_export_requires_project');
95
+ const saved = await submitTrustedOperation(state, {
96
+ kind: 'workspace.save',
97
+ workspaceId: input.workspaceId,
98
+ directoryName: input.directoryName,
99
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
100
+ clientRequestId: `${input.clientRequestId.slice(0, 80)}:export-save`,
101
+ payload: {},
102
+ });
103
+ if (saved.status !== 'completed')
104
+ throw new PlatformExportError('workspace_save_failed', saved.error);
105
+ const status = await readWorkspaceGitStatus(target.root);
106
+ // A save skips the push during a merge; with an upstream, `ahead` says the push did not land.
107
+ if (status.merging || (status.hasUpstream && status.ahead > 0))
108
+ throw new PlatformExportError('workspace_not_pushed');
109
+ if (status.branch === 'HEAD')
110
+ throw new PlatformExportError('workspace_detached_head');
111
+ const commit = (await run('git', ['-C', target.root, 'rev-parse', 'HEAD'])).stdout.trim();
112
+ const result = await request(state, '/api/builds/agent-exports', {
113
+ method: 'POST',
114
+ body: {
115
+ projectId: target.projectId,
116
+ branchName: status.branch,
117
+ commitHash: commit,
118
+ platforms: input.channels,
119
+ ...(input.format ? { format: input.format } : {}),
120
+ clientRequestId: input.clientRequestId,
121
+ },
122
+ });
123
+ return {
124
+ commit: commit.slice(0, 7),
125
+ branch: status.branch,
126
+ builds: result.builds.map((build) => ({
127
+ buildId: build.id,
128
+ channel: build.platform,
129
+ status: build.status,
130
+ ...(build.format ? { format: build.format } : {}),
131
+ ...(build.error ? { error: build.error } : {}),
132
+ ...(result.options?.[build.platform] ? { options: result.options[build.platform] } : {}),
133
+ })),
134
+ };
135
+ }
136
+ /** One platform export as the Agent reads it: status, the recorded failure and the checks. */
137
+ export async function readPlatformExport(state, input) {
138
+ const target = await platformExportTarget(state, input);
139
+ if (!target)
140
+ throw new PlatformExportError('platform_export_requires_project');
141
+ let buildId = input.buildId;
142
+ if (!buildId) {
143
+ const { branch } = await readWorkspaceGitStatus(target.root);
144
+ const query = new URLSearchParams({ projectId: target.projectId, branchName: branch, limit: '1',
145
+ ...(input.channel ? { platform: input.channel } : {}) });
146
+ const list = await request(state, `/api/agent/tools/list-builds?${query}`, { method: 'GET' });
147
+ buildId = list.items[0]?.id;
148
+ if (!buildId)
149
+ throw new PlatformExportError('delivery_build_not_found');
150
+ }
151
+ const build = await request(state, `/api/agent/tools/get-build?${new URLSearchParams({ id: buildId })}`, { method: 'GET' });
152
+ const running = build.status === 'queued' || build.status === 'running';
153
+ const findings = (build.validationWarnings ?? []).map((warning) => ({
154
+ code: warning.code,
155
+ severity: warning.severity,
156
+ message: warning.message,
157
+ ...(typeof warning.details?.status === 'string' ? { status: warning.details.status } : {}),
158
+ ...(typeof warning.details?.recommendation === 'string' ? { recommendation: warning.details.recommendation } : {}),
159
+ ...(typeof warning.details?.file === 'string' ? { file: warning.details.file } : {}),
160
+ ...(typeof warning.details?.line === 'number' ? { line: warning.details.line } : {}),
161
+ }));
162
+ return {
163
+ build: {
164
+ buildId: build.id,
165
+ channel: build.platform,
166
+ format: build.format,
167
+ status: build.status,
168
+ ...(running ? { stage: buildStageAt(build.progress).stage } : {}),
169
+ ...(build.commitHash ? { commit: build.commitHash } : {}),
170
+ ...(build.error ? { error: build.error } : {}),
171
+ ...(build.previewUrl ? { previewUrl: build.previewUrl } : {}),
172
+ ...(build.downloadUrl ? { downloadUrl: build.downloadUrl } : {}),
173
+ ...(typeof build.outputSize === 'number' ? { sizeBytes: build.outputSize } : {}),
174
+ ...(typeof build.sizeLimit === 'number' ? { sizeLimitBytes: build.sizeLimit } : {}),
175
+ },
176
+ ...(build.artifacts?.failure ? { failure: build.artifacts.failure } : {}),
177
+ ...(!running && build.validationPassed !== null && build.validationPassed !== undefined ? {
178
+ validation: {
179
+ passed: build.validationPassed,
180
+ findings: findings.slice(0, MAX_FINDINGS),
181
+ ...(findings.length > MAX_FINDINGS ? { omitted: findings.length - MAX_FINDINGS } : {}),
182
+ },
183
+ } : {}),
184
+ };
185
+ }
@@ -1,8 +1,11 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import JSON5 from 'json5';
3
+ import { parameterAssetMimeType, parameterAssetValueError, } from '@playcraft/common/cloud-workspace/runtime';
3
4
  export class ThemeAssetIndexError extends Error {
4
5
  code = 'parameter_asset_index_unsupported';
5
- constructor() { super('parameter_asset_index_unsupported'); }
6
+ constructor() {
7
+ super('parameter_asset_index_unsupported');
8
+ }
6
9
  }
7
10
  /** A bounded lexer recognizes the standard literal registry without evaluating project code. */
8
11
  function lex(source) {
@@ -84,9 +87,13 @@ function registry(source) {
84
87
  for (let i = exportStart + 3; i < tokens.length && exportDepth; i++) {
85
88
  const token = tokens[i];
86
89
  const previous = tokens[i - 1]?.text;
87
- const properKey = previous === ':' && (tokens[i - 2]?.text === 'assetRecordByPath' || tokens[i - 2]?.string === 'assetRecordByPath');
88
- if (exportDepth === 1 && token.text === 'assetRecordByPath' && [',', '}'].includes(tokens[i + 1]?.text ?? '')
89
- && (previous === '{' || previous === ',' || properKey))
90
+ const properKey = previous === ':' &&
91
+ (tokens[i - 2]?.text === 'assetRecordByPath' ||
92
+ tokens[i - 2]?.string === 'assetRecordByPath');
93
+ if (exportDepth === 1 &&
94
+ token.text === 'assetRecordByPath' &&
95
+ [',', '}'].includes(tokens[i + 1]?.text ?? '') &&
96
+ (previous === '{' || previous === ',' || properKey))
90
97
  exportsRegistry = true;
91
98
  if (token.string === undefined && token.text === '{')
92
99
  exportDepth++;
@@ -103,13 +110,17 @@ function registry(source) {
103
110
  }
104
111
  if (tokens[++cursor]?.text !== '{')
105
112
  throw new ThemeAssetIndexError();
113
+ const start = tokens[cursor].start;
106
114
  cursor++;
107
115
  const entries = new Map();
108
116
  let lastValueEnd, trailingComma = false;
109
117
  while (tokens[cursor]?.text !== '}') {
110
118
  const key = tokens[cursor], colon = tokens[cursor + 1], value = tokens[cursor + 2];
111
- if (key?.string === undefined || colon?.text !== ':' || !value || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value.text)
112
- || entries.has(key.string))
119
+ if (key?.string === undefined ||
120
+ colon?.text !== ':' ||
121
+ !value ||
122
+ !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value.text) ||
123
+ entries.has(key.string))
113
124
  throw new ThemeAssetIndexError();
114
125
  entries.set(key.string, value.text);
115
126
  lastValueEnd = value.end;
@@ -125,15 +136,110 @@ function registry(source) {
125
136
  const end = tokens[cursor].start;
126
137
  const imports = new Map();
127
138
  for (let i = 0; i < tokens.length - 3; i++) {
128
- if (tokens[i].text === 'import' && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(tokens[i + 1].text)
129
- && tokens[i + 1].text !== 'type' && tokens[i + 2].text === 'from' && tokens[i + 3].string !== undefined)
139
+ if (tokens[i].text === 'import' &&
140
+ /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(tokens[i + 1].text) &&
141
+ tokens[i + 1].text !== 'type' &&
142
+ tokens[i + 2].text === 'from' &&
143
+ tokens[i + 3].string !== undefined)
130
144
  imports.set(tokens[i + 3].string, tokens[i + 1].text);
131
145
  }
132
- return { end, lastValueEnd, trailingComma, entries, imports, tokens };
146
+ return { start, end, lastValueEnd, trailingComma, entries, imports, tokens };
147
+ }
148
+ /** Only literal, exclusively registered image imports belong to the replaceable resource domain. */
149
+ export function themeAssetIndexFingerprint(source, requiredImageValues = []) {
150
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
151
+ try {
152
+ const state = registry(source), tokens = state.tokens;
153
+ // Mutation, aliases, getters and other registry consumers cannot be separated safely.
154
+ if (tokens.filter((token) => token.string === undefined && token.text === 'assetRecordByPath')
155
+ .length !== 2)
156
+ return hash(source);
157
+ const references = new Map(), imports = new Map();
158
+ const values = new Map();
159
+ for (const token of tokens) {
160
+ if (token.string === undefined)
161
+ references.set(token.text, (references.get(token.text) ?? 0) + 1);
162
+ }
163
+ for (let i = 0; i < tokens.length - 4; i++) {
164
+ if (tokens[i].text !== 'import' ||
165
+ tokens[i + 2].text !== 'from' ||
166
+ tokens[i + 3].string === undefined ||
167
+ tokens[i + 4].text !== ';')
168
+ continue;
169
+ const variable = tokens[i + 1].text, imported = imports.get(variable) ?? [];
170
+ imported.push({ path: tokens[i + 3].string, start: i });
171
+ imports.set(variable, imported);
172
+ }
173
+ for (const [value, variable] of state.entries)
174
+ values.set(variable, [...(values.get(variable) ?? []), value]);
175
+ const imageEntries = new Set();
176
+ const replacements = [];
177
+ for (const [variable, bindings] of imports) {
178
+ if (bindings.length !== 1)
179
+ continue;
180
+ const binding = bindings[0], importPath = binding.path;
181
+ const value = importPath.startsWith('./assets/')
182
+ ? importPath
183
+ : importPath.startsWith('../../assets/')
184
+ ? 'src/' + importPath.slice(6)
185
+ : undefined;
186
+ if (!value)
187
+ continue;
188
+ const mime = parameterAssetMimeType(value);
189
+ if (!mime?.startsWith('image/') ||
190
+ parameterAssetValueError({
191
+ kind: 'image',
192
+ mimeTypes: [mime],
193
+ extensions: [],
194
+ maxBytes: 32 * 1024 * 1024,
195
+ }, value))
196
+ continue;
197
+ const registered = values.get(variable) ?? [];
198
+ if (registered.some((key) => key !== value) ||
199
+ references.get(variable) !== registered.length + 1)
200
+ continue;
201
+ for (const key of registered)
202
+ imageEntries.add(key);
203
+ const start = tokens[binding.start].start;
204
+ let end = tokens[binding.start + 4].end;
205
+ if (source.startsWith('\r\n', end))
206
+ end += 2;
207
+ else if (source[end] === '\n')
208
+ end++;
209
+ replacements.push({ start, end, content: '' });
210
+ }
211
+ const start = tokens.findIndex((token) => token.start === state.start);
212
+ const end = tokens.findIndex((token) => token.start === state.end);
213
+ if (start < 0 || end <= start)
214
+ return hash(source);
215
+ // Shared src/assets defaults belong to the existing build loader. Only theme-relative
216
+ // effective images require this selected theme's literal registry.
217
+ const missingThemeImages = [
218
+ ...new Set(requiredImageValues.filter((value) => value.startsWith('./assets/') && !state.entries.has(value))),
219
+ ].sort();
220
+ replacements.push({
221
+ start: tokens[start].end,
222
+ end: tokens[end].start,
223
+ content: JSON.stringify([...state.entries].filter(([value]) => !imageEntries.has(value))),
224
+ });
225
+ let canonical = source;
226
+ for (const replacement of replacements.sort((a, b) => b.start - a.start))
227
+ canonical =
228
+ canonical.slice(0, replacement.start) +
229
+ replacement.content +
230
+ canonical.slice(replacement.end);
231
+ return hash('theme-asset-code-v1:' + JSON.stringify({ source: canonical, missingThemeImages }));
232
+ }
233
+ catch {
234
+ // Unknown project syntax retains the original strict byte identity.
235
+ return hash(source);
236
+ }
133
237
  }
134
238
  /** Add only literal imports/entries; retain user exports, comments, unrelated mappings and files. */
135
239
  export function registerThemeAsset(source, value, importPath) {
136
- if (!value || !importPath.startsWith('./') && !importPath.startsWith('../') || /[\r\n\x00]/.test(importPath))
240
+ if (!value ||
241
+ (!importPath.startsWith('./') && !importPath.startsWith('../')) ||
242
+ /[\r\n\x00]/.test(importPath))
137
243
  throw new ThemeAssetIndexError();
138
244
  const state = registry(source);
139
245
  const imported = state.imports.get(importPath);
@@ -143,8 +249,11 @@ export function registerThemeAsset(source, value, importPath) {
143
249
  return source;
144
250
  throw new ThemeAssetIndexError();
145
251
  }
146
- const variable = imported ?? '__playcraftParameterAsset_' + createHash('sha256').update(importPath).digest('hex').slice(0, 16);
147
- if (!imported && state.tokens.some(token => token.string === undefined && token.text === variable))
252
+ const variable = imported ??
253
+ '__playcraftParameterAsset_' +
254
+ createHash('sha256').update(importPath).digest('hex').slice(0, 16);
255
+ if (!imported &&
256
+ state.tokens.some((token) => token.string === undefined && token.text === variable))
148
257
  throw new ThemeAssetIndexError();
149
258
  let result = source;
150
259
  let end = state.end;
@@ -152,6 +261,7 @@ export function registerThemeAsset(source, value, importPath) {
152
261
  result = result.slice(0, state.lastValueEnd) + ',' + result.slice(state.lastValueEnd);
153
262
  end++;
154
263
  }
155
- result = result.slice(0, end) + `\n ${JSON.stringify(value)}: ${variable},\n` + result.slice(end);
264
+ result =
265
+ result.slice(0, end) + `\n ${JSON.stringify(value)}: ${variable},\n` + result.slice(end);
156
266
  return (imported ? '' : `import ${variable} from ${JSON.stringify(importPath)};\n`) + result;
157
267
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.55-beta",
3
+ "version": "0.1.57-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -52,8 +52,8 @@
52
52
  "@gltf-transform/core": "^4.3.0",
53
53
  "@gltf-transform/extensions": "^4.3.0",
54
54
  "@gltf-transform/functions": "^4.3.0",
55
- "@playcraft/build": "0.1.55-beta",
56
- "@playcraft/common": "0.1.55-beta",
55
+ "@playcraft/build": "0.1.57-beta",
56
+ "@playcraft/common": "0.1.57-beta",
57
57
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
58
58
  "chokidar": "^4.0.3",
59
59
  "commander": "^13.1.0",
@@ -10,8 +10,8 @@
10
10
  },
11
11
  {
12
12
  "skillRef": "platform/playcraft-asset-management",
13
- "version": "1.0.0",
14
- "artifactHash": "27209dba24f6e1851dc83682fbd24165894c802794c797cac32d8871f740cf14",
13
+ "version": "1.1.0",
14
+ "artifactHash": "0b5408792edfeb7b852817bd76893b95738a0e161407f0b48c4b50161797ba3c",
15
15
  "surface": "portal-agent",
16
16
  "mountPath": ".playcraft/skills/platform__playcraft-asset-management"
17
17
  },
@@ -108,8 +108,8 @@
108
108
  },
109
109
  {
110
110
  "skillRef": "remix/playcraft-audio-generation",
111
- "version": "1.0.0",
112
- "artifactHash": "8d1b035b289545ca8fd6c247b9417bf34bddf5a26b31e114c35d001aa3a9ebf0",
111
+ "version": "1.1.1",
112
+ "artifactHash": "27661b1b52e7981a0b9cf5531f535fcf10928ea290348fcb469e35ad2082b437",
113
113
  "surface": "sandbox-agent",
114
114
  "mountPath": ".playcraft/skills/remix__playcraft-audio-generation"
115
115
  },
@@ -122,15 +122,15 @@
122
122
  },
123
123
  {
124
124
  "skillRef": "remix/playcraft-image-generation",
125
- "version": "1.0.0",
126
- "artifactHash": "91bec2422c750d9d47d5be2796ec5aaffb48de561f2c2d8b92112f37d576f489",
125
+ "version": "1.1.2",
126
+ "artifactHash": "a006c96700f8dbfe4c34a31d0e1646620ca6148d319e59e7466d627fea1ca65e",
127
127
  "surface": "sandbox-agent",
128
128
  "mountPath": ".playcraft/skills/remix__playcraft-image-generation"
129
129
  },
130
130
  {
131
131
  "skillRef": "remix/playcraft-image-processing",
132
- "version": "1.0.0",
133
- "artifactHash": "d845c067bed103c3d1d3f145b1cb8246ffbd6a9ae91efcfbce2eae456819212d",
132
+ "version": "1.1.1",
133
+ "artifactHash": "903f4efc14e2bb57403856edeca43146904ec64811cfd107b6ec76bb451842fd",
134
134
  "surface": "sandbox-agent",
135
135
  "mountPath": ".playcraft/skills/remix__playcraft-image-processing"
136
136
  },
@@ -147,6 +147,13 @@
147
147
  "artifactHash": "e2a59c61ef5b094a821fbbab3b0f65f906bfc0dfda6f3d8c0b33a6f460dd0963",
148
148
  "surface": "sandbox-agent",
149
149
  "mountPath": ".playcraft/skills/remix__playcraft-remix-workflow"
150
+ },
151
+ {
152
+ "skillRef": "remix/playcraft-workspace-assets",
153
+ "version": "1.0.0",
154
+ "artifactHash": "f098949b043a229e561d92a34c3a65b44ffbd83d1758ef30ac09d2d0d5ea2570",
155
+ "surface": "sandbox-agent",
156
+ "mountPath": ".playcraft/skills/remix__playcraft-workspace-assets"
150
157
  }
151
158
  ]
152
159
  }
@@ -46,7 +46,7 @@ The artifact panel shows final deliverables under `dist/` at the current project
46
46
 
47
47
  When a generated asset is ready for delivery, save or copy it into `dist/` with a stable relative path. Keep the resources required by a model or document within `dist/` and preserve their relative links. Reuse the same delivery path for a revised artifact so its versions remain grouped; the platform retains its history. Use different paths for independent candidates or deliverables.
48
48
 
49
- Run playable builds from the project root and place their final output under `dist/` (`playcraft build --mode full --platform playcraft --format html --output ./dist .`). A base build's `dist/index.html` can still require resource files; it is not proof of a self-contained playable. The workspace's playable publish action validates its isolated temporary build, then retains the exact single-file result at `dist/playable/index.html`. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
49
+ Run playable builds from the project root (`playcraft build --mode full --platform playcraft --format html .`). In a workspace this command builds in an isolated copy, validates the result, and retains the playable at `dist/playable/index.html` while preserving other deliverables. A successful agent command or turn that writes a valid self-contained HTML (HyperText Markup Language, 超文本标记语言) file at this same path is collected as the same playable preview. No separate publish action is required to view it. A base build's `dist/index.html` can still require resource files and remains a document. The page's generate-preview action rebuilds the current project through the same validation. Public sharing is a separate explicit action. Temporary `.playcraft-preview-out/` and `.playcraft-temp/` files are not deliverables. Preserve other artifacts already in `dist/` when rebuilding a playable.
50
50
 
51
51
  Keep this convention in `AGENTS.md`, the project's standard instruction filename. Do not create a separate `AGENT.md`.
52
52
 
@@ -14,6 +14,18 @@ Create your own `src/preview/runtime.ts`. Import `connectPreviewRuntime` and `re
14
14
  - `createGame(context)` returns actual `apply(context)`, `restartScene(context)`, `afterRender(context)` and `dispose()` callbacks. Check `context.assertCurrent()` and honor `context.signal` before late effects. `afterRender` must wait for real rendering, not a successful placeholder.
15
15
  - Dispose the endpoint on page exit. Advertising SDK configuration remains the production concern; do not route development updates through advertising caches.
16
16
 
17
+ ## Runtime image parameters
18
+
19
+ Register an image consumer with `resource: 'image'` and `mode: 'live'`. Keep its logical asset path in a preview-only resolved property used by `effectivePath`; do not compare an embedded image address with the raw parameter path. `context.loadImage(field, logicalPath)` requests the current declared image through the host and returns a decoded image element. Empty paths clear the consumer without reading a file. The host validates the field against the current schema and configuration; the game receives image bytes, never platform file addresses or credentials.
20
+
21
+ Prepare all required images before changing visible objects, call `assertCurrent()` after asynchronous work and immediately before installing textures, then wait for the real engine frame. Keep old objects intact until preparation succeeds. Release old preview-owned textures and overlay references after a successful replacement and on disposal. Never remove an engine texture still used by another object.
22
+
23
+ `ResourceLoadError` is exported from `runtime.mjs`. Live adapters let this error reach the runtime, which retains the last known installation and reports a recoverable, field-level resource failure. Do not catch an arbitrary update exception and claim successful fallback. During initial `createGame`, catch only this load error to start with an embedded default image or empty background; the runtime still reports the saved image as unapplied. A later patch of that field retries it; unrelated parameter updates can continue. `resourceErrors` and file-save receipts are independent. Save logical paths and let the original production asset registry include the file when building delivery output.
24
+
25
+ The endpoint advertises `canRefreshImages: true` for explicit same-path content refresh. The host sends registered live-image fields and their expected file digests through a separate resource sequence; raw parameters and draft revisions remain unchanged. In `apply`, honor `context.fields` even when a logical path equals its previous value: request that field through `loadImage` and install the returned image after `assertCurrent()`. A local path-equality cache must not hide this explicit update. The runtime prepares new images without blocking unrelated live parameters, checks the expected content digest, rebases against the latest permitted configuration and records installed digests only after the real render acknowledgement. A matching installed digest is a no-op; a failed or superseded preparation leaves the old image available and can be retried. Older endpoints without this optional capability require the existing rebuild route.
26
+
27
+ Content generations are independent per field. If an opening image and board image are being prepared together, a newer board-only refresh cancels only the old board read; the still-current opening image must finish and apply while preserving the newer board. Each application uses only the prepared fields that remain current and the latest permitted configuration. A preparation failure in a still-current batch retains the old images and marks all its unapplied fields as recoverable, so an explicit retry cannot silently forget a prepared sibling.
28
+
17
29
  ## Preview build
18
30
 
19
31
  The engineering project must install a compatible Vite as a development dependency. Set a separate package script: