@playcraft/cli 0.1.49-beta → 0.1.50-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.
@@ -0,0 +1,282 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { parameterAgentReportSchema, parameterAgentTaskRequestSchema, parameterAgentVerifyPayloadSchema, parameterCatalog, stableStringify, validateParameterChange, } from '@playcraft/common/cloud-workspace/runtime';
3
+ import { readWorkspaceParameters } from './parameters.js';
4
+ import { revisionScope } from './registry.js';
5
+ import { runDeveloperCommand } from './developer-command.js';
6
+ import { PARAMETER_AGENT_PROTOCOL } from './parameter-agent-protocol.js';
7
+ import { ParameterAgentError, parameterDigest, parameterInputSnapshot, readParameterAgentFile } from './parameter-agent-inputs.js';
8
+ import { listPrivateParameterTaskIds, parameterTaskWorkspace, readPrivateParameterTask, writePrivateParameterTask } from './parameter-agent-store.js';
9
+ export { ParameterAgentError } from './parameter-agent-inputs.js';
10
+ const reportPath = (taskId) => `.playcraft/parameter-admission/${taskId}.json`;
11
+ const now = () => new Date().toISOString();
12
+ function scope(state, operation, requireSession = true) {
13
+ const workspaceId = parameterTaskWorkspace(state, operation.workspaceId);
14
+ const directoryName = operation.directoryName ?? 'basic';
15
+ if ((requireSession && !operation.sessionId) || directoryName === 'root' || !/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/.test(directoryName))
16
+ throw new ParameterAgentError('parameter_agent_scope_invalid');
17
+ if (operation.environment !== state.config.environment || operation.userId !== state.config.userId
18
+ || (operation.generation !== undefined && operation.generation !== state.config.generation)
19
+ || (operation.runtimeEpoch !== undefined && operation.runtimeEpoch !== state.runtimeEpoch))
20
+ throw new ParameterAgentError('parameter_agent_scope_expired');
21
+ return { workspaceId, directoryName, sessionId: operation.sessionId ?? '' };
22
+ }
23
+ function current(state, task) {
24
+ return task.record.generation === state.config.generation && task.record.runtimeEpoch === state.runtimeEpoch;
25
+ }
26
+ async function update(state, task, patch, root) {
27
+ task.record = { ...task.record, ...patch, updatedAt: now() };
28
+ await writePrivateParameterTask(state, task, root);
29
+ return task.record;
30
+ }
31
+ async function inspectReport(root, task, snapshot) {
32
+ const bytes = await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024);
33
+ let report;
34
+ try {
35
+ report = parameterAgentReportSchema.parse(JSON.parse(bytes.toString('utf8')));
36
+ }
37
+ catch {
38
+ throw new ParameterAgentError('parameter_agent_report_invalid');
39
+ }
40
+ if (report.taskId !== task.record.taskId)
41
+ throw new ParameterAgentError('parameter_agent_report_task_mismatch');
42
+ const inputs = new Map(snapshot.sources.map(source => [source.path, source.sha256]));
43
+ const declared = new Map(report.sources.map(source => [source.path, source.sha256]));
44
+ for (const source of report.sources) {
45
+ if (!inputs.has(source.path))
46
+ throw new ParameterAgentError('parameter_agent_evidence_invalid');
47
+ if (inputs.get(source.path) !== source.sha256)
48
+ throw new ParameterAgentError('parameter_agent_report_stale');
49
+ }
50
+ for (const candidate of report.candidates) {
51
+ for (const evidence of [...candidate.origins, ...candidate.consumers]) {
52
+ if (!declared.has(evidence.path))
53
+ throw new ParameterAgentError('parameter_agent_evidence_missing');
54
+ const content = await readParameterAgentFile(root, evidence.path);
55
+ if (!content.toString('utf8').trim() || (evidence.line && evidence.line > content.toString('utf8').split('\n').length))
56
+ throw new ParameterAgentError('parameter_agent_evidence_invalid');
57
+ }
58
+ }
59
+ return report;
60
+ }
61
+ export async function prepareParameterAgentTask(state, operation, value, root) {
62
+ const request = parameterAgentTaskRequestSchema.parse(value);
63
+ if (operation.kind !== 'session.turn' || operation.clientRequestId !== request.taskId)
64
+ throw new ParameterAgentError('parameter_agent_task_identity_invalid');
65
+ const owner = scope(state, operation);
66
+ const existing = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, request.taskId, root);
67
+ if (existing && (existing.record.sessionId !== owner.sessionId || !current(state, existing) || stableStringify(existing.request) !== stableStringify(request)))
68
+ throw new ParameterAgentError('parameter_agent_task_conflict');
69
+ const document = await readWorkspaceParameters(root, state.journal.fileRevisions(revisionScope(state, operation.workspaceId, owner.directoryName)));
70
+ if (request.sourceFingerprint && request.sourceFingerprint !== document.sourceFingerprint)
71
+ throw new ParameterAgentError('parameter_source_conflict');
72
+ let selected;
73
+ if (request.candidate) {
74
+ const parent = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, request.candidate.taskId, root);
75
+ if (!parent || !current(state, parent) || ['running', 'uncertain', 'interrupted'].includes(parent.record.status))
76
+ throw new ParameterAgentError('parameter_agent_candidate_unavailable');
77
+ const snapshot = await parameterInputSnapshot(root);
78
+ const report = await inspectReport(root, parent, snapshot);
79
+ selected = report.candidates.find(candidate => candidate.id === request.candidate.candidateId);
80
+ if (!selected || !parent.record.sourceDigest || parent.record.sourceDigest !== snapshot.digest)
81
+ throw new ParameterAgentError('parameter_agent_candidate_stale');
82
+ }
83
+ const initial = await parameterInputSnapshot(root);
84
+ const timestamp = now();
85
+ const task = existing ?? {
86
+ request, initialDigest: initial.digest, instanceToken: state.instanceToken,
87
+ record: { version: 1, taskId: request.taskId, action: request.action, ...(request.goal ? { goal: request.goal } : {}), ...owner, generation: state.config.generation, runtimeEpoch: state.runtimeEpoch, status: 'running', reportPath: reportPath(request.taskId), checks: [], createdAt: timestamp, updatedAt: timestamp },
88
+ };
89
+ if (!existing) {
90
+ if ((await listPrivateParameterTaskIds(state, owner.workspaceId, owner.directoryName, root)).length >= 256)
91
+ throw new ParameterAgentError('parameter_agent_task_limit');
92
+ await writePrivateParameterTask(state, task, root);
93
+ }
94
+ const instructions = [
95
+ PARAMETER_AGENT_PROTOCOL,
96
+ `Platform task identity: ${JSON.stringify({ version: 1, taskId: request.taskId, action: request.action, reportPath: task.record.reportPath })}. Write only this exact report identity.`,
97
+ request.action === 'discover' ? 'Discovery is read-only for business source/schema/data/manifest. Write only the requested development report. Discover actual useful candidates and consumer evidence; do not implement them yet.' : 'Complete the requested integration using existing engineering tools: reuse an existing equivalent field or implement declaration, valid defaults, sparse data semantics, labels, real production consumers and meaningful parameter tests. Leave precise blocked work if incomplete.',
98
+ `User goal (task data): ${JSON.stringify(request.goal ?? '')}`,
99
+ ...(selected ? [`Selected current candidate (reference data): ${JSON.stringify(selected)}`] : []),
100
+ 'After finishing files, calculate current raw-file hashes and write the report. The platform will execute independent checks after this turn. Do not claim those checks already passed.',
101
+ ].join('\n\n');
102
+ return { instructions, task: task.record };
103
+ }
104
+ export async function finishParameterAgentTask(state, operation, status, error) {
105
+ if (operation.kind !== 'session.turn' || !operation.sessionId || operation.directoryName === 'root' || !/^[a-f0-9-]{36}$/i.test(operation.clientRequestId))
106
+ return;
107
+ let owner;
108
+ try {
109
+ owner = scope(state, operation);
110
+ }
111
+ catch {
112
+ return;
113
+ }
114
+ const task = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, operation.clientRequestId);
115
+ if (!task || !current(state, task) || task.record.sessionId !== owner.sessionId || !['running', 'uncertain'].includes(task.record.status))
116
+ return;
117
+ await update(state, task, { status: status === 'completed' ? 'reported' : status, error: error?.slice(0, 256) });
118
+ }
119
+ async function publicTask(state, task, root, snapshot) {
120
+ if (!current(state, task))
121
+ return { ...task.record, status: 'stale', error: 'parameter_agent_runtime_expired' };
122
+ try {
123
+ const report = await inspectReport(root, task, snapshot);
124
+ if (task.record.sourceDigest && task.record.sourceDigest !== snapshot.digest)
125
+ return { ...task.record, report, status: 'stale', error: 'parameter_agent_inputs_changed' };
126
+ const bytes = await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024);
127
+ if (task.reportHash && task.reportHash !== parameterDigest(bytes))
128
+ return { ...task.record, report, status: 'stale', error: 'parameter_agent_report_changed' };
129
+ return { ...task.record, report };
130
+ }
131
+ catch (cause) {
132
+ const code = errorCode(cause);
133
+ if (code === 'ENOENT' && ['running', 'uncertain', 'interrupted', 'failed'].includes(task.record.status))
134
+ return task.record;
135
+ return { ...task.record, status: code === 'ENOENT' ? 'missing' : code === 'parameter_agent_report_stale' ? 'stale' : 'invalid', error: code === 'ENOENT' ? 'parameter_agent_report_missing' : code };
136
+ }
137
+ }
138
+ export async function listParameterAgentTasks(state, workspaceId, directoryName, root) {
139
+ if (directoryName === 'root')
140
+ return [];
141
+ const workspace = parameterTaskWorkspace(state, workspaceId);
142
+ const ids = await listPrivateParameterTaskIds(state, workspace, directoryName, root);
143
+ if (!ids.length)
144
+ return [];
145
+ const snapshot = await parameterInputSnapshot(root);
146
+ const tasks = [];
147
+ for (const id of ids) {
148
+ let task = await readPrivateParameterTask(state, workspace, directoryName, id, root);
149
+ if (task && ['running', 'uncertain'].includes(task.record.status) && current(state, task)) {
150
+ const operation = state.journal.listOperations?.().map(item => item.operation).find(item => item.kind === 'session.turn'
151
+ && item.clientRequestId === id && item.sessionId === task.record.sessionId
152
+ && parameterTaskWorkspace(state, item.workspaceId) === workspace && (item.directoryName ?? 'basic') === directoryName);
153
+ if (operation && ['completed', 'failed', 'interrupted', 'uncertain'].includes(operation.status)) {
154
+ await finishParameterAgentTask(state, operation, operation.status, operation.error);
155
+ task = await readPrivateParameterTask(state, workspace, directoryName, id, root);
156
+ }
157
+ }
158
+ if (task && current(state, task) && task.instanceToken !== state.instanceToken) {
159
+ if (task.record.status === 'checking') {
160
+ await update(state, task, { status: 'needs-verification', receiptId: undefined, error: 'parameter_agent_check_interrupted' }, root);
161
+ }
162
+ else if (task.record.status === 'running') {
163
+ await update(state, task, { status: 'uncertain', receiptId: undefined, error: 'parameter_agent_turn_unconfirmed_after_restart' }, root);
164
+ }
165
+ }
166
+ if (task)
167
+ tasks.push(await publicTask(state, task, root, snapshot));
168
+ }
169
+ return tasks.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
170
+ }
171
+ function errorCode(cause) {
172
+ return cause instanceof Error && 'code' in cause && typeof cause.code === 'string' ? cause.code : 'parameter_agent_verification_failed';
173
+ }
174
+ export async function verifyParameterAgentTask(state, operation, payload, root, options = {}) {
175
+ if (operation.kind !== 'parameter.verify')
176
+ throw new ParameterAgentError('parameter_agent_task_identity_invalid');
177
+ const owner = scope(state, operation, false);
178
+ const { taskId } = parameterAgentVerifyPayloadSchema.parse(payload);
179
+ const task = await readPrivateParameterTask(state, owner.workspaceId, owner.directoryName, taskId, root);
180
+ if (!task)
181
+ throw new ParameterAgentError('parameter_agent_task_unknown');
182
+ const instanceToken = state.instanceToken;
183
+ const assertActive = () => {
184
+ options.signal?.throwIfAborted();
185
+ options.assertActive?.();
186
+ if (!current(state, task) || state.journal.destroyed() || instanceToken !== state.instanceToken)
187
+ throw new ParameterAgentError('parameter_agent_scope_expired');
188
+ };
189
+ assertActive();
190
+ if (['running', 'uncertain', 'interrupted'].includes(task.record.status))
191
+ throw new ParameterAgentError('parameter_agent_turn_not_completed');
192
+ try {
193
+ const before = await parameterInputSnapshot(root);
194
+ const report = await inspectReport(root, task, before);
195
+ task.record.report = report;
196
+ task.reportHash = parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024));
197
+ if (task.request.action === 'discover') {
198
+ if (task.initialDigest !== before.digest)
199
+ throw new ParameterAgentError('parameter_agent_discovery_modified_sources');
200
+ assertActive();
201
+ if ((await parameterInputSnapshot(root)).digest !== before.digest || parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024)) !== task.reportHash)
202
+ throw new ParameterAgentError('parameter_agent_inputs_changed');
203
+ return update(state, task, { status: 'candidates', sourceDigest: before.digest, checks: [], error: undefined, receiptId: undefined }, root);
204
+ }
205
+ const document = await readWorkspaceParameters(root, state.journal.fileRevisions(revisionScope(state, operation.workspaceId, owner.directoryName)));
206
+ if (document.status !== 'ready')
207
+ throw new ParameterAgentError('parameter_agent_parameters_not_ready');
208
+ const catalog = parameterCatalog(document.schema, document.data);
209
+ const implemented = report.candidates.filter(candidate => candidate.status === 'implemented');
210
+ if (!implemented.length || report.candidates.some(candidate => candidate.status !== 'implemented' || candidate.missing.length))
211
+ throw new ParameterAgentError('parameter_agent_integration_incomplete');
212
+ const evidence = new Set(report.sources.map(source => source.path));
213
+ for (const path of [document.schemaPath, document.dataPath, document.platformI18nPath, 'package.json']) {
214
+ if (path && !evidence.has(path))
215
+ throw new ParameterAgentError('parameter_agent_evidence_missing');
216
+ }
217
+ for (const candidate of implemented) {
218
+ const field = catalog.find(entry => candidate.fieldPath && entry.key === candidate.fieldPath.join('.'));
219
+ if (!field || !field.editable || !field.canReset)
220
+ throw new ParameterAgentError('parameter_agent_field_not_ready');
221
+ if (field.hasOverride && validateParameterChange(document.schema, document.data, { fieldPath: field.fieldPath, value: field.value }))
222
+ throw new ParameterAgentError('parameter_agent_field_not_ready');
223
+ if (!candidate.consumers.length)
224
+ throw new ParameterAgentError('parameter_agent_consumer_missing');
225
+ }
226
+ const packageData = JSON.parse((await readParameterAgentFile(root, 'package.json')).toString('utf8'));
227
+ for (const script of ['test:parameters', 'build']) {
228
+ if (typeof packageData.scripts?.[script] !== 'string' || !packageData.scripts[script].trim())
229
+ throw new ParameterAgentError('parameter_agent_check_script_missing');
230
+ }
231
+ task.instanceToken = state.instanceToken;
232
+ await update(state, task, { status: 'checking', checks: [], error: undefined, receiptId: undefined, sourceDigest: before.digest }, root);
233
+ const scripts = ['test:parameters', 'build', ...(typeof packageData.scripts?.typecheck === 'string' ? ['typecheck'] : [])];
234
+ for (const script of scripts) {
235
+ assertActive();
236
+ const controller = new AbortController();
237
+ const abort = () => controller.abort();
238
+ options.signal?.addEventListener('abort', abort, { once: true });
239
+ let activityError;
240
+ const timer = setInterval(() => { try {
241
+ assertActive();
242
+ }
243
+ catch (error) {
244
+ activityError = error;
245
+ controller.abort();
246
+ } }, 200);
247
+ const startedAt = now();
248
+ const chunks = [];
249
+ let outputSize = 0;
250
+ let outcome;
251
+ try {
252
+ outcome = await (options.runCommand ?? runDeveloperCommand)(state, {
253
+ processId: `parameter-check-${taskId}-${script.replace(':', '-')}`, cwd: root,
254
+ command: `pnpm run ${script}`, timeoutMs: options.timeoutMs ?? 120_000, signal: controller.signal,
255
+ onOutput(chunk) { const bytes = Buffer.from(chunk); outputSize += bytes.length; if (outputSize <= 1024 * 1024)
256
+ chunks.push(bytes); },
257
+ });
258
+ }
259
+ finally {
260
+ clearInterval(timer);
261
+ options.signal?.removeEventListener('abort', abort);
262
+ }
263
+ if (activityError)
264
+ throw activityError;
265
+ assertActive();
266
+ task.record.checks.push({ script: script, code: outcome.code ?? null, timedOut: outcome.timedOut, ...(outcome.canceled ? { canceled: true } : {}), logDigest: parameterDigest(Buffer.concat(chunks)), startedAt, finishedAt: now() });
267
+ await writePrivateParameterTask(state, task, root);
268
+ if (outcome.code !== 0 || outcome.timedOut || outcome.canceled)
269
+ throw new ParameterAgentError(outcome.timedOut ? 'parameter_agent_check_timeout' : outcome.canceled ? 'parameter_agent_check_canceled' : 'parameter_agent_check_failed');
270
+ }
271
+ assertActive();
272
+ const after = await parameterInputSnapshot(root);
273
+ if (after.digest !== before.digest || parameterDigest(await readParameterAgentFile(root, task.record.reportPath, 1024 * 1024)) !== task.reportHash)
274
+ throw new ParameterAgentError('parameter_agent_inputs_changed');
275
+ return update(state, task, { status: 'verified', sourceDigest: after.digest, receiptId: randomUUID(), error: undefined }, root);
276
+ }
277
+ catch (cause) {
278
+ const code = errorCode(cause);
279
+ const status = code === 'ENOENT' ? 'missing' : /stale|changed|expired/.test(code) ? 'stale' : /(?:not_ready|incomplete|consumer_missing|script_missing|evidence_missing)$/.test(code) ? 'needs-verification' : /report_|evidence_invalid|path_invalid/.test(code) ? 'invalid' : 'failed';
280
+ return update(state, task, { status, error: code === 'ENOENT' ? 'parameter_agent_report_missing' : code, receiptId: undefined }, root);
281
+ }
282
+ }
@@ -1,15 +1,40 @@
1
1
  import { readdir, readFile, realpath } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
