@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,235 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants, createReadStream, createWriteStream } from 'node:fs';
|
|
3
|
+
import { chmod, lstat, mkdir, mkdtemp, open, readlink, readdir, realpath, rm, symlink, utimes } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
|
+
import { Readable, Transform } from 'node:stream';
|
|
7
|
+
import { pipeline } from 'node:stream/promises';
|
|
8
|
+
import { createGunzip, createGzip } from 'node:zlib';
|
|
9
|
+
const MAGIC = Buffer.from('PLAYCRAFT-STORAGE-1\n');
|
|
10
|
+
export const STORAGE_SNAPSHOT_MAX_BYTES = 16 * 1024 * 1024 * 1024;
|
|
11
|
+
const MAX_ENTRIES = 250_000, MAX_MANIFEST_BYTES = 32 * 1024 * 1024;
|
|
12
|
+
const EXCLUDED = new Set(['node_modules', '.next', '.cache', '.turbo', '.vite', '.pnpm-store', '.playcraft.json']);
|
|
13
|
+
function inside(root, path) {
|
|
14
|
+
const part = relative(root, path);
|
|
15
|
+
return part === '' || (!isAbsolute(part) && part !== '..' && !part.startsWith(`..${sep}`));
|
|
16
|
+
}
|
|
17
|
+
function excluded(path) {
|
|
18
|
+
const parts = path.split('/');
|
|
19
|
+
return parts.some(name => EXCLUDED.has(name.toLowerCase()) || /^\.playcraft-credential-/.test(name));
|
|
20
|
+
}
|
|
21
|
+
function safeLink(root, parent, link) {
|
|
22
|
+
// Parent traversals may precede a target, but cannot follow a symlinkable path component.
|
|
23
|
+
// For example, `link/../outside` can escape even though its lexical path looks internal.
|
|
24
|
+
return Boolean(link) && !isAbsolute(link) && !/[\0\\]/.test(link) && /^(?:\.\.\/)*(?!.*(?:^|\/)\.\.(?:\/|$)).+$/.test(link) &&
|
|
25
|
+
inside(root, resolve(parent, link));
|
|
26
|
+
}
|
|
27
|
+
function same(before, after) {
|
|
28
|
+
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode && before.size === after.size &&
|
|
29
|
+
before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
|
|
30
|
+
}
|
|
31
|
+
async function scan(root, options) {
|
|
32
|
+
const entries = [], excludedRoots = await Promise.all((options.excludedRoots ?? []).map(path => realpath(path).catch(() => resolve(path))));
|
|
33
|
+
let bytes = 0;
|
|
34
|
+
async function walk(directory) {
|
|
35
|
+
options.signal?.throwIfAborted();
|
|
36
|
+
const before = await lstat(directory);
|
|
37
|
+
if (!before.isDirectory() || before.isSymbolicLink() || !inside(root, await realpath(directory)))
|
|
38
|
+
throw new Error('storage_snapshot_changed');
|
|
39
|
+
for (const name of (await readdir(directory)).sort()) {
|
|
40
|
+
const source = join(directory, name), path = relative(root, source).split(sep).join('/');
|
|
41
|
+
if (excluded(path) || excludedRoots.some(item => inside(item, source)))
|
|
42
|
+
continue;
|
|
43
|
+
if (Buffer.byteLength(path) > 65_535 || /[\0\\]/.test(path))
|
|
44
|
+
throw new Error('storage_snapshot_invalid_path');
|
|
45
|
+
const info = await lstat(source);
|
|
46
|
+
if (/(?:^|\/)\.git\/objects\/info\/alternates$/.test(path))
|
|
47
|
+
throw new Error('storage_snapshot_unsupported');
|
|
48
|
+
const type = info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : info.isFile() ? 'file' : undefined;
|
|
49
|
+
if (!type || entries.length >= MAX_ENTRIES)
|
|
50
|
+
throw new Error('storage_snapshot_unsupported');
|
|
51
|
+
const entry = { path, source, info, type, mode: info.mode & 0o777, mtime: info.mtimeMs, size: type === 'file' ? info.size : 0 };
|
|
52
|
+
if (type === 'symlink') {
|
|
53
|
+
entry.link = await readlink(source);
|
|
54
|
+
if (!safeLink(root, dirname(source), entry.link))
|
|
55
|
+
throw new Error('storage_snapshot_unsafe_link');
|
|
56
|
+
}
|
|
57
|
+
else if (type === 'file' && /(?:^|\/)\.git$/.test(path)) {
|
|
58
|
+
if (info.size > 4096)
|
|
59
|
+
throw new Error('storage_snapshot_unsupported');
|
|
60
|
+
const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
61
|
+
try {
|
|
62
|
+
const pointer = /^gitdir: ([^\r\n]+)\n?$/.exec(await handle.readFile('utf8'));
|
|
63
|
+
if (!pointer || !inside(root, resolve(directory, pointer[1])))
|
|
64
|
+
throw new Error('storage_snapshot_unsupported');
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await handle.close();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
bytes += entry.size;
|
|
71
|
+
if (bytes > STORAGE_SNAPSHOT_MAX_BYTES)
|
|
72
|
+
throw new Error('storage_snapshot_too_large');
|
|
73
|
+
entries.push(entry);
|
|
74
|
+
if (type === 'directory')
|
|
75
|
+
await walk(source);
|
|
76
|
+
}
|
|
77
|
+
if (!same(before, await lstat(directory)))
|
|
78
|
+
throw new Error('storage_snapshot_changed');
|
|
79
|
+
}
|
|
80
|
+
await walk(root);
|
|
81
|
+
return entries;
|
|
82
|
+
}
|
|
83
|
+
export async function createStorageSnapshot(root, options = {}) {
|
|
84
|
+
const temporary = await mkdtemp(join(tmpdir(), 'playcraft-storage-'));
|
|
85
|
+
const cleanup = () => rm(temporary, { recursive: true, force: true });
|
|
86
|
+
try {
|
|
87
|
+
const canonical = await realpath(root), rootInfo = await lstat(canonical), entries = await scan(canonical, options);
|
|
88
|
+
const manifest = Buffer.from(JSON.stringify(entries.map(({ path, type, mode, mtime, size, link }) => ({ path, type, mode, mtime, size, ...(link === undefined ? {} : { link }) }))));
|
|
89
|
+
if (manifest.length > MAX_MANIFEST_BYTES)
|
|
90
|
+
throw new Error('storage_snapshot_too_large');
|
|
91
|
+
const length = Buffer.alloc(4);
|
|
92
|
+
length.writeUInt32BE(manifest.length);
|
|
93
|
+
async function* contents() {
|
|
94
|
+
yield MAGIC;
|
|
95
|
+
yield length;
|
|
96
|
+
yield manifest;
|
|
97
|
+
for (const entry of entries) {
|
|
98
|
+
options.signal?.throwIfAborted();
|
|
99
|
+
if (!same(entry.info, await lstat(entry.source)))
|
|
100
|
+
throw new Error('storage_snapshot_changed');
|
|
101
|
+
if (entry.type !== 'file')
|
|
102
|
+
continue;
|
|
103
|
+
const file = await open(entry.source, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
104
|
+
try {
|
|
105
|
+
if (!same(entry.info, await file.stat()))
|
|
106
|
+
throw new Error('storage_snapshot_changed');
|
|
107
|
+
let bytes = 0;
|
|
108
|
+
for await (const chunk of file.createReadStream({ autoClose: false })) {
|
|
109
|
+
bytes += chunk.length;
|
|
110
|
+
if (bytes > entry.size)
|
|
111
|
+
throw new Error('storage_snapshot_changed');
|
|
112
|
+
yield chunk;
|
|
113
|
+
}
|
|
114
|
+
if (bytes !== entry.size)
|
|
115
|
+
throw new Error('storage_snapshot_changed');
|
|
116
|
+
if (!same(entry.info, await file.stat()))
|
|
117
|
+
throw new Error('storage_snapshot_changed');
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
await file.close();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const entry of entries)
|
|
124
|
+
if (!same(entry.info, await lstat(entry.source)))
|
|
125
|
+
throw new Error('storage_snapshot_changed');
|
|
126
|
+
if (!same(rootInfo, await lstat(canonical)))
|
|
127
|
+
throw new Error('storage_snapshot_changed');
|
|
128
|
+
}
|
|
129
|
+
const path = join(temporary, 'snapshot.gz'), hash = createHash('sha256');
|
|
130
|
+
let bytes = 0;
|
|
131
|
+
await pipeline(Readable.from(contents()), createGzip(), new Transform({ transform(chunk, _encoding, done) { bytes += chunk.length; hash.update(chunk); done(bytes > STORAGE_SNAPSHOT_MAX_BYTES ? new Error('storage_snapshot_too_large') : null, chunk); } }),
|
|
132
|
+
// private
|
|
133
|
+
createWriteStream(path, { flags: 'wx', mode: 0o600 }), { signal: options.signal });
|
|
134
|
+
return { path, bytes, sha256: hash.digest('hex'), cleanup };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
await cleanup();
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/** Extract only a validated manifest into a newly-created private directory; create links last. */
|
|
142
|
+
export async function extractStorageSnapshot(path, target, options = {}) {
|
|
143
|
+
const input = createReadStream(path), stream = input.pipe(createGunzip());
|
|
144
|
+
input.on('error', error => stream.destroy(error));
|
|
145
|
+
const iterator = stream[Symbol.asyncIterator]();
|
|
146
|
+
let chunk = Buffer.alloc(0), offset = 0, total = 0;
|
|
147
|
+
const read = async (length) => {
|
|
148
|
+
const result = Buffer.allocUnsafe(length);
|
|
149
|
+
let written = 0;
|
|
150
|
+
while (written < length) {
|
|
151
|
+
options.signal?.throwIfAborted();
|
|
152
|
+
if (offset === chunk.length) {
|
|
153
|
+
const next = await iterator.next();
|
|
154
|
+
if (next.done)
|
|
155
|
+
throw new Error('storage_snapshot_invalid');
|
|
156
|
+
chunk = next.value;
|
|
157
|
+
offset = 0;
|
|
158
|
+
total += chunk.length;
|
|
159
|
+
if (total > STORAGE_SNAPSHOT_MAX_BYTES + MAX_MANIFEST_BYTES)
|
|
160
|
+
throw new Error('storage_snapshot_too_large');
|
|
161
|
+
}
|
|
162
|
+
const count = Math.min(length - written, chunk.length - offset);
|
|
163
|
+
chunk.copy(result, written, offset, offset + count);
|
|
164
|
+
written += count;
|
|
165
|
+
offset += count;
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
};
|
|
169
|
+
try {
|
|
170
|
+
if (!(await read(MAGIC.length)).equals(MAGIC))
|
|
171
|
+
throw new Error('storage_snapshot_invalid');
|
|
172
|
+
const size = (await read(4)).readUInt32BE();
|
|
173
|
+
if (size > MAX_MANIFEST_BYTES)
|
|
174
|
+
throw new Error('storage_snapshot_too_large');
|
|
175
|
+
const entries = JSON.parse((await read(size)).toString('utf8'));
|
|
176
|
+
if (!Array.isArray(entries) || entries.length > MAX_ENTRIES)
|
|
177
|
+
throw new Error('storage_snapshot_invalid');
|
|
178
|
+
const known = new Map();
|
|
179
|
+
let bytes = 0;
|
|
180
|
+
for (const raw of entries) {
|
|
181
|
+
const entry = raw;
|
|
182
|
+
if (!entry || typeof entry.path !== 'string' || !entry.path || Buffer.byteLength(entry.path) > 65_535 || /[\0\\]/.test(entry.path) || entry.path.split('/').some(part => !part || part === '.' || part === '..') || isAbsolute(entry.path) || known.has(entry.path) ||
|
|
183
|
+
!['file', 'directory', 'symlink'].includes(entry.type) || !Number.isSafeInteger(entry.mode) || entry.mode < 0 || entry.mode > 0o777 || !Number.isFinite(entry.mtime) ||
|
|
184
|
+
!Number.isSafeInteger(entry.size) || entry.size < 0 || (entry.type !== 'file' && entry.size !== 0))
|
|
185
|
+
throw new Error('storage_snapshot_invalid');
|
|
186
|
+
const parent = dirname(entry.path);
|
|
187
|
+
if (parent !== '.' && known.get(parent)?.type !== 'directory')
|
|
188
|
+
throw new Error('storage_snapshot_invalid');
|
|
189
|
+
if (entry.type === 'symlink' && (typeof entry.link !== 'string' || !safeLink(target, join(target, parent), entry.link)))
|
|
190
|
+
throw new Error('storage_snapshot_unsafe_link');
|
|
191
|
+
bytes += entry.size;
|
|
192
|
+
if (bytes > STORAGE_SNAPSHOT_MAX_BYTES)
|
|
193
|
+
throw new Error('storage_snapshot_too_large');
|
|
194
|
+
known.set(entry.path, entry);
|
|
195
|
+
}
|
|
196
|
+
for (const entry of known.values()) {
|
|
197
|
+
const destination = join(target, entry.path);
|
|
198
|
+
// private
|
|
199
|
+
if (entry.type === 'directory')
|
|
200
|
+
await mkdir(destination, { mode: 0o700 });
|
|
201
|
+
if (entry.type !== 'file')
|
|
202
|
+
continue;
|
|
203
|
+
// private
|
|
204
|
+
const handle = await open(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
205
|
+
try {
|
|
206
|
+
let remaining = entry.size;
|
|
207
|
+
while (remaining > 0) {
|
|
208
|
+
const value = await read(Math.min(64 * 1024, remaining));
|
|
209
|
+
await handle.writeFile(value);
|
|
210
|
+
remaining -= value.length;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
finally {
|
|
214
|
+
await handle.close();
|
|
215
|
+
}
|
|
216
|
+
await chmod(destination, entry.mode);
|
|
217
|
+
await utimes(destination, entry.mtime / 1000, entry.mtime / 1000);
|
|
218
|
+
}
|
|
219
|
+
if (offset !== chunk.length || !(await iterator.next()).done)
|
|
220
|
+
throw new Error('storage_snapshot_invalid');
|
|
221
|
+
for (const entry of known.values())
|
|
222
|
+
if (entry.type === 'symlink')
|
|
223
|
+
await symlink(entry.link, join(target, entry.path));
|
|
224
|
+
for (const entry of [...known.values()].reverse())
|
|
225
|
+
if (entry.type === 'directory') {
|
|
226
|
+
await chmod(join(target, entry.path), entry.mode);
|
|
227
|
+
await utimes(join(target, entry.path), entry.mtime / 1000, entry.mtime / 1000);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
input.destroy();
|
|
232
|
+
stream.destroy();
|
|
233
|
+
await iterator.return?.();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -138,7 +138,9 @@ async function copySnapshot(source, target, signal, hashOnly = false) {
|
|
|
138
138
|
size += chunk.length;
|
|
139
139
|
callback(size > before.size ? new WorkspaceTransferError('workspace_transfer_conflict') : null, chunk);
|
|
140
140
|
} });
|
|
141
|
-
await pipeline(handle.createReadStream({ autoClose: false }), guard,
|
|
141
|
+
await pipeline(handle.createReadStream({ autoClose: false }), guard,
|
|
142
|
+
// private
|
|
143
|
+
createWriteStream(to, { flags: 'wx', mode: 0o600 }), { signal });
|
|
142
144
|
}
|
|
143
145
|
else {
|
|
144
146
|
for await (const chunk of handle.createReadStream({ autoClose: false })) {
|
|
@@ -366,6 +368,7 @@ export async function applyWorkspaceTransfer(root, bundle, expectedTree, options
|
|
|
366
368
|
try {
|
|
367
369
|
const bundlePath = typeof bundle === 'string' ? resolve(bundle) : join(temporary, 'incoming.bundle');
|
|
368
370
|
const incoming = join(temporary, 'checkout');
|
|
371
|
+
// private
|
|
369
372
|
if (typeof bundle !== 'string')
|
|
370
373
|
await writeFile(bundlePath, bundle, { mode: 0o600 });
|
|
371
374
|
const bundleInfo = await stat(bundlePath);
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative as relativePath } from 'node:path';
|
|
3
|
+
import { runDeveloperCommand } from './developer-command.js';
|
|
4
|
+
import { SHARED_DIRECTORY_MODE, SHARED_FILE_MODE, sharePathWithGroup, workspaceGroup, } from './shared-write.js';
|
|
5
|
+
/**
|
|
6
|
+
* Subtrees both sandbox identities read and write. Repair never walks the whole checkout: source
|
|
7
|
+
* files belong to Git and are opened once during preparation; these are the trees a session keeps
|
|
8
|
+
* writing after that point.
|
|
9
|
+
*/
|
|
10
|
+
export const WORKSPACE_SHARED_PATHS = [
|
|
11
|
+
'.playcraft/artifact-receipts',
|
|
12
|
+
'.playcraft/skills',
|
|
13
|
+
'.playcraft/skill-artifacts',
|
|
14
|
+
'.playcraft-temp',
|
|
15
|
+
'dist',
|
|
16
|
+
];
|
|
17
|
+
/** Only the root is shared here; the transaction directories below it stay private. */
|
|
18
|
+
const SHARED_ROOT_ONLY = new Set(['.playcraft-temp']);
|
|
19
|
+
const PERMISSION_CODES = new Set(['EACCES', 'EPERM']);
|
|
20
|
+
export function isWorkspacePermissionError(error) {
|
|
21
|
+
const code = error?.code;
|
|
22
|
+
return typeof code === 'string' && PERMISSION_CODES.has(code);
|
|
23
|
+
}
|
|
24
|
+
/** Recognises a filesystem EACCES/EPERM, unwrapping the `cause` some guards attach. */
|
|
25
|
+
export function permissionDriftFromError(error) {
|
|
26
|
+
const seen = error instanceof Error && error.cause ? error.cause : error;
|
|
27
|
+
const node = seen;
|
|
28
|
+
if (!isWorkspacePermissionError(node) || typeof node.path !== 'string' || !node.path)
|
|
29
|
+
return undefined;
|
|
30
|
+
const search = ['scandir', 'readdir', 'stat', 'access', 'opendir'].includes(String(node.syscall));
|
|
31
|
+
return { path: node.path, missing: search ? 'group_search' : 'group_write' };
|
|
32
|
+
}
|
|
33
|
+
/** Recognises the EACCES/EPERM line a failed command or scan leaves in its log. */
|
|
34
|
+
export function permissionDriftFromOutput(output) {
|
|
35
|
+
const match = /(?:EACCES|EPERM): permission denied, (stat|scandir|open|access|readdir|mkdir|unlink|rename) '([^']+)'/
|
|
36
|
+
.exec(output);
|
|
37
|
+
if (!match)
|
|
38
|
+
return undefined;
|
|
39
|
+
const operation = match[1];
|
|
40
|
+
return {
|
|
41
|
+
path: match[2],
|
|
42
|
+
missing: operation === 'scandir' || operation === 'readdir' || operation === 'stat' || operation === 'access'
|
|
43
|
+
? 'group_search'
|
|
44
|
+
: 'group_write',
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function isMissing(error) {
|
|
48
|
+
return error?.code === 'ENOENT';
|
|
49
|
+
}
|
|
50
|
+
async function shapeOwnedTree(path, groupId, recursive = true) {
|
|
51
|
+
const stat = await lstat(path);
|
|
52
|
+
if (stat.isSymbolicLink())
|
|
53
|
+
return { repaired: 0, foreign: false };
|
|
54
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
55
|
+
// The other identity owns this inode; its own pass repairs the subtree.
|
|
56
|
+
if (uid !== undefined && uid !== 0 && stat.uid !== uid)
|
|
57
|
+
return { repaired: 0, foreign: true };
|
|
58
|
+
const changed = await sharePathWithGroup(path, groupId, stat.isDirectory() ? SHARED_DIRECTORY_MODE : SHARED_FILE_MODE, stat.isFile());
|
|
59
|
+
let repaired = changed ? 1 : 0;
|
|
60
|
+
let foreign = false;
|
|
61
|
+
if (!stat.isDirectory() || !recursive)
|
|
62
|
+
return { repaired, foreign };
|
|
63
|
+
const entries = await readdir(path).catch(error => {
|
|
64
|
+
// An unreadable directory can only be opened by its owner.
|
|
65
|
+
if (isWorkspacePermissionError(error)) {
|
|
66
|
+
foreign = true;
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
});
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
const result = await shapeOwnedTree(join(path, entry), groupId);
|
|
73
|
+
repaired += result.repaired;
|
|
74
|
+
foreign ||= result.foreign;
|
|
75
|
+
}
|
|
76
|
+
return { repaired, foreign };
|
|
77
|
+
}
|
|
78
|
+
function missingShare(stat, groupId) {
|
|
79
|
+
if (stat.gid !== groupId)
|
|
80
|
+
return 'group_write';
|
|
81
|
+
const mode = Number(stat.mode);
|
|
82
|
+
if (stat.isDirectory()) {
|
|
83
|
+
if ((mode & 0o010) === 0)
|
|
84
|
+
return 'group_search';
|
|
85
|
+
if ((mode & 0o060) !== 0o060 || (mode & 0o2000) === 0)
|
|
86
|
+
return 'group_write';
|
|
87
|
+
}
|
|
88
|
+
else if ((mode & 0o020) === 0)
|
|
89
|
+
return 'group_write';
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Lists entries of the shared subtrees that lost development-group access. Read-only: the owner of
|
|
94
|
+
* each entry (or `repairWorkspaceShares` at registration) is what changes them.
|
|
95
|
+
*/
|
|
96
|
+
export async function diagnoseWorkspaceShares(root, scope = 'workspace') {
|
|
97
|
+
const groupId = await workspaceGroup(root);
|
|
98
|
+
const targets = scope === 'workspace'
|
|
99
|
+
? WORKSPACE_SHARED_PATHS.map((path) => join(root, path))
|
|
100
|
+
: [scope];
|
|
101
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : undefined;
|
|
102
|
+
const drifts = [];
|
|
103
|
+
const visit = async (path, recursive) => {
|
|
104
|
+
const stat = await lstat(path);
|
|
105
|
+
if (stat.isSymbolicLink())
|
|
106
|
+
return;
|
|
107
|
+
const missing = missingShare(stat, groupId);
|
|
108
|
+
if (missing)
|
|
109
|
+
drifts.push({ path, missing, owner: uid !== undefined && stat.uid === uid ? 'self' : 'other' });
|
|
110
|
+
if (!stat.isDirectory() || !recursive)
|
|
111
|
+
return;
|
|
112
|
+
const entries = await readdir(path).catch(error => {
|
|
113
|
+
if (isWorkspacePermissionError(error))
|
|
114
|
+
return [];
|
|
115
|
+
throw error;
|
|
116
|
+
});
|
|
117
|
+
for (const entry of entries)
|
|
118
|
+
await visit(join(path, entry), true);
|
|
119
|
+
};
|
|
120
|
+
for (const target of targets) {
|
|
121
|
+
const stat = await lstat(target).catch(error => { if (isMissing(error))
|
|
122
|
+
return undefined; throw error; });
|
|
123
|
+
if (!stat)
|
|
124
|
+
continue;
|
|
125
|
+
await visit(target, !SHARED_ROOT_ONLY.has(relativePath(root, target)));
|
|
126
|
+
}
|
|
127
|
+
return drifts;
|
|
128
|
+
}
|
|
129
|
+
function shellQuote(value) {
|
|
130
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The launcher runs commands as the development identity. Entries that identity owns cannot be
|
|
134
|
+
* re-shaped from the controller, so the same invariants are applied from the owning side.
|
|
135
|
+
*/
|
|
136
|
+
async function repairAsOwner(state, root, targets, groupId) {
|
|
137
|
+
if (!targets.length)
|
|
138
|
+
return 0;
|
|
139
|
+
const command = [
|
|
140
|
+
'set -u',
|
|
141
|
+
'count=0',
|
|
142
|
+
...targets.map((target) => {
|
|
143
|
+
const quoted = shellQuote(target);
|
|
144
|
+
return [
|
|
145
|
+
`if test -e ${quoted}; then`,
|
|
146
|
+
` n=$(find ${quoted} -uid "$(id -u)" \\( -type d ! -perm -2770 -o -type f ! -perm -g+w \\) -print 2>/dev/null | wc -l)`,
|
|
147
|
+
' count=$((count+n))',
|
|
148
|
+
` find ${quoted} -uid "$(id -u)" -type d ! -perm -2770 -exec chmod 2770 {} + 2>/dev/null || true`,
|
|
149
|
+
` find ${quoted} -uid "$(id -u)" -type f ! -perm -g+w -exec chmod g+rw {} + 2>/dev/null || true`,
|
|
150
|
+
` find ${quoted} -uid "$(id -u)" ! -group ${groupId} -exec chgrp ${groupId} {} + 2>/dev/null || true`,
|
|
151
|
+
'fi',
|
|
152
|
+
].join('\n');
|
|
153
|
+
}),
|
|
154
|
+
'printf "repaired=%s\\n" "$count"',
|
|
155
|
+
].join('\n');
|
|
156
|
+
let repaired = 0;
|
|
157
|
+
await runDeveloperCommand(state, {
|
|
158
|
+
processId: `share-repair-${Date.now().toString(36)}`,
|
|
159
|
+
// The target itself may be unreadable to the controller; the workspace root is not.
|
|
160
|
+
cwd: root,
|
|
161
|
+
command,
|
|
162
|
+
timeoutMs: 30_000,
|
|
163
|
+
onOutput: (chunk) => {
|
|
164
|
+
const found = /repaired=(\d+)/.exec(String(chunk));
|
|
165
|
+
if (found)
|
|
166
|
+
repaired = Math.max(repaired, Number(found[1]));
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
return repaired;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Re-applies the shared mode to the subtrees both identities use. Idempotent: only entries missing
|
|
173
|
+
* group access are touched. `scope` names one path when a failure already identified the drift.
|
|
174
|
+
*/
|
|
175
|
+
export async function repairWorkspaceShares(state, root, scope = 'workspace') {
|
|
176
|
+
const groupId = await workspaceGroup(root);
|
|
177
|
+
const targets = scope === 'workspace'
|
|
178
|
+
? WORKSPACE_SHARED_PATHS.map((path) => ({
|
|
179
|
+
path: join(root, path),
|
|
180
|
+
recursive: !SHARED_ROOT_ONLY.has(path),
|
|
181
|
+
}))
|
|
182
|
+
: [{ path: scope, recursive: true }];
|
|
183
|
+
const existing = [];
|
|
184
|
+
let repaired = 0;
|
|
185
|
+
const foreign = [];
|
|
186
|
+
for (const target of targets) {
|
|
187
|
+
const stat = await lstat(target.path).catch(error => { if (isMissing(error))
|
|
188
|
+
return undefined; throw error; });
|
|
189
|
+
if (!stat || !stat.isDirectory() || stat.isSymbolicLink())
|
|
190
|
+
continue;
|
|
191
|
+
existing.push(target.path);
|
|
192
|
+
const result = await shapeOwnedTree(target.path, groupId, target.recursive);
|
|
193
|
+
repaired += result.repaired;
|
|
194
|
+
if (result.foreign)
|
|
195
|
+
foreign.push(target.path);
|
|
196
|
+
}
|
|
197
|
+
if (foreign.length)
|
|
198
|
+
repaired += await repairAsOwner(state, root, foreign, groupId);
|
|
199
|
+
const report = { scope, repaired, paths: existing, delegated: foreign.length > 0 };
|
|
200
|
+
if (repaired > 0 || foreign.length > 0) {
|
|
201
|
+
console.log(JSON.stringify({
|
|
202
|
+
event: 'workspace_share_repaired',
|
|
203
|
+
scope,
|
|
204
|
+
root,
|
|
205
|
+
repaired,
|
|
206
|
+
paths: existing.map((path) => (scope === 'workspace' ? path.slice(root.length + 1) : path)),
|
|
207
|
+
delegated: report.delegated,
|
|
208
|
+
}));
|
|
209
|
+
}
|
|
210
|
+
return report;
|
|
211
|
+
}
|
|
212
|
+
/** Repairs once and retries a read/build that failed only because of a shared-permission drift. */
|
|
213
|
+
export async function withShareRepair(state, root, action, drift) {
|
|
214
|
+
try {
|
|
215
|
+
return await action();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (!isWorkspacePermissionError(error))
|
|
219
|
+
throw error;
|
|
220
|
+
const path = drift?.path ?? permissionDriftFromError(error)?.path;
|
|
221
|
+
await repairWorkspaceShares(state, root, await driftScope(root, path));
|
|
222
|
+
return action();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/** The shared subtree (or explicit path) that contains a drifting path. */
|
|
226
|
+
export async function driftScope(root, path) {
|
|
227
|
+
if (!path)
|
|
228
|
+
return 'workspace';
|
|
229
|
+
for (const relative of WORKSPACE_SHARED_PATHS) {
|
|
230
|
+
const candidate = join(root, relative);
|
|
231
|
+
if (path === candidate || path.startsWith(`${candidate}/`))
|
|
232
|
+
return candidate;
|
|
233
|
+
}
|
|
234
|
+
return dirname(path);
|
|
235
|
+
}
|
|
@@ -11,6 +11,7 @@ async function safeParents(root, path, create = false) {
|
|
|
11
11
|
if (!segment || segment === '.' || segment === '..')
|
|
12
12
|
throw new Error('workspace_worktree_attachment_unavailable');
|
|
13
13
|
current = join(current, segment);
|
|
14
|
+
// shared
|
|
14
15
|
if (create)
|
|
15
16
|
await mkdir(current, { mode: 0o755 }).catch(error => { if (error.code !== 'EEXIST')
|
|
16
17
|
throw error; });
|
|
@@ -43,6 +44,7 @@ async function copyExact(source, target, path, limit) {
|
|
|
43
44
|
return;
|
|
44
45
|
}
|
|
45
46
|
const temporary = `${join(target, path)}.${randomUUID()}.tmp`;
|
|
47
|
+
// shared
|
|
46
48
|
try {
|
|
47
49
|
await writeFile(temporary, body, { flag: 'wx', mode: 0o644 });
|
|
48
50
|
await rename(temporary, join(target, path));
|
|
@@ -1,22 +1,27 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
|
-
import { lstat,
|
|
4
|
+
import { lstat, open, realpath } from 'node:fs/promises';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
6
|
import { promisify } from 'node:util';
|
|
7
7
|
import { validateProjectSkillsSnapshot } from '@playcraft/common/project-skills';
|
|
8
8
|
import { readWorkspaceSkills } from './skills.js';
|
|
9
|
+
import { ensureSharedDirectory, writeSharedFile } from './shared-write.js';
|
|
9
10
|
const execute = promisify(execFile);
|
|
10
11
|
async function archiveDirectory(root) {
|
|
11
12
|
let directory = root;
|
|
12
13
|
for (const segment of ['.playcraft', 'skill-artifacts']) {
|
|
13
14
|
directory = join(directory, segment);
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// Reject a redirected archive directory before creating or sharing anything below it.
|
|
16
|
+
const existing = await lstat(directory).catch((error) => {
|
|
17
|
+
if (error.code === 'ENOENT')
|
|
18
|
+
return undefined;
|
|
19
|
+
throw error;
|
|
20
|
+
});
|
|
21
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink()))
|
|
18
22
|
throw new Error('workspace_worktree_skill_archive_unavailable');
|
|
19
23
|
}
|
|
24
|
+
await ensureSharedDirectory(root, join(root, '.playcraft/skill-artifacts'));
|
|
20
25
|
}
|
|
21
26
|
/** Clone the saved commit and its exact skill archives, without copying unsaved files or credentials. */
|
|
22
27
|
export async function checkoutChatWorktree(source, destination, commit, stateDir) {
|
|
@@ -60,7 +65,7 @@ export async function checkoutChatWorktree(source, destination, commit, stateDir
|
|
|
60
65
|
if (createHash('sha256').update(archive).digest('hex') !== entry.revision.artifactHash)
|
|
61
66
|
throw new Error('workspace_worktree_skill_archive_unavailable');
|
|
62
67
|
await archiveDirectory(destination);
|
|
63
|
-
await
|
|
68
|
+
await writeSharedFile(destination, saved, archive);
|
|
64
69
|
}
|
|
65
70
|
// Existing locked repository bytes and the exact runtime cache can fill missing archives.
|
|
66
71
|
await readWorkspaceSkills(destination, join(stateDir, 'skill-artifacts'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@playcraft/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.59-beta",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
],
|
|
24
24
|
"scripts": {
|
|
25
25
|
"dev": "tsc -w",
|
|
26
|
-
"build": "node scripts/generate-asset-skills.mjs && node scripts/generate-video-skills.mjs && node scripts/generate-preview-runtime.mjs && tsc",
|
|
26
|
+
"build": "node scripts/generate-asset-skills.mjs && node scripts/generate-video-skills.mjs && node scripts/generate-parameter-skill.mjs && node scripts/generate-preview-runtime.mjs && tsc",
|
|
27
27
|
"start": "node dist/index.js",
|
|
28
28
|
"test": "pnpm build && vitest run",
|
|
29
29
|
"test:agent": "node scripts/run-agent-tests.mjs --list",
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"verify:platform-skills": "node scripts/generate-platform-skills.mjs --check",
|
|
42
42
|
"generate:video-skills": "node scripts/generate-video-skills.mjs",
|
|
43
43
|
"verify:video-skills": "node scripts/generate-video-skills.mjs --check && node --test scripts/generate-video-skills.test.mjs",
|
|
44
|
+
"verify:parameter-skill": "node scripts/generate-parameter-skill.mjs --check && node --test scripts/generate-parameter-skill.test.mjs",
|
|
44
45
|
"verify:asset-skills": "node scripts/generate-asset-skills.mjs --check && node --test scripts/generate-asset-skills.test.mjs",
|
|
45
46
|
"verify:agent-stop": "node scripts/verify-agent-stop.mjs",
|
|
46
47
|
"verify-sprite-overlay": "node scripts/verify-sprite-overlay.mjs",
|
|
@@ -52,8 +53,8 @@
|
|
|
52
53
|
"@gltf-transform/core": "^4.3.0",
|
|
53
54
|
"@gltf-transform/extensions": "^4.3.0",
|
|
54
55
|
"@gltf-transform/functions": "^4.3.0",
|
|
55
|
-
"@playcraft/build": "0.1.
|
|
56
|
-
"@playcraft/common": "0.1.
|
|
56
|
+
"@playcraft/build": "0.1.59-beta",
|
|
57
|
+
"@playcraft/common": "0.1.59-beta",
|
|
57
58
|
"better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
|
|
58
59
|
"chokidar": "^4.0.3",
|
|
59
60
|
"commander": "^13.1.0",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: playcraft-runtime-parameters
|
|
3
|
+
description: Initialize or extend configurable parameters in a Cloud Workspace game by extracting real controls, connecting production consumers and a separate development preview, and generating the platform parameter panel from the project declarations.
|
|
4
|
+
compatibility: agent,opencode
|
|
5
|
+
version: 1.0.0
|
|
6
|
+
surfaces:
|
|
7
|
+
- sandbox-agent
|
|
8
|
+
metadata:
|
|
9
|
+
protocol: platform-protocol
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Initialize game parameters
|
|
13
|
+
|
|
14
|
+
Turn the current game into a configurable engineering project. The platform renders its panel from the actual declaration; do not develop a game-specific platform panel. Work in the authorized engineering directory using the current execution mode and permissions.
|
|
15
|
+
|
|
16
|
+
Read [the parameter protocol](references/platform-protocol.md) before changing configuration, then [development preview integration](references/preview-integration.md) before connecting the game. The runtime supplies the task identity, report path and report structure separately; use those exact values.
|
|
17
|
+
|
|
18
|
+
## Extract and connect
|
|
19
|
+
|
|
20
|
+
Inspect the production entry, engine, configuration and real consumers. Preserve existing paths, settings, scripts and gameplay. Reuse existing equivalent parameters before extracting constants. Select a bounded first group of useful controls whose effects can be tested; a game with different behavior should produce different controls. Do not copy a prior game's catalog or expose every constant.
|
|
21
|
+
|
|
22
|
+
Create or extend the schema, sparse theme overrides and platform labels. Connect a neutral resolver shared by production and preview to real consumers. A field declaration or unused getter alone is incomplete. Keep current visuals and behavior as defaults. Preserve user overrides, translated text and selected resources on repeated initialization.
|
|
23
|
+
|
|
24
|
+
For each consumed field, select an honest application mode: `live` for an in-place change, `restart-scene` for a change requiring a new game or scene, or `reload` when a new build is necessary. Register only real consumers. At least one meaningful live consumer should be connected when the game supports it; do not mark every field live to make initialization appear successful. Reuse the existing game's start/reset/dispose lifecycle for a restart.
|
|
25
|
+
|
|
26
|
+
## Validate the generated project
|
|
27
|
+
|
|
28
|
+
Add or extend `test:parameters` with executable checks for defaults, sparse overrides and changed real-consumer behavior. Keep or restore a successful production `build`; retain an existing `typecheck`. Use the separate preview entry and production release boundary described in the integration reference.
|
|
29
|
+
|
|
30
|
+
The platform runs its own checks, builds the first development preview and derives capabilities from the real handshake. Report concrete incomplete work when the project cannot be adapted; do not fabricate receipts, successful builds or live capability. Preserve reviewable partial changes on failure.
|
|
31
|
+
|
|
32
|
+
Initialization may need one preview build because it changes source. Subsequent ordinary value edits must use the connected runtime and existing persistence, without calling the Agent or rebuilding. Keep development runtime modules out of production output under `dist/`.
|
package/project-template/.claude/skills/playcraft-runtime-parameters/references/platform-protocol.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Existing parameter protocol
|
|
2
|
+
|
|
3
|
+
This is the Cloud Workspace-compatible interpretation of the supplied `platform-protocol` skill. Reuse its schema, `xplatform`, platform translations and sparse theme data; do not introduce a parallel `xplatform.parameter` declaration.
|
|
4
|
+
|
|
5
|
+
## Declaration and defaults
|
|
6
|
+
|
|
7
|
+
`.playcraft/workspace.json` may declare `parameters: {schemaPath, dataPath, themeId}`. These are engineering-relative paths and the selected theme identity. Preserve unrelated manifest keys. Prefer an existing declaration and theme layout. For a new layout, use `gamePlay.preloaded`, `gamePlay.sections` and `gamePlay.settlement` for the three game-screen areas; organize their children around the current game rather than a sample catalog.
|
|
8
|
+
|
|
9
|
+
Object nodes use `type: "object"` and `properties`. Leaves use supported primitive types, valid defaults, meaningful units and known bounds or enums. Basic editable leaves support string, finite number, integer, boolean and null. Preserve existing standard `required` arrays. Unsupported references and conditional composition are not a reason to flatten complex data or silently remove constraints.
|
|
10
|
+
|
|
11
|
+
Missing theme values inherit supported schema defaults. A data file containing `{}` is valid. Editing creates a raw override; restoring a default removes that override. Never write every resolved value into the theme data. Preserve comments, unknown data, unrelated overrides and empty parent objects already present in the project.
|
|
12
|
+
|
|
13
|
+
Use `i18n.platform.json5` beside the schema with the same tree. `_self.title` and `_self.description` contain `zh` and `en` labels for the panel. Preserve `xplatform.ext.readonly`, ancestor read-only rules and `xplatform.showIf` / `showIfAny`. Visibility controls presentation rather than write permission.
|
|
14
|
+
|
|
15
|
+
## Specialized values
|
|
16
|
+
|
|
17
|
+
Image, video and other resource leaves retain `xplatform.value_type: "path"` plus `mime_type`. Preserve their actual theme/shared path resolution and production asset registration. The platform already provides local upload, shared asset library, intelligent production and engineering-file selection; generated adapters consume the selected logical path rather than adding another asset-upload implementation.
|
|
18
|
+
|
|
19
|
+
Game text retains `xplatform.value_type: "i18n"` and an `i18n.runtime.<key>` reference. Keep global `i18n.runtime.json5` and per-theme `theme.i18n.runtime.json5` semantics and export the text from the real theme entry used in production. Supported languages are `en`, `zh`, `zh-TW`, `de`, `fr`, `es`, `ja`, `ko`, `pt`, `it`, `hi`, `bn`, `ur`, `ar`, `tr`, `ru`, `ms`, `id`. Keep complete language maps for overridden keys and remove an override only when its full value matches the inherited default. Explicit blank strings remain blank rather than falling back to another language.
|
|
20
|
+
|
|
21
|
+
Arrays, complex structures and unsupported constraints keep their original meaning. Do not disguise them as ordinary primitive controls to obtain a writable panel. An initialization can leave such controls explicitly unsupported while connecting a useful supported set.
|