@playcraft/cli 0.1.57-beta → 0.1.59-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/commands/workspace.js +43 -0
- package/dist/utils/agent-generation-provenance.js +1 -1
- package/dist/workspace/messages.js +10 -0
- package/dist/workspace-runtime/batch/deliver.js +140 -80
- package/dist/workspace-runtime/batch/freeze.js +18 -6
- package/dist/workspace-runtime/batch/generate.js +68 -64
- package/dist/workspace-runtime/batch/policy.js +71 -22
- package/dist/workspace-runtime/batch/prepare.js +108 -93
- package/dist/workspace-runtime/batch/records.js +4 -0
- package/dist/workspace-runtime/batch/routes.js +10 -2
- package/dist/workspace-runtime/batch/source-candidate.js +2 -0
- package/dist/workspace-runtime/batch/source-capture.js +1 -0
- package/dist/workspace-runtime/batch/source-failure.js +2 -0
- package/dist/workspace-runtime/batch/source-publish.js +3 -0
- package/dist/workspace-runtime/batch/writer-proof.js +37 -0
- package/dist/workspace-runtime/codex/app-server.js +278 -20
- package/dist/workspace-runtime/codex/config-toml.js +4 -0
- package/dist/workspace-runtime/codex/delivery-tools.js +1 -1
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
- package/dist/workspace-runtime/codex/loopback.js +2 -0
- package/dist/workspace-runtime/codex/model-broker.js +7 -4
- package/dist/workspace-runtime/persistence/journal.js +34 -0
- package/dist/workspace-runtime/persistence/sqlite-worker.js +68 -6
- package/dist/workspace-runtime/previews/output.js +2 -0
- package/dist/workspace-runtime/previews/publish.js +20 -5
- package/dist/workspace-runtime/previews/runtime-preview.js +4 -0
- package/dist/workspace-runtime/processes/managed-writes.js +47 -5
- package/dist/workspace-runtime/serve.js +13 -3
- package/dist/workspace-runtime/server/accept-operation.js +22 -2
- package/dist/workspace-runtime/server/directory-access.js +414 -0
- package/dist/workspace-runtime/server/dispatch.js +74 -29
- package/dist/workspace-runtime/server/endpoints.js +1 -0
- package/dist/workspace-runtime/server/http.js +80 -10
- package/dist/workspace-runtime/server/operation-record.js +2 -0
- package/dist/workspace-runtime/server/terminal-host.js +124 -8
- package/dist/workspace-runtime/server/workspace-storage.js +193 -0
- package/dist/workspace-runtime/server/workspace-transfer.js +3 -1
- package/dist/workspace-runtime/workspaces/append-only-log.js +3 -0
- package/dist/workspace-runtime/workspaces/artifact-events.js +5 -2
- package/dist/workspace-runtime/workspaces/artifact-files.js +14 -8
- package/dist/workspace-runtime/workspaces/artifact-receipts.js +7 -25
- package/dist/workspace-runtime/workspaces/artifacts.js +4 -1
- package/dist/workspace-runtime/workspaces/asset-library-import.js +9 -4
- package/dist/workspace-runtime/workspaces/asset-publication-access.js +7 -9
- package/dist/workspace-runtime/workspaces/asset-transfer-publish.js +3 -0
- package/dist/workspace-runtime/workspaces/asset-transfer-storage.js +17 -2
- package/dist/workspace-runtime/workspaces/attachments.js +9 -0
- package/dist/workspace-runtime/workspaces/batch-commit.js +5 -3
- package/dist/workspace-runtime/workspaces/binary-write.js +12 -7
- package/dist/workspace-runtime/workspaces/cli-context.js +2 -0
- package/dist/workspace-runtime/workspaces/cli-credentials.js +1 -0
- package/dist/workspace-runtime/workspaces/delivery-build.js +30 -11
- package/dist/workspace-runtime/workspaces/delivery-store.js +2 -0
- package/dist/workspace-runtime/workspaces/dependencies.js +90 -70
- package/dist/workspace-runtime/workspaces/developer-command.js +12 -1
- package/dist/workspace-runtime/workspaces/directory-access.js +13 -18
- package/dist/workspace-runtime/workspaces/directory-attachments.js +8 -0
- package/dist/workspace-runtime/workspaces/directory-create.js +18 -5
- package/dist/workspace-runtime/workspaces/directory-maintenance.js +46 -35
- package/dist/workspace-runtime/workspaces/directory-mutations.js +8 -3
- package/dist/workspace-runtime/workspaces/files.js +4 -0
- package/dist/workspace-runtime/workspaces/git-credential-broker.js +3 -0
- package/dist/workspace-runtime/workspaces/git-direct-store.js +2 -0
- package/dist/workspace-runtime/workspaces/json5-edit.js +36 -0
- package/dist/workspace-runtime/workspaces/library-attachments.js +4 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-protocol.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent-store.js +2 -0
- package/dist/workspace-runtime/workspaces/parameter-agent.js +40 -11
- package/dist/workspace-runtime/workspaces/parameter-assets.js +1 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill-transaction.js +141 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +329 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization.js +63 -0
- package/dist/workspace-runtime/workspaces/parameter-production.js +124 -0
- package/dist/workspace-runtime/workspaces/parameters.js +95 -3
- package/dist/workspace-runtime/workspaces/platform-export.js +5 -0
- package/dist/workspace-runtime/workspaces/prepare.js +4 -0
- package/dist/workspace-runtime/workspaces/registry.js +55 -3
- package/dist/workspace-runtime/workspaces/remix-references.js +5 -0
- package/dist/workspace-runtime/workspaces/shared-write.js +187 -0
- package/dist/workspace-runtime/workspaces/skills.js +14 -15
- package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
- package/dist/workspace-runtime/workspaces/transfer.js +4 -1
- package/dist/workspace-runtime/workspaces/workspace-shares.js +235 -0
- package/dist/workspace-runtime/workspaces/worktree-attachments.js +2 -0
- package/dist/workspace-runtime/workspaces/worktree-checkout.js +11 -6
- package/package.json +5 -4
- package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +32 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md +21 -0
- package/project-template/.claude/skills/playcraft-runtime-parameters/references/preview-integration.md +57 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, readFile, readdir, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { canonicalProjectSkillsJson, createCanonicalBundleArchive, declarationDigest, dependencyDigest, PROJECT_SKILL_SOURCE_ROOTS, readProjectSkillSource, readProjectSkillsLockFile, validateProjectSkillsSnapshot, } from '@playcraft/common/project-skills';
|
|
6
|
+
import { resolveCliPackageRoot } from '../../scaffold/compose.js';
|
|
7
|
+
import { excludeFromGit } from './dependencies.js';
|
|
8
|
+
import { ensureSharedDirectory, openSharedFile, shareIfNeeded } from './shared-write.js';
|
|
9
|
+
import { acquireParameterSkillLease, readParameterSkillTransactionFile, writeParameterSkillTransaction } from './parameter-initialization-skill-transaction.js';
|
|
10
|
+
export const PARAMETER_INITIALIZATION_SKILL = 'playcraft-runtime-parameters';
|
|
11
|
+
const sourceRoot = `.agents/skills/${PARAMETER_INITIALIZATION_SKILL}`;
|
|
12
|
+
const skillRef = `builtin/${PARAMETER_INITIALIZATION_SKILL}`;
|
|
13
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
14
|
+
async function safeDirectory(root, path) {
|
|
15
|
+
let current = root;
|
|
16
|
+
for (const segment of path.split('/')) {
|
|
17
|
+
current = join(current, segment);
|
|
18
|
+
const stat = await lstat(current).catch((error) => {
|
|
19
|
+
if (error.code === 'ENOENT')
|
|
20
|
+
return undefined;
|
|
21
|
+
throw error;
|
|
22
|
+
});
|
|
23
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink()))
|
|
24
|
+
throw new Error('workspace_skills_invalid');
|
|
25
|
+
}
|
|
26
|
+
await ensureSharedDirectory(root, current);
|
|
27
|
+
return current;
|
|
28
|
+
}
|
|
29
|
+
async function writeExclusiveShared(root, path, data) {
|
|
30
|
+
const handle = await openSharedFile(root, path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW);
|
|
31
|
+
try {
|
|
32
|
+
await handle.writeFile(data);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await handle.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function optionalFile(path) {
|
|
39
|
+
try {
|
|
40
|
+
const stat = await lstat(path);
|
|
41
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 1024 * 1024)
|
|
42
|
+
throw new Error('workspace_skills_invalid');
|
|
43
|
+
return await readFile(path);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === 'ENOENT')
|
|
47
|
+
return undefined;
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function installSource(root, files, token) {
|
|
52
|
+
const directory = join(root, sourceRoot);
|
|
53
|
+
const staging = await safeDirectory(root, `.playcraft/parameter-initialization-${token}`);
|
|
54
|
+
const temporary = join(staging, 'source');
|
|
55
|
+
await ensureSharedDirectory(root, temporary);
|
|
56
|
+
await safeDirectory(root, '.agents/skills');
|
|
57
|
+
try {
|
|
58
|
+
for (const file of files) {
|
|
59
|
+
if (dirname(file.path) !== '.')
|
|
60
|
+
await safeDirectory(temporary, dirname(file.path));
|
|
61
|
+
await writeExclusiveShared(root, join(temporary, file.path), file.content);
|
|
62
|
+
}
|
|
63
|
+
if (await lstat(directory).then(() => true, error => { if (error.code === 'ENOENT')
|
|
64
|
+
return false; throw error; }))
|
|
65
|
+
throw new Error('workspace_skills_parameter_conflict');
|
|
66
|
+
await rename(temporary, directory);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
await rm(staging, { recursive: true, force: true });
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
await rm(staging, { recursive: true, force: true }).catch(() => undefined);
|
|
73
|
+
}
|
|
74
|
+
async function preserveArtifact(root, artifactHash, archive, token) {
|
|
75
|
+
const directory = await safeDirectory(root, '.playcraft/skill-artifacts');
|
|
76
|
+
const path = join(directory, `${artifactHash}.tar.gz`);
|
|
77
|
+
const existing = await readParameterSkillTransactionFile(path);
|
|
78
|
+
if (existing) {
|
|
79
|
+
if (!existing.equals(archive))
|
|
80
|
+
throw new Error('workspace_skills_digest_mismatch');
|
|
81
|
+
await shareIfNeeded(root, path);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const temporary = `${path}.${token}.tmp`;
|
|
85
|
+
try {
|
|
86
|
+
await writeExclusiveShared(root, temporary, archive);
|
|
87
|
+
await rename(temporary, path);
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
await rm(temporary, { force: true });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function snapshot(originalDeclaration, originalLock) {
|
|
94
|
+
if ((originalDeclaration === undefined) !== (originalLock === null))
|
|
95
|
+
throw new Error('workspace_skills_invalid');
|
|
96
|
+
if (originalDeclaration !== undefined)
|
|
97
|
+
return validateProjectSkillsSnapshot(originalDeclaration.toString('utf8'), originalLock);
|
|
98
|
+
const declaration = { schemaVersion: 1, entries: [] };
|
|
99
|
+
return { declaration, lock: { lockVersion: 3, declarationHash: declarationDigest(declaration), entries: [] } };
|
|
100
|
+
}
|
|
101
|
+
function selectedSnapshot(previous, templateHash, artifactHash) {
|
|
102
|
+
const next = structuredClone(previous);
|
|
103
|
+
const matches = next.declaration.entries.filter(entry => entry.entryKey === PARAMETER_INITIALIZATION_SKILL
|
|
104
|
+
|| entry.source.skillRef === skillRef || entry.source.kind === 'repository' && entry.source.root === sourceRoot);
|
|
105
|
+
if (matches.length > 1)
|
|
106
|
+
throw new Error('workspace_skills_parameter_conflict');
|
|
107
|
+
const existing = matches[0];
|
|
108
|
+
let selected;
|
|
109
|
+
if (existing) {
|
|
110
|
+
const locked = next.lock.entries.find(entry => entry.entryKey === existing.entryKey);
|
|
111
|
+
if (existing.source.kind !== 'repository' || existing.source.root !== sourceRoot
|
|
112
|
+
|| existing.source.skillRef !== skillRef || existing.surface !== 'sandbox-agent' || existing.projectFiles?.length
|
|
113
|
+
|| locked.dependencies.length || locked.revision.kind !== 'source' || locked.sourceTreeHash !== templateHash
|
|
114
|
+
|| locked.revision.artifactHash !== artifactHash)
|
|
115
|
+
throw new Error('workspace_skills_parameter_conflict');
|
|
116
|
+
selected = existing;
|
|
117
|
+
selected.enabled = true;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
selected = { entryKey: PARAMETER_INITIALIZATION_SKILL, source: { kind: 'repository', root: sourceRoot, skillRef },
|
|
121
|
+
enabled: true, surface: 'sandbox-agent' };
|
|
122
|
+
const locked = { entryKey: selected.entryKey, sourceTreeHash: templateHash,
|
|
123
|
+
revision: { kind: 'source', skillRef, sourceRevision: `src-v1-sha256:${templateHash}`, artifactHash },
|
|
124
|
+
dependencies: [], dependencyLockHash: dependencyDigest([], []) };
|
|
125
|
+
next.declaration.entries.push(selected);
|
|
126
|
+
next.lock.entries.push(locked);
|
|
127
|
+
}
|
|
128
|
+
next.lock.declarationHash = declarationDigest(next.declaration);
|
|
129
|
+
validateProjectSkillsSnapshot(next.declaration, next.lock);
|
|
130
|
+
return { next, selected, sourceAdded: !existing };
|
|
131
|
+
}
|
|
132
|
+
async function cleanStaging(root, transaction, files, archive) {
|
|
133
|
+
const directory = join(root, '.playcraft', `parameter-initialization-${transaction.owner.token}`);
|
|
134
|
+
const expected = new Map(files.map(file => [`source/${file.path}`, Buffer.from(file.content)]));
|
|
135
|
+
const visit = async (path, relative = '') => {
|
|
136
|
+
const stat = await lstat(path);
|
|
137
|
+
if (stat.isSymbolicLink())
|
|
138
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
139
|
+
if (stat.isDirectory()) {
|
|
140
|
+
if (relative && ![...expected.keys()].some(file => file.startsWith(`${relative}/`)))
|
|
141
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
142
|
+
for (const name of await readdir(path))
|
|
143
|
+
await visit(join(path, name), relative ? `${relative}/${name}` : name);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
const bytes = await readParameterSkillTransactionFile(path);
|
|
147
|
+
const wanted = expected.get(relative);
|
|
148
|
+
if (!bytes || !wanted || !wanted.subarray(0, bytes.length).equals(bytes))
|
|
149
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
if (await lstat(directory).then(() => true, error => { if (error.code === 'ENOENT')
|
|
153
|
+
return false; throw error; })) {
|
|
154
|
+
await visit(directory);
|
|
155
|
+
await rm(directory, { recursive: true });
|
|
156
|
+
}
|
|
157
|
+
const temporaryFiles = [
|
|
158
|
+
['skills.json', Buffer.from(transaction.nextDeclaration)], ['skills.lock.json', Buffer.from(transaction.nextLock)],
|
|
159
|
+
[`skill-artifacts/${transaction.artifactHash}.tar.gz`, archive],
|
|
160
|
+
];
|
|
161
|
+
for (const [name, expectedBytes] of temporaryFiles) {
|
|
162
|
+
const path = join(root, '.playcraft', `${name}.${transaction.owner.token}.tmp`);
|
|
163
|
+
const bytes = await readParameterSkillTransactionFile(path);
|
|
164
|
+
if (bytes) {
|
|
165
|
+
if (!expectedBytes.subarray(0, bytes.length).equals(bytes))
|
|
166
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
167
|
+
await rm(path);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function recoverTransaction(root, transaction, template, artifactHash, archive) {
|
|
172
|
+
if (transaction.templateHash !== template.sourceTreeHash || transaction.artifactHash !== artifactHash)
|
|
173
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
174
|
+
const originalDeclaration = transaction.originalDeclaration === null ? undefined : Buffer.from(transaction.originalDeclaration, 'base64');
|
|
175
|
+
if (originalDeclaration && originalDeclaration.toString('base64') !== transaction.originalDeclaration)
|
|
176
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
177
|
+
const previous = snapshot(originalDeclaration, transaction.originalLock);
|
|
178
|
+
const { next, selected, sourceAdded } = selectedSnapshot(previous, template.sourceTreeHash, artifactHash);
|
|
179
|
+
if (sourceAdded !== transaction.sourceAdded || `${canonicalProjectSkillsJson(next.declaration)}\n` !== transaction.nextDeclaration
|
|
180
|
+
|| `${canonicalProjectSkillsJson(next.lock)}\n` !== transaction.nextLock)
|
|
181
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
182
|
+
const declarationPath = join(root, '.playcraft/skills.json'), lockPath = join(root, '.playcraft/skills.lock.json');
|
|
183
|
+
const declaration = await optionalFile(declarationPath), lock = await readProjectSkillsLockFile(root);
|
|
184
|
+
const oldDeclaration = declaration === undefined ? originalDeclaration === undefined : !!originalDeclaration?.equals(declaration);
|
|
185
|
+
const newDeclaration = declaration?.equals(Buffer.from(transaction.nextDeclaration));
|
|
186
|
+
const oldLock = lock === transaction.originalLock, newLock = lock === transaction.nextLock;
|
|
187
|
+
if (!(oldDeclaration && oldLock || newDeclaration && (oldLock || newLock)))
|
|
188
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
189
|
+
const sourceExists = await lstat(join(root, sourceRoot)).then(() => true, error => { if (error.code === 'ENOENT')
|
|
190
|
+
return false; throw error; });
|
|
191
|
+
if (!sourceExists) {
|
|
192
|
+
if (!sourceAdded || !oldDeclaration || !oldLock)
|
|
193
|
+
throw new Error('workspace_skills_parameter_recovery_conflict');
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
if ((await readProjectSkillSource(root, selected)).sourceTreeHash !== template.sourceTreeHash)
|
|
197
|
+
throw new Error('workspace_skills_source_changed');
|
|
198
|
+
// The exact source and pair identify the interrupted import; finish only its recorded transition.
|
|
199
|
+
for (const [path, content] of [[declarationPath, transaction.nextDeclaration], [lockPath, transaction.nextLock]]) {
|
|
200
|
+
if ((await optionalFile(path))?.equals(Buffer.from(content)))
|
|
201
|
+
continue;
|
|
202
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
203
|
+
try {
|
|
204
|
+
await writeExclusiveShared(root, temporary, content);
|
|
205
|
+
await rename(temporary, path);
|
|
206
|
+
}
|
|
207
|
+
finally {
|
|
208
|
+
await rm(temporary, { force: true });
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
await cleanStaging(root, transaction, template.files, archive);
|
|
213
|
+
}
|
|
214
|
+
/** A deliberate initialization selects one versioned project skill without synchronizing other sources. */
|
|
215
|
+
export async function ensureParameterInitializationSkill(root) {
|
|
216
|
+
const rootStat = await lstat(root);
|
|
217
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
218
|
+
throw new Error('workspace_skills_invalid');
|
|
219
|
+
const templateEntry = { entryKey: PARAMETER_INITIALIZATION_SKILL,
|
|
220
|
+
source: { kind: 'repository', root: `project-template/.claude/skills/${PARAMETER_INITIALIZATION_SKILL}`, skillRef },
|
|
221
|
+
enabled: true, surface: 'sandbox-agent' };
|
|
222
|
+
const template = await readProjectSkillSource(resolveCliPackageRoot(), templateEntry);
|
|
223
|
+
const archive = createCanonicalBundleArchive(template.files.map(file => ({ path: file.path, data: Buffer.from(file.content) })));
|
|
224
|
+
const artifactHash = digest(archive);
|
|
225
|
+
await excludeFromGit(root, '/.playcraft/skills/');
|
|
226
|
+
await excludeFromGit(root, '/.playcraft/skill-artifacts/');
|
|
227
|
+
await excludeFromGit(root, '/.playcraft/skills-write.lock');
|
|
228
|
+
await excludeFromGit(root, '/.playcraft/parameter-initialization-*');
|
|
229
|
+
const management = await safeDirectory(root, '.playcraft');
|
|
230
|
+
const lease = await acquireParameterSkillLease(management, transaction => recoverTransaction(root, transaction, template, artifactHash, archive));
|
|
231
|
+
const staged = [];
|
|
232
|
+
let ownedSource = false, committed = false, cleanupAllowed = true;
|
|
233
|
+
let removeTransaction;
|
|
234
|
+
try {
|
|
235
|
+
const declarationPath = join(management, 'skills.json'), lockPath = join(management, 'skills.lock.json');
|
|
236
|
+
const originalDeclaration = await optionalFile(declarationPath);
|
|
237
|
+
const originalLock = await readProjectSkillsLockFile(root);
|
|
238
|
+
const previous = snapshot(originalDeclaration, originalLock);
|
|
239
|
+
const { next, selected, sourceAdded } = selectedSnapshot(previous, template.sourceTreeHash, artifactHash);
|
|
240
|
+
if (!sourceAdded) {
|
|
241
|
+
if ((await readProjectSkillSource(root, selected)).sourceTreeHash !== template.sourceTreeHash)
|
|
242
|
+
throw new Error('workspace_skills_source_changed');
|
|
243
|
+
await safeDirectory(root, sourceRoot);
|
|
244
|
+
for (const file of template.files) {
|
|
245
|
+
if (dirname(file.path) !== '.')
|
|
246
|
+
await safeDirectory(root, `${sourceRoot}/${dirname(file.path)}`);
|
|
247
|
+
await shareIfNeeded(root, join(root, sourceRoot, file.path));
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
for (const source of PROJECT_SKILL_SOURCE_ROOTS) {
|
|
252
|
+
const path = join(root, source, PARAMETER_INITIALIZATION_SKILL);
|
|
253
|
+
const exists = await lstat(path).then(() => true, error => {
|
|
254
|
+
if (error.code === 'ENOENT')
|
|
255
|
+
return false;
|
|
256
|
+
throw error;
|
|
257
|
+
});
|
|
258
|
+
if (exists)
|
|
259
|
+
throw new Error('workspace_skills_parameter_conflict');
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (canonicalProjectSkillsJson(previous) !== canonicalProjectSkillsJson(next)) {
|
|
263
|
+
removeTransaction = await writeParameterSkillTransaction(management, { version: 1, owner: lease.owner,
|
|
264
|
+
templateHash: template.sourceTreeHash, artifactHash, sourceAdded,
|
|
265
|
+
originalDeclaration: originalDeclaration?.toString('base64') ?? null, originalLock,
|
|
266
|
+
nextDeclaration: `${canonicalProjectSkillsJson(next.declaration)}\n`, nextLock: `${canonicalProjectSkillsJson(next.lock)}\n` });
|
|
267
|
+
if (sourceAdded) {
|
|
268
|
+
await installSource(root, template.files, lease.owner.token);
|
|
269
|
+
ownedSource = true;
|
|
270
|
+
}
|
|
271
|
+
await preserveArtifact(root, artifactHash, archive, lease.owner.token);
|
|
272
|
+
const pending = [];
|
|
273
|
+
for (const [path, value] of [[declarationPath, next.declaration], [lockPath, next.lock]]) {
|
|
274
|
+
const temporary = `${path}.${lease.owner.token}.tmp`;
|
|
275
|
+
staged.push(temporary);
|
|
276
|
+
await writeExclusiveShared(root, temporary, `${canonicalProjectSkillsJson(value)}\n`);
|
|
277
|
+
pending.push(temporary);
|
|
278
|
+
}
|
|
279
|
+
const currentDeclaration = await optionalFile(declarationPath);
|
|
280
|
+
if (currentDeclaration === undefined ? originalDeclaration !== undefined
|
|
281
|
+
: originalDeclaration === undefined || !currentDeclaration.equals(originalDeclaration))
|
|
282
|
+
throw new Error('skills_revision_conflict');
|
|
283
|
+
if (await readProjectSkillsLockFile(root) !== originalLock)
|
|
284
|
+
throw new Error('skills_revision_conflict');
|
|
285
|
+
await rename(pending[0], declarationPath);
|
|
286
|
+
try {
|
|
287
|
+
await rename(pending[1], lockPath);
|
|
288
|
+
}
|
|
289
|
+
catch (error) {
|
|
290
|
+
try {
|
|
291
|
+
if (originalDeclaration === undefined)
|
|
292
|
+
await rm(declarationPath, { force: true });
|
|
293
|
+
else {
|
|
294
|
+
const restore = `${declarationPath}.${randomUUID()}.tmp`;
|
|
295
|
+
staged.push(restore);
|
|
296
|
+
await writeExclusiveShared(root, restore, originalDeclaration);
|
|
297
|
+
await rename(restore, declarationPath);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (restoreError) {
|
|
301
|
+
cleanupAllowed = false;
|
|
302
|
+
throw restoreError;
|
|
303
|
+
}
|
|
304
|
+
throw error;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else
|
|
308
|
+
await preserveArtifact(root, artifactHash, archive, lease.owner.token);
|
|
309
|
+
committed = true;
|
|
310
|
+
await shareIfNeeded(root, declarationPath);
|
|
311
|
+
await shareIfNeeded(root, lockPath);
|
|
312
|
+
return { entryKey: selected.entryKey, lockHash: digest(canonicalProjectSkillsJson(next.lock)) };
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
if (cleanupAllowed && ownedSource && !committed) {
|
|
316
|
+
const entry = { entryKey: PARAMETER_INITIALIZATION_SKILL,
|
|
317
|
+
source: { kind: 'repository', root: sourceRoot }, enabled: true, surface: 'sandbox-agent' };
|
|
318
|
+
if ((await readProjectSkillSource(root, entry)).sourceTreeHash !== template.sourceTreeHash)
|
|
319
|
+
throw new Error('workspace_skills_source_changed');
|
|
320
|
+
await rm(join(root, sourceRoot), { recursive: true });
|
|
321
|
+
}
|
|
322
|
+
if (cleanupAllowed) {
|
|
323
|
+
for (const path of staged)
|
|
324
|
+
await rm(path, { force: true });
|
|
325
|
+
await removeTransaction?.();
|
|
326
|
+
await lease.release();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import JSON5 from 'json5';
|
|
2
|
+
import { workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
import { PREVIEW_RUNTIME_DIRECTORY, readPreviewRuntimeDistribution } from '../../preview-runtime/distribution.js';
|
|
4
|
+
import { ParameterAgentError, parameterDigest, readParameterAgentFile } from './parameter-agent-inputs.js';
|
|
5
|
+
const record = (value) => value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
6
|
+
const path = (value) => workspaceRelativePathSchema.safeParse(value).success;
|
|
7
|
+
async function input(root, file) {
|
|
8
|
+
try {
|
|
9
|
+
return await readParameterAgentFile(root, file);
|
|
10
|
+
}
|
|
11
|
+
catch (error) {
|
|
12
|
+
if (error.code === 'ENOENT')
|
|
13
|
+
throw new ParameterAgentError('parameter_agent_preview_inputs_not_ready');
|
|
14
|
+
throw error;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Check admission inputs without building a preview, deleting outputs or declaring fields live. */
|
|
18
|
+
export async function inspectParameterInitialization(root, document, report) {
|
|
19
|
+
const manifestBytes = await input(root, '.playcraft/workspace.json');
|
|
20
|
+
let manifest;
|
|
21
|
+
try {
|
|
22
|
+
manifest = record(JSON5.parse(manifestBytes.toString('utf8')));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new ParameterAgentError('parameter_agent_preview_manifest_not_ready');
|
|
26
|
+
}
|
|
27
|
+
const parameters = record(manifest?.parameters);
|
|
28
|
+
const preview = record(manifest?.previewRuntime);
|
|
29
|
+
const allowed = new Set(['version', 'buildScript', 'outputPath', 'reportPath', 'configContractVersion', 'entryPath', 'viteConfigPath', 'mountId', 'gameFilePaths']);
|
|
30
|
+
if (!parameters || parameters.schemaPath !== document.schemaPath || parameters.dataPath !== document.dataPath ||
|
|
31
|
+
!document.platformI18nPath || !preview || Object.keys(preview).some(key => !allowed.has(key)) || preview.version !== 1 ||
|
|
32
|
+
typeof preview.buildScript !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9:_-]{0,63}$/.test(preview.buildScript) ||
|
|
33
|
+
!path(preview.outputPath) || !preview.outputPath.startsWith('.playcraft-editor-preview/') || !preview.outputPath.endsWith('.html') ||
|
|
34
|
+
!path(preview.reportPath) || !preview.reportPath.startsWith('.playcraft-editor-preview/') || !preview.reportPath.endsWith('.json') ||
|
|
35
|
+
typeof preview.configContractVersion !== 'string' || !preview.configContractVersion.length || preview.configContractVersion.length > 128 ||
|
|
36
|
+
!path(preview.entryPath) || (preview.viteConfigPath !== undefined && !path(preview.viteConfigPath)) ||
|
|
37
|
+
(preview.mountId !== undefined && (typeof preview.mountId !== 'string' || !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(preview.mountId))) ||
|
|
38
|
+
!Array.isArray(preview.gameFilePaths) || preview.gameFilePaths.length > 256 || !preview.gameFilePaths.every(path))
|
|
39
|
+
throw new ParameterAgentError('parameter_agent_preview_manifest_not_ready');
|
|
40
|
+
const files = new Set(preview.gameFilePaths);
|
|
41
|
+
if (!files.has(preview.entryPath) || report.candidates.some(candidate => candidate.status === 'implemented' && !candidate.consumers.some(consumer => files.has(consumer.path))))
|
|
42
|
+
throw new ParameterAgentError('parameter_agent_preview_inputs_not_ready');
|
|
43
|
+
const packageData = JSON.parse((await input(root, 'package.json')).toString('utf8'));
|
|
44
|
+
if (typeof packageData.scripts?.[preview.buildScript] !== 'string' || !packageData.scripts[preview.buildScript].trim())
|
|
45
|
+
throw new ParameterAgentError('parameter_agent_preview_script_not_ready');
|
|
46
|
+
const hashes = [['.playcraft/workspace.json', parameterDigest(manifestBytes)]];
|
|
47
|
+
for (const file of [...files, preview.entryPath, ...(preview.viteConfigPath ? [preview.viteConfigPath] : [])])
|
|
48
|
+
hashes.push([file, parameterDigest(await input(root, file))]);
|
|
49
|
+
const trusted = await readPreviewRuntimeDistribution();
|
|
50
|
+
const copiedManifest = await input(root, `${PREVIEW_RUNTIME_DIRECTORY}/manifest.json`);
|
|
51
|
+
if (!copiedManifest.equals(trusted.manifestBytes))
|
|
52
|
+
throw new ParameterAgentError('parameter_agent_preview_sdk_not_ready');
|
|
53
|
+
hashes.push([`${PREVIEW_RUNTIME_DIRECTORY}/manifest.json`, parameterDigest(copiedManifest)]);
|
|
54
|
+
for (const [name, expected] of Object.entries(trusted.manifest.files)) {
|
|
55
|
+
const file = `${PREVIEW_RUNTIME_DIRECTORY}/${name}`;
|
|
56
|
+
const bytes = await input(root, file);
|
|
57
|
+
const actual = parameterDigest(bytes);
|
|
58
|
+
if (actual !== expected)
|
|
59
|
+
throw new ParameterAgentError('parameter_agent_preview_sdk_not_ready');
|
|
60
|
+
hashes.push([file, actual]);
|
|
61
|
+
}
|
|
62
|
+
return parameterDigest(JSON.stringify(hashes));
|
|
63
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { lstat, readdir, realpath, unlink } from 'node:fs/promises';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import JSON5 from 'json5';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { workspaceRelativePathSchema } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
import { ParameterAgentError, parameterDigest, readParameterAgentFile } from './parameter-agent-inputs.js';
|
|
7
|
+
const reportPath = '.playcraft-editor-preview/release-report.json';
|
|
8
|
+
const marker = /runtime\.parameters\.|playcraft-preview-storage:|__playcraft_preview_context__/i;
|
|
9
|
+
const slash = (value) => value.replaceAll('\\', '/').replaceAll('\0', '').split('?')[0];
|
|
10
|
+
const relativePath = z.string().refine(value => workspaceRelativePathSchema.safeParse(value).success);
|
|
11
|
+
const reportSchema = z.object({
|
|
12
|
+
version: z.literal(1), target: z.literal('playable-release'), previewRuntimeExcluded: z.literal(true),
|
|
13
|
+
moduleIds: z.array(z.string().min(1).max(4096)).min(1).max(10000),
|
|
14
|
+
artifacts: z.array(z.object({ path: relativePath, sha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict()).min(1).max(4096),
|
|
15
|
+
}).strict();
|
|
16
|
+
async function safePath(root, path, missing = false) {
|
|
17
|
+
let current = root;
|
|
18
|
+
const segments = path.split('/');
|
|
19
|
+
for (let index = 0; index < segments.length; index++) {
|
|
20
|
+
current = resolve(current, segments[index]);
|
|
21
|
+
const stat = await lstat(current).catch(error => {
|
|
22
|
+
if (missing && error.code === 'ENOENT')
|
|
23
|
+
return undefined;
|
|
24
|
+
throw error;
|
|
25
|
+
});
|
|
26
|
+
if (!stat)
|
|
27
|
+
continue;
|
|
28
|
+
if (stat.isSymbolicLink() || (index < segments.length - 1 && !stat.isDirectory()))
|
|
29
|
+
throw new ParameterAgentError('parameter_agent_production_path_not_ready');
|
|
30
|
+
}
|
|
31
|
+
return current;
|
|
32
|
+
}
|
|
33
|
+
/** Remove only the old fixed guard report; never follow its parents into source or delivery files. */
|
|
34
|
+
export async function prepareParameterProductionCheck(root) {
|
|
35
|
+
try {
|
|
36
|
+
const base = await realpath(root);
|
|
37
|
+
const manifest = JSON5.parse((await readParameterAgentFile(base, '.playcraft/workspace.json')).toString('utf8'));
|
|
38
|
+
const entry = manifest.previewRuntime?.entryPath;
|
|
39
|
+
if (!workspaceRelativePathSchema.safeParse(entry).success)
|
|
40
|
+
throw new ParameterAgentError('parameter_agent_production_entry_not_ready');
|
|
41
|
+
const file = await safePath(base, reportPath, true);
|
|
42
|
+
const stat = await lstat(file).catch(error => {
|
|
43
|
+
if (error.code === 'ENOENT')
|
|
44
|
+
return undefined;
|
|
45
|
+
throw error;
|
|
46
|
+
});
|
|
47
|
+
if (stat && (!stat.isFile() || stat.nlink !== 1))
|
|
48
|
+
throw new ParameterAgentError('parameter_agent_production_path_not_ready');
|
|
49
|
+
if (stat)
|
|
50
|
+
await unlink(file);
|
|
51
|
+
return { root: base, startedAt: Date.now(), previewEntry: slash(resolve(base, entry)), requestedPreviewEntry: slash(resolve(root, entry)) };
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (error instanceof ParameterAgentError)
|
|
55
|
+
throw error;
|
|
56
|
+
throw new ParameterAgentError('parameter_agent_production_report_not_ready');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Inspect the reported module boundary and every actual delivery file, not a claimed success flag. */
|
|
60
|
+
export async function inspectParameterProductionCheck(plan, assertActive = () => { }) {
|
|
61
|
+
try {
|
|
62
|
+
assertActive();
|
|
63
|
+
const file = await safePath(plan.root, reportPath);
|
|
64
|
+
const stat = await lstat(file);
|
|
65
|
+
if (!stat.isFile() || stat.nlink !== 1 || stat.mtimeMs + 1 < plan.startedAt)
|
|
66
|
+
throw new ParameterAgentError('parameter_agent_production_report_not_ready');
|
|
67
|
+
const bytes = await readParameterAgentFile(plan.root, reportPath);
|
|
68
|
+
const parsed = reportSchema.safeParse(JSON.parse(bytes.toString('utf8')));
|
|
69
|
+
if (!parsed.success)
|
|
70
|
+
throw new ParameterAgentError('parameter_agent_production_report_not_ready');
|
|
71
|
+
const report = parsed.data;
|
|
72
|
+
const sdkRoot = slash(resolve(plan.root, '.playcraft/preview-runtime')) + '/';
|
|
73
|
+
for (const moduleId of report.moduleIds) {
|
|
74
|
+
const id = slash(moduleId);
|
|
75
|
+
if (id === plan.previewEntry || id === plan.requestedPreviewEntry || id.startsWith(sdkRoot) ||
|
|
76
|
+
/(?:^|\/)(?:@playcraft\/)?preview-runtime(?:\/|@|$)/.test(id) || /@playcraft\+preview-runtime@/.test(id))
|
|
77
|
+
throw new ParameterAgentError('parameter_agent_production_modules_not_ready');
|
|
78
|
+
}
|
|
79
|
+
const declared = new Map(report.artifacts.map(artifact => [artifact.path, artifact.sha256]));
|
|
80
|
+
if (declared.size !== report.artifacts.length || new Set(report.moduleIds).size !== report.moduleIds.length)
|
|
81
|
+
throw new ParameterAgentError('parameter_agent_production_report_not_ready');
|
|
82
|
+
const output = await safePath(plan.root, 'dist');
|
|
83
|
+
if (!(await lstat(output)).isDirectory())
|
|
84
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
85
|
+
let count = 0;
|
|
86
|
+
let total = 0;
|
|
87
|
+
let directories = 0;
|
|
88
|
+
const walk = async (directory, prefix = '', depth = 0) => {
|
|
89
|
+
assertActive();
|
|
90
|
+
if (depth > 24 || ++directories > 4096)
|
|
91
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
92
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
93
|
+
if (entries.length > 4096)
|
|
94
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
95
|
+
for (const entry of entries) {
|
|
96
|
+
assertActive();
|
|
97
|
+
const relative = prefix + entry.name;
|
|
98
|
+
if (!workspaceRelativePathSchema.safeParse(relative).success || entry.isSymbolicLink())
|
|
99
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
100
|
+
if (entry.isDirectory())
|
|
101
|
+
await walk(resolve(directory, entry.name), relative + '/', depth + 1);
|
|
102
|
+
else if (entry.isFile()) {
|
|
103
|
+
if (++count > 4096)
|
|
104
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
105
|
+
const content = await readParameterAgentFile(plan.root, `dist/${relative}`, 50 * 1024 * 1024);
|
|
106
|
+
total += content.byteLength;
|
|
107
|
+
if (total > 128 * 1024 * 1024 || declared.get(relative) !== parameterDigest(content) || marker.test(content.toString()))
|
|
108
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
109
|
+
}
|
|
110
|
+
else
|
|
111
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
await walk(output);
|
|
115
|
+
if (count !== declared.size || parameterDigest(await readParameterAgentFile(plan.root, reportPath)) !== parameterDigest(bytes))
|
|
116
|
+
throw new ParameterAgentError('parameter_agent_production_outputs_not_ready');
|
|
117
|
+
return { reportHash: parameterDigest(bytes), artifactCount: count, moduleCount: report.moduleIds.length };
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof ParameterAgentError || (error instanceof Error && error.name === 'AbortError'))
|
|
121
|
+
throw error;
|
|
122
|
+
throw new ParameterAgentError('parameter_agent_production_report_not_ready');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -2,9 +2,9 @@ import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { posix, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import JSON5 from 'json5';
|
|
5
|
-
import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, validateParameterChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
5
|
+
import { parameterDocumentSchema, parameterPatchPayloadSchema, parameterCatalog, parameterI18nCatalog, parameterRuntimeTranslations, PARAMETER_RUNTIME_LOCALES, validateParameterChange, validateParameterI18nChange, workspaceRelativePathSchema, WORKSPACE_FILE_CONTENT_MAX, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
6
|
import { InvalidWorkspacePathError, writeWorkspaceFile } from './files.js';
|
|
7
|
-
import { editJson5Paths, ParameterEditError } from './json5-edit.js';
|
|
7
|
+
import { editJson5Paths, editJson5StringRecords, ParameterEditError } from './json5-edit.js';
|
|
8
8
|
import { prepareParameterAssetRegistration, writeParameterAssetRegistration, } from './parameter-assets.js';
|
|
9
9
|
import { themeAssetIndexFingerprint } from './theme-asset-index.js';
|
|
10
10
|
const MANIFEST_PATH = '.playcraft/workspace.json';
|
|
@@ -32,6 +32,7 @@ async function sourceFingerprint(root, located, ownIndexWrite) {
|
|
|
32
32
|
readOptionalText(root, MANIFEST_PATH),
|
|
33
33
|
readOptionalText(root, THEME_INDEX_PATH),
|
|
34
34
|
selectedIndex ? readOptionalText(root, selectedIndex) : undefined,
|
|
35
|
+
located.schemaPath ? readOptionalText(root, runtimeI18nGlobalPath(located.schemaPath)) : undefined,
|
|
35
36
|
]);
|
|
36
37
|
const hashes = sources.map((text) => text === undefined ? null : createHash('sha256').update(text).digest('hex'));
|
|
37
38
|
const rawIndexHash = hashes[4];
|
|
@@ -106,6 +107,10 @@ async function readParameterDocuments(root, revisions, located) {
|
|
|
106
107
|
? { platformI18n: platformFile.value, platformI18nPath: i18nPath }
|
|
107
108
|
: {}),
|
|
108
109
|
};
|
|
110
|
+
const runtimeI18n = await readRuntimeI18n(root, revisions, located);
|
|
111
|
+
if (runtimeI18n.errors.length)
|
|
112
|
+
return parameterDocumentSchema.parse({ ...schemaDocument, status: 'invalid', errors: runtimeI18n.errors });
|
|
113
|
+
Object.assign(schemaDocument, runtimeI18n.document ? { runtimeI18n: runtimeI18n.document } : {});
|
|
109
114
|
if (platformFile.status === 'invalid') {
|
|
110
115
|
return parameterDocumentSchema.parse({
|
|
111
116
|
...schemaDocument,
|
|
@@ -148,6 +153,8 @@ async function readParameterDocuments(root, revisions, located) {
|
|
|
148
153
|
export async function patchWorkspaceParameters(root, revisions, payload, observed, options = {}) {
|
|
149
154
|
const parsed = parameterPatchPayloadSchema.parse(payload);
|
|
150
155
|
const document = await readWorkspaceParameters(root, revisions);
|
|
156
|
+
if ('documentId' in parsed)
|
|
157
|
+
return patchWorkspaceParameterI18n(root, document, parsed, observed);
|
|
151
158
|
if (document.status === 'invalid')
|
|
152
159
|
throw new ParameterEditError('parameter_document_invalid');
|
|
153
160
|
if (document.status === 'missing' || document.status === 'schema_only' || !document.dataPath) {
|
|
@@ -169,7 +176,7 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
|
|
|
169
176
|
if (observed && createHash('sha256').update(source).digest('hex') !== observed.contentHash) {
|
|
170
177
|
throw new ParameterEditError('revision_conflict');
|
|
171
178
|
}
|
|
172
|
-
const next = editJson5Paths(source, changes);
|
|
179
|
+
const next = formatParameterData(document.dataPath, source, editJson5Paths(source, changes));
|
|
173
180
|
JSON5.parse(next);
|
|
174
181
|
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX) {
|
|
175
182
|
throw new ParameterEditError('parameter_document_invalid');
|
|
@@ -209,6 +216,91 @@ export async function patchWorkspaceParameters(root, revisions, payload, observe
|
|
|
209
216
|
const written = await writeWorkspaceFile(root, document.dataPath, next);
|
|
210
217
|
return { ...written, ...(additionalWrites.length ? { additionalWrites } : {}) };
|
|
211
218
|
}
|
|
219
|
+
/** Strict data files must remain consumable by the project's original production importer. */
|
|
220
|
+
function formatParameterData(path, source, edited) {
|
|
221
|
+
if (!/\.json$/i.test(path))
|
|
222
|
+
return edited;
|
|
223
|
+
const indentation = source.match(/^[\t ]+(?=")/m)?.[0] ?? (/[\r\n]/.test(source.trim()) ? ' ' : undefined);
|
|
224
|
+
const next = JSON.stringify(JSON5.parse(edited), (_key, value) => {
|
|
225
|
+
// Unknown authored values must not silently change from non-finite numbers into null.
|
|
226
|
+
if (typeof value === 'number' && !Number.isFinite(value))
|
|
227
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
228
|
+
return value;
|
|
229
|
+
}, indentation);
|
|
230
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
231
|
+
return next.replaceAll('\n', eol) + (source.endsWith('\n') ? eol : '');
|
|
232
|
+
}
|
|
233
|
+
async function readRuntimeI18n(root, revisions, located) {
|
|
234
|
+
if (!located.schemaPath || !located.dataPath)
|
|
235
|
+
return { errors: [] };
|
|
236
|
+
const globalPath = runtimeI18nGlobalPath(located.schemaPath);
|
|
237
|
+
const themePath = runtimeI18nThemePath(located.dataPath);
|
|
238
|
+
const [globalFile, themeFile] = await Promise.all([readOptionalJson5(root, globalPath), readOptionalJson5(root, themePath)]);
|
|
239
|
+
const errors = [];
|
|
240
|
+
if (globalFile.status === 'invalid')
|
|
241
|
+
errors.push({ path: globalPath, reason: globalFile.reason });
|
|
242
|
+
if (themeFile.status === 'invalid')
|
|
243
|
+
errors.push({ path: themePath, reason: themeFile.reason });
|
|
244
|
+
if (errors.length || globalFile.status === 'missing' && themeFile.status === 'missing')
|
|
245
|
+
return { errors };
|
|
246
|
+
return { errors, document: {
|
|
247
|
+
globalPath, themePath,
|
|
248
|
+
global: globalFile.status === 'ok' ? globalFile.value : {},
|
|
249
|
+
theme: themeFile.status === 'ok' ? themeFile.value : {},
|
|
250
|
+
...(globalFile.status === 'ok' ? { globalHash: globalFile.contentHash } : {}),
|
|
251
|
+
...(themeFile.status === 'ok' ? { themeHash: themeFile.contentHash } : {}),
|
|
252
|
+
globalRevision: revisions[globalPath]?.revision ?? 0,
|
|
253
|
+
themeRevision: revisions[themePath]?.revision ?? 0,
|
|
254
|
+
locales: [...PARAMETER_RUNTIME_LOCALES],
|
|
255
|
+
} };
|
|
256
|
+
}
|
|
257
|
+
async function patchWorkspaceParameterI18n(root, document, payload, observed) {
|
|
258
|
+
if (document.status !== 'ready' || !document.runtimeI18n)
|
|
259
|
+
throw new ParameterEditError('parameter_i18n_global_invalid');
|
|
260
|
+
const runtime = document.runtimeI18n;
|
|
261
|
+
if (payload.sourceFingerprint !== document.sourceFingerprint || observed && observed.dataPath !== runtime.themePath)
|
|
262
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
263
|
+
for (const change of payload.changes) {
|
|
264
|
+
const error = validateParameterI18nChange(document, change);
|
|
265
|
+
if (error)
|
|
266
|
+
throw new ParameterEditError(error);
|
|
267
|
+
}
|
|
268
|
+
const source = await readOptionalText(root, runtime.themePath);
|
|
269
|
+
if (observed && (source === undefined ? '' : createHash('sha256').update(source).digest('hex')) !== observed.contentHash)
|
|
270
|
+
throw new ParameterEditError('revision_conflict');
|
|
271
|
+
const globals = runtime.global;
|
|
272
|
+
const themes = runtime.theme;
|
|
273
|
+
const nextEntries = new Map();
|
|
274
|
+
const changedLocales = new Set();
|
|
275
|
+
for (const change of payload.changes) {
|
|
276
|
+
const field = parameterI18nCatalog(document, change.locale).find(entry => entry.key === change.fieldPath.join('.'));
|
|
277
|
+
const key = field.runtimeI18nKey;
|
|
278
|
+
const identity = JSON.stringify([key, change.locale]);
|
|
279
|
+
if (changedLocales.has(identity))
|
|
280
|
+
throw new ParameterEditError('parameter_changes_overlap');
|
|
281
|
+
changedLocales.add(identity);
|
|
282
|
+
const defaults = parameterRuntimeTranslations(globals[key]);
|
|
283
|
+
const previous = parameterRuntimeTranslations(themes[key]) ?? defaults;
|
|
284
|
+
const entry = nextEntries.get(key) ?? { ...previous };
|
|
285
|
+
entry[change.locale] = change.op === 'remove' ? defaults[change.locale] : change.value;
|
|
286
|
+
nextEntries.set(key, entry);
|
|
287
|
+
}
|
|
288
|
+
const edits = [...nextEntries].map(([key, value]) => PARAMETER_RUNTIME_LOCALES.every(locale => value[locale] === globals[key][locale]) ? { key, op: 'remove' } : { key, value });
|
|
289
|
+
const next = editJson5StringRecords(source ?? '{}\n', edits);
|
|
290
|
+
if (Buffer.byteLength(next, 'utf8') > WORKSPACE_FILE_CONTENT_MAX)
|
|
291
|
+
throw new ParameterEditError('parameter_document_invalid');
|
|
292
|
+
if (await sourceFingerprint(root, await locateParameterFiles(root)) !== document.sourceFingerprint)
|
|
293
|
+
throw new ParameterEditError('parameter_source_conflict');
|
|
294
|
+
if (await readOptionalText(root, runtime.themePath) !== source)
|
|
295
|
+
throw new ParameterEditError('revision_conflict');
|
|
296
|
+
return writeWorkspaceFile(root, runtime.themePath, next);
|
|
297
|
+
}
|
|
298
|
+
function runtimeI18nGlobalPath(schemaPath) {
|
|
299
|
+
return posix.join(posix.dirname(schemaPath), 'i18n.runtime.json5');
|
|
300
|
+
}
|
|
301
|
+
function runtimeI18nThemePath(dataPath) {
|
|
302
|
+
return posix.join(posix.dirname(dataPath), 'theme.i18n.runtime.json5');
|
|
303
|
+
}
|
|
212
304
|
async function locateParameterFiles(root) {
|
|
213
305
|
const manifest = await readOptionalJson5(root, MANIFEST_PATH);
|
|
214
306
|
if (manifest.status === 'invalid') {
|