2
3
  import { relative, resolve, sep } from 'node:path';
3
4
  import JSON5 from 'json5';
4
- import { parameterDocumentSchema, parameterPatchPayloadSchema, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { parameterDocumentSchema, parameterPatchPayloadSchema, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
5
6
  import { InvalidWorkspacePathError, writeWorkspaceFile, } from './files.js';
6
- import { ParameterEditError, replaceJson5Path } from './json5-edit.js';
7
+ import { editJson5Paths, ParameterEditError } from './json5-edit.js';
7
8
  const MANIFEST_PATH = '.playcraft/workspace.json';
8
9
  const DEFAULT_SCHEMA_PATH = 'src/theme/theme.schema.json5';
9
10
  const THEME_INDEX_PATH = 'src/theme/index.ts';
10
11
  const THEME_ROOT = 'src/theme';
11
12
  export async function readWorkspaceParameters(root, revisions) {
12
- const located = await locateParameterFiles(root);
13
+ for (let attempt = 0; attempt < 3; attempt++) {
14
+ const located = await locateParameterFiles(root);
15
+ const before = await sourceFingerprint(root, located);
16
+ const document = await readParameterDocuments(root, revisions, located);
17
+ const after = await sourceFingerprint(root, await locateParameterFiles(root));
18
+ if (before === after)
19
+ return parameterDocumentSchema.parse({ ...document, sourceFingerprint: after });
20
+ }
21
+ throw new ParameterEditError('parameter_source_changed');
22
+ }
23
+ async function sourceFingerprint(root, located) {
24
+ const sources = await Promise.all([
25
+ located.schemaPath ? readOptionalText(root, located.schemaPath) : undefined,
26
+ located.schemaPath ? readOptionalText(root, platformI18nPath(located.schemaPath)) : undefined,
27
+ readOptionalText(root, MANIFEST_PATH),
28
+ readOptionalText(root, THEME_INDEX_PATH),
29
+ ]);
30
+ return createHash('sha256').update(JSON.stringify({
31
+ schemaPath: located.schemaPath ?? null,
32
+ dataPath: located.dataPath ?? null,
33
+ themeId: located.themeId ?? null,
34
+ sources: sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex')),
35
+ })).digest('hex');
36
+ }
37
+ async function readParameterDocuments(root, revisions, located) {
13
38
  if (located.errors.some((item) => item.reason === 'parameter_manifest_invalid')) {
14
39
  return parameterDocumentSchema.parse({ status: 'invalid', errors: located.errors });
15
40
  }
@@ -35,65 +60,88 @@ export async function readWorkspaceParameters(root, revisions) {
35
60
  errors: [{ path: located.schemaPath, reason: schemaFile.reason }],
36
61
  });
37
62
  }
63
+ const i18nPath = platformI18nPath(located.schemaPath);
64
+ const platformFile = await readOptionalJson5(root, i18nPath);
65
+ const schemaDocument = {
66
+ schemaPath: located.schemaPath,
67
+ themeId: located.themeId,
68
+ schema: schemaFile.value,
69
+ schemaHash: schemaFile.contentHash,
70
+ schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
71
+ ...(platformFile.status === 'ok' ? { platformI18n: platformFile.value, platformI18nPath: i18nPath } : {}),
72
+ };
73
+ if (platformFile.status === 'invalid') {
74
+ return parameterDocumentSchema.parse({
75
+ ...schemaDocument,
76
+ status: 'invalid',
77
+ errors: [{ path: i18nPath, reason: platformFile.reason }],
78
+ });
79
+ }
38
80
  if (!located.dataPath) {
39
81
  return parameterDocumentSchema.parse({
82
+ ...schemaDocument,
40
83
  status: 'schema_only',
41
- schemaPath: located.schemaPath,
42
- themeId: located.themeId,
43
- schema: schemaFile.value,
44
- schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
45
84
  ...(located.errors.length > 0 ? { errors: located.errors } : {}),
46
85
  });
47
86
  }
48
87
  const dataFile = await readOptionalJson5(root, located.dataPath);
49
88
  if (dataFile.status === 'missing') {
50
89
  return parameterDocumentSchema.parse({
90
+ ...schemaDocument,
51
91
  status: 'schema_only',
52
- schemaPath: located.schemaPath,
53
92
  dataPath: located.dataPath,
54
- themeId: located.themeId,
55
- schema: schemaFile.value,
56
- schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
57
93
  });
58
94
  }
59
95
  if (dataFile.status === 'invalid') {
60
96
  return parameterDocumentSchema.parse({
97
+ ...schemaDocument,
61
98
  status: 'invalid',
62
- schemaPath: located.schemaPath,
63
99
  dataPath: located.dataPath,
64
- themeId: located.themeId,
65
- schema: schemaFile.value,
66
- schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
67
100
  errors: [{ path: located.dataPath, reason: dataFile.reason }],
68
101
  });
69
102
  }
70
103
  return parameterDocumentSchema.parse({
104
+ ...schemaDocument,
71
105
  status: 'ready',
72
- schemaPath: located.schemaPath,
73
106
  dataPath: located.dataPath,
74
- themeId: located.themeId,
75
- schema: schemaFile.value,
76
107
  data: dataFile.value,
77
- schemaRevision: revisions[located.schemaPath]?.revision ?? 0,
108
+ dataHash: dataFile.contentHash,
78
109
  dataRevision: revisions[located.dataPath]?.revision ?? 0,
79
110
  });
80
111
  }
81
- export async function patchWorkspaceParameters(root, revisions, payload) {
112
+ export async function patchWorkspaceParameters(root, revisions, payload, observed) {
82
113
  const parsed = parameterPatchPayloadSchema.parse(payload);
83
114
  const document = await readWorkspaceParameters(root, revisions);
115
+ if (document.status === 'invalid')
116
+ throw new ParameterEditError('parameter_document_invalid');
84
117
  if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
85
118
  throw new ParameterEditError('parameter_data_missing');
86
119
  }
87
- if (document.status === 'invalid')
88
- throw new ParameterEditError('parameter_document_invalid');
89
- const typeError = validateAgainstSchema(document.schema, parsed.fieldPath, parsed.value);
90
- if (typeError)
91
- throw new ParameterEditError(typeError);
120
+ if ((parsed.sourceFingerprint && parsed.sourceFingerprint !== document.sourceFingerprint)
121
+ || (observed && observed.dataPath !== document.dataPath)) {
122
+ throw new ParameterEditError('parameter_source_conflict');
123
+ }
124
+ const changes = 'changes' in parsed ? parsed.changes : [{ fieldPath: parsed.fieldPath, value: parsed.value }];
125
+ for (const change of changes) {
126
+ const validationError = validateParameterChange(document.schema, document.data, change);
127
+ if (validationError)
128
+ throw new ParameterEditError(validationError);
129
+ }
92
130
  const source = await readOptionalText(root, document.dataPath);
93
131
  if (source === undefined)
94
132
  throw new ParameterEditError('parameter_data_missing');
95
- const next = replaceJson5Path(source, parsed.fieldPath, parsed.value);
133
+ if (observed && createHash('sha256').update(source).digest('hex') !== observed.contentHash) {
134
+ throw new ParameterEditError('revision_conflict');
135
+ }
136
+ const next = editJson5Paths(source, changes);
96
137
  JSON5.parse(next);
138
+ if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX) {
139
+ throw new ParameterEditError('parameter_document_invalid');
140
+ }
141
+ const current = await locateParameterFiles(root);
142
+ if (await sourceFingerprint(root, current) !== document.sourceFingerprint) {
143
+ throw new ParameterEditError('parameter_source_conflict');
144
+ }
97
145
  return writeWorkspaceFile(root, document.dataPath, next);
98
146
  }
99
147
  async function locateParameterFiles(root) {
@@ -210,7 +258,7 @@ async function readOptionalJson5(root, relativePath) {
210
258
  if (text === undefined)
211
259
  return { status: 'missing' };
212
260
  try {
213
- return { status: 'ok', value: JSON5.parse(text) };
261
+ return { status: 'ok', value: JSON5.parse(text), contentHash: createHash('sha256').update(text).digest('hex') };
214
262
  }
215
263
  catch {
216
264
  return { status: 'invalid', reason: 'parameter_document_invalid' };
@@ -242,31 +290,6 @@ async function readOptionalText(root, relativePath) {
242
290
  throw error;
243
291
  }
244
292
  }
245
- function validateAgainstSchema(schema, fieldPath, value) {
246
- let node = schema;
247
- for (const key of fieldPath) {
248
- if (!node || typeof node !== 'object' || Array.isArray(node))
249
- return 'parameter_value_invalid';
250
- const properties = node.properties;
251
- if (!properties || typeof properties !== 'object' || Array.isArray(properties))
252
- return 'parameter_value_invalid';
253
- node = properties[key];
254
- if (node === undefined)
255
- return undefined;
256
- }
257
- if (!node || typeof node !== 'object' || Array.isArray(node))
258
- return undefined;
259
- const type = node.type;
260
- if (type === 'string')
261
- return typeof value === 'string' ? undefined : 'parameter_value_invalid';
262
- if (type === 'boolean')
263
- return typeof value === 'boolean' ? undefined : 'parameter_value_invalid';
264
- if (type === 'integer')
265
- return Number.isInteger(value) ? undefined : 'parameter_value_invalid';
266
- if (type === 'number') {
267
- return typeof value === 'number' && Number.isFinite(value) ? undefined : 'parameter_value_invalid';
268
- }
269
- if (type === 'null')
270
- return value === null ? undefined : 'parameter_value_invalid';
271
- return undefined;
293
+ function platformI18nPath(schemaPath) {
294
+ return [...schemaPath.split('/').slice(0, -1), 'i18n.platform.json5'].join('/');
272
295
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.49-beta",
3
+ "version": "0.1.50-beta",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,8 +51,8 @@
51
51
  "@gltf-transform/core": "^4.3.0",
52
52
  "@gltf-transform/extensions": "^4.3.0",
53
53
  "@gltf-transform/functions": "^4.3.0",
54
- "@playcraft/build": "0.1.49-beta",
55
- "@playcraft/common": "0.1.49-beta",
54
+ "@playcraft/build": "0.1.50-beta",
55
+ "@playcraft/common": "0.1.50-beta",
56
56
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
57
57
  "chokidar": "^4.0.3",
58
58
  "commander": "^13.1.0",