@yugoo-design/dsh-design-studio 0.1.2 → 0.2.2
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/README.md +101 -89
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/lib/index.d.ts +3 -349
- package/lib/index.js +4 -886
- package/lib/instance-changes-CFLBPBdi.d.ts +280 -0
- package/lib/instance-changes-RvOGeBaE.js +691 -0
- package/lib/node.d.ts +437 -0
- package/lib/node.js +2270 -0
- package/licenses/dsh-distribution.json +160 -70
- package/package.json +18 -6
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { DesignBundleVersion, DesignKitVersion, compileSchema, loadDesignBundle, loadDesignKit } from "@yugoo-design/dsh-design-core";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
//#region src/load.ts
|
|
5
|
+
/** Studio-owned loading of a Kit from its directory source. */
|
|
6
|
+
async function loadStudioKit(fs, kitRoot, options = {}) {
|
|
7
|
+
const root = resolve(kitRoot);
|
|
8
|
+
return await loadDesignKit(fs, root, {
|
|
9
|
+
sourceId: options.sourceId ?? `directory:${root}`,
|
|
10
|
+
...options.bundles ? { resolveBundle: async (pin) => {
|
|
11
|
+
const location = options.bundles.find((entry) => entry.name === pin.name && entry.version === pin.version);
|
|
12
|
+
return location ? loadDesignBundle(fs, location, pin) : void 0;
|
|
13
|
+
} } : {}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
//#endregion
|
|
17
|
+
//#region src/panel-preview.ts
|
|
18
|
+
/** Definition-only Panel preview preparation; never writes or runs a Bundle. */
|
|
19
|
+
function failure(code, message) {
|
|
20
|
+
return { diagnostics: [{
|
|
21
|
+
code,
|
|
22
|
+
message
|
|
23
|
+
}] };
|
|
24
|
+
}
|
|
25
|
+
/** Shared finalization over a real Core-compiled Bundle, with optional real Kit context. */
|
|
26
|
+
function finalizeStudioPanelPreview(input) {
|
|
27
|
+
const { compiled, revision, sampleSource } = input;
|
|
28
|
+
const bundle = { ...input.bundle }, kit = input.kit === void 0 ? void 0 : { ...input.kit };
|
|
29
|
+
if (!Number.isSafeInteger(revision) || revision < 0 || typeof sampleSource !== "string") return failure("preview.request", "Select an exact Bundle, text sample JSON and a non-negative editor revision.");
|
|
30
|
+
if ((compiled.catalogName ?? compiled.name) !== bundle.name) return failure("preview.bundle_pin", "The source does not match the selected Bundle version.");
|
|
31
|
+
const panelYaml = compiled.metadata?.panelYaml;
|
|
32
|
+
if (typeof panelYaml !== "string") return failure("preview.not_declarative", "Only an existing declarative Panel can be previewed.");
|
|
33
|
+
const schema = compiled.metadata?.schemaDocument;
|
|
34
|
+
if (schema === void 0) return failure("preview.definition", "The loader did not produce a declarative Panel and schema.");
|
|
35
|
+
if (new TextEncoder().encode(sampleSource).length > 262144) return failure("preview.size", "Sample JSON is limited to 256 KiB.");
|
|
36
|
+
let sample;
|
|
37
|
+
try {
|
|
38
|
+
sample = JSON.parse(sampleSource);
|
|
39
|
+
} catch {
|
|
40
|
+
return failure("preview.sample_json", "The preview sample must be valid JSON.");
|
|
41
|
+
}
|
|
42
|
+
if (sample === null || typeof sample !== "object" || Array.isArray(sample)) return failure("preview.sample_json", "The preview sample must be a JSON object.");
|
|
43
|
+
try {
|
|
44
|
+
const errors = compileSchema(schema)(sample);
|
|
45
|
+
if (errors.length > 0) return { diagnostics: errors.map((message) => ({
|
|
46
|
+
code: "preview.sample_schema",
|
|
47
|
+
message
|
|
48
|
+
})) };
|
|
49
|
+
let artifacts;
|
|
50
|
+
try {
|
|
51
|
+
const mapping = input.readSampleResource?.("reference/preview-artifacts.json");
|
|
52
|
+
if (mapping !== void 0) {
|
|
53
|
+
if (new TextEncoder().encode(mapping).length > 16384) throw new Error("Sample artifact mapping exceeds 16 KiB.");
|
|
54
|
+
const parsed = JSON.parse(mapping);
|
|
55
|
+
if (parsed?.version !== 1 || !parsed.files || typeof parsed.files !== "object" || Array.isArray(parsed.files) || Object.keys(parsed).some((key) => !["version", "files"].includes(key)) || Object.keys(parsed.files).length > 8) throw new Error("Sample artifact mapping needs version 1 and at most eight files.");
|
|
56
|
+
artifacts = Object.create(null);
|
|
57
|
+
let bytes = 0;
|
|
58
|
+
const safe = (path) => typeof path === "string" && !/[\\:\x00-\x20?#%]/u.test(path) && path.split("/").every((part) => part !== "" && ![
|
|
59
|
+
".",
|
|
60
|
+
"..",
|
|
61
|
+
"__proto__",
|
|
62
|
+
"constructor",
|
|
63
|
+
"prototype"
|
|
64
|
+
].includes(part));
|
|
65
|
+
for (const [path, resource] of Object.entries(parsed.files)) {
|
|
66
|
+
if (!safe(path) || !path.startsWith(`${compiled.name}/artifacts/`) || !/\.(?:html|svg)$/iu.test(path) || !safe(resource) || !resource.startsWith("reference/preview-artifacts/") || !/\.(?:html|svg)$/iu.test(resource)) throw new Error("Sample artifacts must map Bundle HTML/SVG paths to confined preview resources.");
|
|
67
|
+
const content = input.readSampleResource?.(resource);
|
|
68
|
+
const size = typeof content === "string" ? new TextEncoder().encode(content).length : 0;
|
|
69
|
+
if (!size || size > 262144 || (bytes += size) > 1048576) throw new Error("Sample artifact missing or exceeds the 256 KiB file / 1 MiB total limit.");
|
|
70
|
+
artifacts[path] = content;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return failure("preview.sample_artifacts", error instanceof Error ? error.message : String(error));
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
preview: {
|
|
78
|
+
revision,
|
|
79
|
+
...kit === void 0 ? {} : { kit },
|
|
80
|
+
bundle,
|
|
81
|
+
panelYaml,
|
|
82
|
+
sample,
|
|
83
|
+
...artifacts === void 0 ? {} : { artifacts }
|
|
84
|
+
},
|
|
85
|
+
diagnostics: []
|
|
86
|
+
};
|
|
87
|
+
} catch (error) {
|
|
88
|
+
return failure("preview.definition", error instanceof Error ? error.message : String(error));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Compile one Panel edit against an exact, host-resolved Kit snapshot.
|
|
93
|
+
*
|
|
94
|
+
* The host must supply an immutable/read-confined source port and root for the
|
|
95
|
+
* selected pin, not arbitrary browser paths or a mutable production project.
|
|
96
|
+
* Only panel.yaml is overlaid. All schemas, methods, locales and renderer
|
|
97
|
+
* contracts still come from that snapshot and pass through the runtime loader.
|
|
98
|
+
* The returned sample is fresh JSON and can be edited in a sandboxed preview;
|
|
99
|
+
* this function grants no artifact, filesystem, model or publication authority.
|
|
100
|
+
* A preview consumer must discard a result if its editor revision has changed.
|
|
101
|
+
*/
|
|
102
|
+
async function prepareStudioPanelPreview(source, kitRoot, request, loadOptions = {}) {
|
|
103
|
+
const { panelSource, sampleSource, revision } = request;
|
|
104
|
+
const kitPin = { ...request.kit };
|
|
105
|
+
const bundlePin = { ...request.bundle };
|
|
106
|
+
if (!isAbsolute(kitRoot) || !/^[a-z][a-z0-9_]*$/.test(bundlePin.name) || !Number.isSafeInteger(revision) || revision < 0 || typeof panelSource !== "string" || typeof sampleSource !== "string") return failure("preview.request", "Select an exact Bundle and a non-negative editor revision.");
|
|
107
|
+
if (new TextEncoder().encode(panelSource).length > 1048576 || new TextEncoder().encode(sampleSource).length > 262144) return failure("preview.size", "Panel source is limited to 1 MiB and sample JSON to 256 KiB.");
|
|
108
|
+
let sample;
|
|
109
|
+
try {
|
|
110
|
+
sample = JSON.parse(sampleSource);
|
|
111
|
+
} catch {
|
|
112
|
+
return failure("preview.sample_json", "The preview sample must be valid JSON.");
|
|
113
|
+
}
|
|
114
|
+
if (sample === null || typeof sample !== "object" || Array.isArray(sample)) return failure("preview.sample_json", "The preview sample must be a JSON object.");
|
|
115
|
+
try {
|
|
116
|
+
const base = await loadStudioKit(source, kitRoot, loadOptions);
|
|
117
|
+
if (base.name !== kitPin.name) return failure("preview.kit_pin", "The source does not match the selected Kit version.");
|
|
118
|
+
const bundle = base.skills.find((skill) => (skill.catalogName ?? skill.name) === bundlePin.name);
|
|
119
|
+
if (bundle === void 0) return failure("preview.bundle_pin", "The source does not match the selected Bundle version.");
|
|
120
|
+
if (typeof bundle.metadata?.panelYaml !== "string") return failure("preview.not_declarative", "Only an existing declarative Panel can be edited in this preview.");
|
|
121
|
+
const panelPath = resolve(bundle.resourceRoot ?? resolve(kitRoot, "skills", bundle.name), "panel.yaml");
|
|
122
|
+
const candidate = await loadStudioKit({
|
|
123
|
+
readText: (path) => resolve(path) === panelPath ? Promise.resolve(panelSource) : source.readText(path),
|
|
124
|
+
...source.readResource ? { readResource: (path) => resolve(path) === panelPath ? Promise.resolve({
|
|
125
|
+
kind: "text",
|
|
126
|
+
content: panelSource
|
|
127
|
+
}) : source.readResource(path) } : {},
|
|
128
|
+
listDir: (path) => source.listDir(path),
|
|
129
|
+
...source.isDirectory ? { isDirectory: (path) => source.isDirectory(path) } : {},
|
|
130
|
+
...source.realPath ? { realPath: (path) => source.realPath(path) } : {}
|
|
131
|
+
}, kitRoot, loadOptions);
|
|
132
|
+
const compiled = candidate.skills.find((skill) => (skill.catalogName ?? skill.name) === bundlePin.name);
|
|
133
|
+
if (compiled === void 0) return failure("preview.bundle_pin", "The source does not match the selected Bundle version.");
|
|
134
|
+
return finalizeStudioPanelPreview({
|
|
135
|
+
compiled,
|
|
136
|
+
revision,
|
|
137
|
+
kit: kitPin,
|
|
138
|
+
bundle: bundlePin,
|
|
139
|
+
sampleSource,
|
|
140
|
+
readSampleResource: (path) => candidate.resources[`${compiled.name}/${path}`]
|
|
141
|
+
});
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return failure("preview.definition", error instanceof Error ? error.message : String(error));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
//#endregion
|
|
147
|
+
//#region src/content-archive.ts
|
|
148
|
+
/** Portable regular-file content, not a plugin installer or publication format. */
|
|
149
|
+
const MAX_BYTES = 16777216;
|
|
150
|
+
const MAX_JSON = 33554432;
|
|
151
|
+
const MAX_FILES = 4096;
|
|
152
|
+
const HASH = /^[0-9a-f]{64}$/;
|
|
153
|
+
const sha = (value) => createHash("sha256").update(value).digest("hex");
|
|
154
|
+
const OPAQUE_RESOURCE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
155
|
+
".bin",
|
|
156
|
+
".png",
|
|
157
|
+
".jpg",
|
|
158
|
+
".jpeg",
|
|
159
|
+
".gif",
|
|
160
|
+
".webp",
|
|
161
|
+
".avif",
|
|
162
|
+
".ico",
|
|
163
|
+
".bmp",
|
|
164
|
+
".pdf",
|
|
165
|
+
".woff",
|
|
166
|
+
".woff2",
|
|
167
|
+
".ttf",
|
|
168
|
+
".otf",
|
|
169
|
+
".eot",
|
|
170
|
+
".wasm"
|
|
171
|
+
]);
|
|
172
|
+
/** Shared by archive capture and virtual import; full bytes and directory entries remain intact. */
|
|
173
|
+
function readStudioArchiveResource(path, bytes) {
|
|
174
|
+
return OPAQUE_RESOURCE_EXTENSIONS.has(extname(path).toLowerCase()) ? {
|
|
175
|
+
kind: "opaque",
|
|
176
|
+
byteLength: bytes.byteLength
|
|
177
|
+
} : {
|
|
178
|
+
kind: "text",
|
|
179
|
+
content: new TextDecoder("utf-8", { fatal: true }).decode(bytes)
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
function object(value) {
|
|
183
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Invalid archive object.");
|
|
184
|
+
return value;
|
|
185
|
+
}
|
|
186
|
+
function keys(value, allowed) {
|
|
187
|
+
if (Object.keys(value).length !== allowed.length || allowed.some((key) => !Object.hasOwn(value, key))) throw Error("Unknown or missing archive fields.");
|
|
188
|
+
}
|
|
189
|
+
function text(value, limit) {
|
|
190
|
+
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value) > limit || /[\u0000-\u001f\u007f]/u.test(value)) throw Error("Invalid archive metadata text.");
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
function metadataText(value) {
|
|
194
|
+
if (typeof value !== "string" || Buffer.byteLength(value) > 2048) throw Error("Invalid archive metadata text.");
|
|
195
|
+
return value;
|
|
196
|
+
}
|
|
197
|
+
function pin(value, kind) {
|
|
198
|
+
const data = object(value);
|
|
199
|
+
keys(data, ["name", "version"]);
|
|
200
|
+
const name = text(data.name, 128), version = text(data.version, 32);
|
|
201
|
+
if (!/^[a-z][a-z0-9_]*$/.test(name)) throw Error("Unsupported archive content identity.");
|
|
202
|
+
safePath(name);
|
|
203
|
+
safePath(version);
|
|
204
|
+
if (kind === "kit") DesignKitVersion(version);
|
|
205
|
+
else DesignBundleVersion(version);
|
|
206
|
+
return Object.freeze({
|
|
207
|
+
name,
|
|
208
|
+
version
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function safePath(value) {
|
|
212
|
+
const path = text(value, 1024);
|
|
213
|
+
if (path !== path.normalize("NFC") || /[\\:*?"<>|]/u.test(path)) throw Error("Unsafe archive path.");
|
|
214
|
+
if (/\.(exe|dll|node|so|dylib|com|msi|sys)$/iu.test(path)) throw Error("Native executable payload propagation is unsupported.");
|
|
215
|
+
for (const part of path.split("/")) if (!part || part === "." || part === ".." || [".git", "node_modules"].includes(part.toLowerCase()) || /[. ]$/u.test(part) || /^(con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³])(?:\.|$)/iu.test(part)) throw Error("Unsafe archive path.");
|
|
216
|
+
return path;
|
|
217
|
+
}
|
|
218
|
+
function canonical(data) {
|
|
219
|
+
return JSON.stringify(data);
|
|
220
|
+
}
|
|
221
|
+
/** Parse a bounded JSON envelope; hashes include metadata and every original byte. */
|
|
222
|
+
function readStudioContentArchive(source) {
|
|
223
|
+
if (typeof source !== "string" || Buffer.byteLength(source) > MAX_JSON) throw Error("Archive JSON exceeds 32 MiB.");
|
|
224
|
+
const raw = object(JSON.parse(source));
|
|
225
|
+
keys(raw, [
|
|
226
|
+
"format",
|
|
227
|
+
"kind",
|
|
228
|
+
"pin",
|
|
229
|
+
...Object.hasOwn(raw, "title") ? ["title"] : [],
|
|
230
|
+
...Object.hasOwn(raw, "provenance") ? ["provenance"] : [],
|
|
231
|
+
"dependencies",
|
|
232
|
+
"files",
|
|
233
|
+
"sha256"
|
|
234
|
+
]);
|
|
235
|
+
if (raw.format !== "studio-content-archive/v1" || raw.kind !== "kit" && raw.kind !== "bundle") throw Error("Unsupported archive format.");
|
|
236
|
+
const identity = pin(raw.pin, raw.kind);
|
|
237
|
+
let title;
|
|
238
|
+
if (raw.title !== void 0) {
|
|
239
|
+
const names = object(raw.title);
|
|
240
|
+
keys(names, ["zh", "en"]);
|
|
241
|
+
title = Object.freeze({
|
|
242
|
+
zh: text(names.zh, 800),
|
|
243
|
+
en: text(names.en, 800)
|
|
244
|
+
});
|
|
245
|
+
if (title.zh.length > 200 || title.en.length > 200) throw Error("Archive display title exceeds 200 characters.");
|
|
246
|
+
}
|
|
247
|
+
let origin;
|
|
248
|
+
if (raw.provenance !== void 0) {
|
|
249
|
+
const provenance = object(raw.provenance);
|
|
250
|
+
keys(provenance, ["source", "license"].filter((key) => Object.hasOwn(provenance, key)));
|
|
251
|
+
origin = Object.freeze({
|
|
252
|
+
...provenance.source === void 0 ? {} : { source: metadataText(provenance.source) },
|
|
253
|
+
...provenance.license === void 0 ? {} : { license: metadataText(provenance.license) }
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
if (!Array.isArray(raw.dependencies) || raw.dependencies.length > MAX_FILES) throw Error("Invalid archive dependencies.");
|
|
257
|
+
const dependencies = raw.dependencies.map((value) => pin(value, "bundle"));
|
|
258
|
+
const names = /* @__PURE__ */ new Set();
|
|
259
|
+
for (const dependency of dependencies) {
|
|
260
|
+
if (names.has(dependency.name) || raw.kind === "bundle" && dependency.name === identity.name) throw Error("Duplicate or self archive dependency.");
|
|
261
|
+
names.add(dependency.name);
|
|
262
|
+
}
|
|
263
|
+
if (!Array.isArray(raw.files) || !raw.files.length || raw.files.length > MAX_FILES) throw Error("Invalid archive file count.");
|
|
264
|
+
let total = 0;
|
|
265
|
+
const paths = /* @__PURE__ */ new Map();
|
|
266
|
+
const prefixes = /* @__PURE__ */ new Map();
|
|
267
|
+
const directories = /* @__PURE__ */ new Set();
|
|
268
|
+
const files = raw.files.map((value) => {
|
|
269
|
+
const entry = object(value);
|
|
270
|
+
keys(entry, [
|
|
271
|
+
"path",
|
|
272
|
+
"base64",
|
|
273
|
+
"bytes",
|
|
274
|
+
"sha256"
|
|
275
|
+
]);
|
|
276
|
+
const path = safePath(entry.path), folded = path.toLowerCase();
|
|
277
|
+
const segments = path.split("/");
|
|
278
|
+
for (let index = 1; index <= segments.length; index++) {
|
|
279
|
+
const prefix = segments.slice(0, index).join("/"), foldedPrefix = prefix.toLowerCase();
|
|
280
|
+
if (prefixes.has(foldedPrefix) && prefixes.get(foldedPrefix) !== prefix) throw Error("Case-conflicting archive path segments.");
|
|
281
|
+
prefixes.set(foldedPrefix, prefix);
|
|
282
|
+
}
|
|
283
|
+
if (paths.has(folded)) throw Error("Duplicate or case-conflicting archive paths.");
|
|
284
|
+
paths.set(folded, path);
|
|
285
|
+
const parts = folded.split("/");
|
|
286
|
+
parts.pop();
|
|
287
|
+
while (parts.length) {
|
|
288
|
+
directories.add(parts.join("/"));
|
|
289
|
+
parts.pop();
|
|
290
|
+
}
|
|
291
|
+
if (!Number.isSafeInteger(entry.bytes) || entry.bytes < 0 || entry.bytes > MAX_BYTES - total) throw Error("Archive content exceeds 16 MiB or has an invalid byte count.");
|
|
292
|
+
if (typeof entry.base64 !== "string" || entry.base64.length > Math.ceil(MAX_BYTES / 3) * 4 || entry.base64.length % 4 || !/^[A-Za-z0-9+/]*={0,2}$/u.test(entry.base64)) throw Error("Invalid archive base64.");
|
|
293
|
+
const bytes = Buffer.from(entry.base64, "base64");
|
|
294
|
+
if (bytes.toString("base64") !== entry.base64 || entry.bytes !== bytes.length || typeof entry.sha256 !== "string" || !HASH.test(entry.sha256) || sha(bytes) !== entry.sha256) throw Error("Archive file integrity mismatch.");
|
|
295
|
+
total += bytes.length;
|
|
296
|
+
if (total > MAX_BYTES) throw Error("Archive content exceeds 16 MiB.");
|
|
297
|
+
return Object.freeze({
|
|
298
|
+
path,
|
|
299
|
+
base64: entry.base64,
|
|
300
|
+
bytes: bytes.length,
|
|
301
|
+
sha256: entry.sha256
|
|
302
|
+
});
|
|
303
|
+
}).sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
304
|
+
if ([...directories].some((path) => paths.has(path))) throw Error("Archive file/directory collision.");
|
|
305
|
+
const data = {
|
|
306
|
+
format: raw.format,
|
|
307
|
+
kind: raw.kind,
|
|
308
|
+
pin: identity,
|
|
309
|
+
...title === void 0 ? {} : { title },
|
|
310
|
+
...origin === void 0 ? {} : { provenance: origin },
|
|
311
|
+
dependencies: Object.freeze(dependencies),
|
|
312
|
+
files: Object.freeze(files)
|
|
313
|
+
};
|
|
314
|
+
if (typeof raw.sha256 !== "string" || !HASH.test(raw.sha256) || sha(canonical(data)) !== raw.sha256) throw Error("Archive envelope integrity mismatch.");
|
|
315
|
+
return Object.freeze({
|
|
316
|
+
...data,
|
|
317
|
+
sha256: raw.sha256
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
/** Pure virtual source: explicit text reads stay strict; attached byte assets remain opaque. */
|
|
321
|
+
function virtualSource(files, root) {
|
|
322
|
+
const map = new Map(files.map((file) => [file.path, file.base64]));
|
|
323
|
+
const key = (path) => {
|
|
324
|
+
const result = relative(root, resolve(path));
|
|
325
|
+
if (isAbsolute(result) || result === ".." || result.startsWith(`..${sep}`)) throw Error("Archive validation path escapes source.");
|
|
326
|
+
return result.split(sep).join("/");
|
|
327
|
+
};
|
|
328
|
+
const children = (path) => {
|
|
329
|
+
const prefix = key(path);
|
|
330
|
+
const head = prefix ? `${prefix}/` : "";
|
|
331
|
+
return [...new Set([...map.keys()].filter((file) => file.startsWith(head)).map((file) => file.slice(head.length).split("/")[0]))].sort();
|
|
332
|
+
};
|
|
333
|
+
return {
|
|
334
|
+
async readText(path) {
|
|
335
|
+
const content = map.get(key(path));
|
|
336
|
+
return content === void 0 ? void 0 : new TextDecoder("utf-8", { fatal: true }).decode(Buffer.from(content, "base64"));
|
|
337
|
+
},
|
|
338
|
+
async readResource(path) {
|
|
339
|
+
const content = map.get(key(path));
|
|
340
|
+
return content === void 0 ? void 0 : readStudioArchiveResource(path, Buffer.from(content, "base64"));
|
|
341
|
+
},
|
|
342
|
+
async listDir(path) {
|
|
343
|
+
return children(path);
|
|
344
|
+
},
|
|
345
|
+
async isDirectory(path) {
|
|
346
|
+
return children(path).length > 0;
|
|
347
|
+
},
|
|
348
|
+
async realPath(path) {
|
|
349
|
+
key(path);
|
|
350
|
+
return resolve(path);
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const bundlePin = (skill) => ({
|
|
355
|
+
name: skill.catalogName ?? skill.name,
|
|
356
|
+
version: String(skill.version)
|
|
357
|
+
});
|
|
358
|
+
const samePin = (left, right) => left.name === right.name && left.version === right.version;
|
|
359
|
+
const bundlePrefix = (identity) => `bundles/${identity.name}/${identity.version}/`;
|
|
360
|
+
const within = (root, path) => {
|
|
361
|
+
const rel = relative(root, resolve(path));
|
|
362
|
+
return !isAbsolute(rel) && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
363
|
+
};
|
|
364
|
+
/** All reads dispatch to captured bytes; there is no live filesystem fallback. */
|
|
365
|
+
function sourceView(snapshot) {
|
|
366
|
+
const bundles = snapshot.bundles ?? [];
|
|
367
|
+
const sources = [...bundles.map((bundle) => bundle.snapshot), snapshot].sort((a, b) => b.root.length - a.root.length);
|
|
368
|
+
const owner = (path) => {
|
|
369
|
+
const source = sources.find((source) => within(source.root, path));
|
|
370
|
+
if (!source) throw Error("Archive validation path escapes captured sources.");
|
|
371
|
+
return source;
|
|
372
|
+
};
|
|
373
|
+
return {
|
|
374
|
+
fs: {
|
|
375
|
+
readText: (path) => owner(path).fs.readText(path),
|
|
376
|
+
async readResource(path) {
|
|
377
|
+
const source = owner(path);
|
|
378
|
+
if (typeof source.readBytes === "function") return readStudioArchiveResource(path, await source.readBytes(path));
|
|
379
|
+
if (source.fs.readResource) return source.fs.readResource(path);
|
|
380
|
+
const content = await source.fs.readText(path);
|
|
381
|
+
return content === void 0 ? void 0 : {
|
|
382
|
+
kind: "text",
|
|
383
|
+
content
|
|
384
|
+
};
|
|
385
|
+
},
|
|
386
|
+
listDir: (path) => owner(path).fs.listDir(path),
|
|
387
|
+
async isDirectory(path) {
|
|
388
|
+
const source = owner(path);
|
|
389
|
+
return source.fs.isDirectory ? source.fs.isDirectory(path) : (await source.fs.listDir(path)).length > 0;
|
|
390
|
+
},
|
|
391
|
+
async realPath(path) {
|
|
392
|
+
const source = owner(path);
|
|
393
|
+
return source.fs.realPath ? source.fs.realPath(path) : resolve(path);
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
options: {
|
|
397
|
+
sourceId: snapshot.sourceId,
|
|
398
|
+
bundles: bundles.map((bundle) => ({
|
|
399
|
+
name: bundle.name,
|
|
400
|
+
version: bundle.version,
|
|
401
|
+
rootDir: bundle.snapshot.root,
|
|
402
|
+
sourceId: bundle.sourceId
|
|
403
|
+
}))
|
|
404
|
+
}
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
function archiveSnapshot(archive) {
|
|
408
|
+
const root = resolve("/studio-content-archive-virtual");
|
|
409
|
+
const sourceId = `archive:untrusted:${archive.sha256}`;
|
|
410
|
+
const independent = archive.kind === "kit" && archive.files.some((file) => file.path === "kit.yaml");
|
|
411
|
+
const filesAt = (files, root) => {
|
|
412
|
+
const fs = virtualSource(files, root);
|
|
413
|
+
return {
|
|
414
|
+
root,
|
|
415
|
+
sourceId,
|
|
416
|
+
fs,
|
|
417
|
+
async listFiles() {
|
|
418
|
+
return files.map((file) => file.path);
|
|
419
|
+
},
|
|
420
|
+
async readBytes(path) {
|
|
421
|
+
const file = files.find((file) => resolve(root, file.path) === resolve(path));
|
|
422
|
+
if (!file) throw Error("Missing archive source file.");
|
|
423
|
+
return Buffer.from(file.base64, "base64");
|
|
424
|
+
}
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
if (!independent) return filesAt(archive.files, root);
|
|
428
|
+
const bundleFiles = /* @__PURE__ */ new Set();
|
|
429
|
+
const bundles = archive.dependencies.map((identity) => {
|
|
430
|
+
const prefix = bundlePrefix(identity);
|
|
431
|
+
const files = archive.files.filter((file) => file.path.startsWith(prefix)).map((file) => {
|
|
432
|
+
bundleFiles.add(file.path);
|
|
433
|
+
return Object.freeze({
|
|
434
|
+
...file,
|
|
435
|
+
path: file.path.slice(prefix.length)
|
|
436
|
+
});
|
|
437
|
+
});
|
|
438
|
+
if (!files.length) throw Error(`Archive is missing exact Bundle content: ${identity.name}@${identity.version}`);
|
|
439
|
+
return {
|
|
440
|
+
...identity,
|
|
441
|
+
sourceId,
|
|
442
|
+
snapshot: filesAt(files, resolve(root, prefix))
|
|
443
|
+
};
|
|
444
|
+
});
|
|
445
|
+
if (archive.files.some((file) => file.path.startsWith("bundles/") && !bundleFiles.has(file.path))) throw Error("Archive contains unreferenced Bundle content.");
|
|
446
|
+
return {
|
|
447
|
+
...filesAt(archive.files.filter((file) => !bundleFiles.has(file.path)), root),
|
|
448
|
+
bundles
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
/** No writes, extraction, permission restoration, script execution, installation or publication. */
|
|
452
|
+
async function prepareStudioContentImport(source, options = {}) {
|
|
453
|
+
const archive = readStudioContentArchive(source);
|
|
454
|
+
let validation;
|
|
455
|
+
let contents = Object.freeze([]);
|
|
456
|
+
let requiresTrustReview = archive.files.some((file) => /\.(m?js|cjs|[cm]?tsx?|sh|ps1|cmd|bat|py|wasm)$/iu.test(file.path));
|
|
457
|
+
try {
|
|
458
|
+
const snapshot = archiveSnapshot(archive), view = sourceView(snapshot);
|
|
459
|
+
const selected = [];
|
|
460
|
+
if (archive.kind === "kit") {
|
|
461
|
+
const kit = await loadStudioKit(view.fs, snapshot.root, view.options);
|
|
462
|
+
selected.push(...kit.skills);
|
|
463
|
+
if (kit.name !== archive.pin.name || String(kit.version) !== archive.pin.version) throw Error("Archive Kit identity disagrees with source.");
|
|
464
|
+
if (JSON.stringify(kit.skills.map(bundlePin)) !== JSON.stringify(archive.dependencies)) throw Error("Archive Kit member pins disagree with source.");
|
|
465
|
+
if (snapshot.bundles) {
|
|
466
|
+
const kitFiles = archive.files.filter((file) => !file.path.startsWith("bundles/"));
|
|
467
|
+
contents = Object.freeze([Object.freeze({
|
|
468
|
+
kind: "kit",
|
|
469
|
+
pin: archive.pin,
|
|
470
|
+
files: Object.freeze(kitFiles)
|
|
471
|
+
}), ...snapshot.bundles.map((bundle) => Object.freeze({
|
|
472
|
+
kind: "bundle",
|
|
473
|
+
pin: Object.freeze({
|
|
474
|
+
name: bundle.name,
|
|
475
|
+
version: bundle.version
|
|
476
|
+
}),
|
|
477
|
+
files: Object.freeze(archive.files.filter((file) => file.path.startsWith(bundlePrefix(bundle))).map((file) => Object.freeze({
|
|
478
|
+
...file,
|
|
479
|
+
path: file.path.slice(bundlePrefix(bundle).length)
|
|
480
|
+
})))
|
|
481
|
+
}))]);
|
|
482
|
+
} else contents = Object.freeze([Object.freeze({
|
|
483
|
+
kind: "kit",
|
|
484
|
+
pin: archive.pin,
|
|
485
|
+
files: archive.files
|
|
486
|
+
})]);
|
|
487
|
+
} else {
|
|
488
|
+
const bundle = await loadDesignBundle(snapshot.fs, {
|
|
489
|
+
rootDir: snapshot.root,
|
|
490
|
+
sourceId: snapshot.sourceId
|
|
491
|
+
}, archive.pin);
|
|
492
|
+
selected.push(bundle.skill);
|
|
493
|
+
const context = options.validationContext;
|
|
494
|
+
if (archive.dependencies.length && (!context || !isAbsolute(context.root))) throw Error("Independent Bundle dependency validation requires a host-resolved immutable Kit context.");
|
|
495
|
+
if (context) {
|
|
496
|
+
if (!isAbsolute(context.root)) throw Error("Archive context must have a host-resolved absolute root.");
|
|
497
|
+
const contextView = sourceView(context);
|
|
498
|
+
const kit = await loadStudioKit(contextView.fs, context.root, contextView.options);
|
|
499
|
+
if (archive.dependencies.some((dependency) => !kit.skills.some((skill) => samePin(bundlePin(skill), dependency)))) throw Error("Archive Bundle dependency is unresolved in context.");
|
|
500
|
+
const existing = kit.skills.find((skill) => samePin(bundlePin(skill), archive.pin));
|
|
501
|
+
if (existing?.resourceRoot) {
|
|
502
|
+
const local = virtualSource(archive.files, existing.resourceRoot);
|
|
503
|
+
const owns = (path) => within(existing.resourceRoot, path);
|
|
504
|
+
await loadStudioKit({
|
|
505
|
+
readText: (path) => owns(path) ? local.readText(path) : contextView.fs.readText(path),
|
|
506
|
+
readResource: (path) => owns(path) ? local.readResource(path) : contextView.fs.readResource(path),
|
|
507
|
+
listDir: (path) => owns(path) ? local.listDir(path) : contextView.fs.listDir(path),
|
|
508
|
+
async isDirectory(path) {
|
|
509
|
+
return owns(path) ? local.isDirectory(path) : contextView.fs.isDirectory(path);
|
|
510
|
+
},
|
|
511
|
+
async realPath(path) {
|
|
512
|
+
return owns(path) ? resolve(path) : contextView.fs.realPath(path);
|
|
513
|
+
}
|
|
514
|
+
}, context.root, contextView.options);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
contents = Object.freeze([Object.freeze({
|
|
518
|
+
kind: "bundle",
|
|
519
|
+
pin: archive.pin,
|
|
520
|
+
files: archive.files
|
|
521
|
+
})]);
|
|
522
|
+
}
|
|
523
|
+
requiresTrustReview ||= selected.some((skill) => {
|
|
524
|
+
const manifest = skill.metadata?.skillManifest;
|
|
525
|
+
const rules = skill.metadata?.validationDocument;
|
|
526
|
+
return manifest?.presentation?.mode === "native" || !!rules?.rules?.some((rule) => typeof rule.check?.driver === "string");
|
|
527
|
+
});
|
|
528
|
+
validation = Object.freeze({
|
|
529
|
+
valid: true,
|
|
530
|
+
diagnostics: Object.freeze([])
|
|
531
|
+
});
|
|
532
|
+
} catch (error) {
|
|
533
|
+
contents = Object.freeze([]);
|
|
534
|
+
validation = Object.freeze({
|
|
535
|
+
valid: false,
|
|
536
|
+
diagnostics: Object.freeze([Object.freeze({
|
|
537
|
+
code: "archive.definition",
|
|
538
|
+
message: error instanceof Error ? error.message : String(error),
|
|
539
|
+
subject: archive.pin.name
|
|
540
|
+
})])
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return Object.freeze({
|
|
544
|
+
archive,
|
|
545
|
+
files: archive.files,
|
|
546
|
+
contents,
|
|
547
|
+
executionGranted: false,
|
|
548
|
+
requiresTrustReview,
|
|
549
|
+
validation
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/** Capture only immutable host snapshots, preserving full exact referenced trees. */
|
|
553
|
+
function exportStudioContentArchive(snapshot, input) {
|
|
554
|
+
return exportArchive(snapshot, input, true);
|
|
555
|
+
}
|
|
556
|
+
/** Capture saved source bytes, including incomplete working copies, without definition validation. */
|
|
557
|
+
function exportStudioSourceArchive(snapshot, input) {
|
|
558
|
+
return exportArchive(snapshot, input, false);
|
|
559
|
+
}
|
|
560
|
+
async function exportArchive(snapshot, input, validateDefinition) {
|
|
561
|
+
const request = JSON.parse(JSON.stringify(input));
|
|
562
|
+
if (!isAbsolute(snapshot.root)) throw Error("Archive source root must be host-resolved absolute.");
|
|
563
|
+
const standalone = snapshot.bundle !== void 0;
|
|
564
|
+
if (standalone && (request.kind !== "bundle" || !samePin(snapshot.bundle, request.pin))) throw Error("Standalone Bundle snapshot pin disagrees with export.");
|
|
565
|
+
const view = validateDefinition ? sourceView(snapshot) : void 0;
|
|
566
|
+
const kit = !validateDefinition || standalone ? void 0 : await loadStudioKit(view.fs, snapshot.root, view.options);
|
|
567
|
+
const selected = !validateDefinition ? void 0 : standalone ? (await loadDesignBundle(view.fs, {
|
|
568
|
+
rootDir: snapshot.root,
|
|
569
|
+
sourceId: snapshot.sourceId ?? "studio:exact-instance"
|
|
570
|
+
}, request.pin)).skill : request.kind === "kit" ? kit : kit.skills.find((skill) => samePin(bundlePin(skill), request.pin));
|
|
571
|
+
if (validateDefinition && (!selected || !samePin(request.kind === "kit" ? {
|
|
572
|
+
name: selected.name,
|
|
573
|
+
version: String(selected.version)
|
|
574
|
+
} : bundlePin(selected), request.pin))) throw Error("Export pin disagrees with snapshot.");
|
|
575
|
+
const files = [];
|
|
576
|
+
let total = 0;
|
|
577
|
+
const append = async (source, prefix, selectPrefix = "") => {
|
|
578
|
+
const all = await source.listFiles();
|
|
579
|
+
if (all.length > MAX_FILES) throw Error("Snapshot file count exceeds 4096.");
|
|
580
|
+
for (const path of [...all].sort()) {
|
|
581
|
+
safePath(path);
|
|
582
|
+
if (selectPrefix && !path.startsWith(selectPrefix)) continue;
|
|
583
|
+
const target = prefix + path.slice(selectPrefix.length);
|
|
584
|
+
safePath(target);
|
|
585
|
+
const bytes = await source.readBytes(resolve(source.root, path));
|
|
586
|
+
total += bytes.byteLength;
|
|
587
|
+
if (total > MAX_BYTES || files.length >= MAX_FILES) throw Error("Archive content exceeds 16 MiB or 4096 files.");
|
|
588
|
+
files.push({
|
|
589
|
+
path: target,
|
|
590
|
+
base64: Buffer.from(bytes).toString("base64"),
|
|
591
|
+
bytes: bytes.byteLength,
|
|
592
|
+
sha256: sha(bytes)
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
if (!validateDefinition) {
|
|
597
|
+
await append(snapshot, "");
|
|
598
|
+
if (request.kind === "kit") for (const bundle of snapshot.bundles ?? []) await append(bundle.snapshot, bundlePrefix(bundle));
|
|
599
|
+
} else if (standalone) await append(snapshot, "");
|
|
600
|
+
else if (snapshot.bundles) {
|
|
601
|
+
const referenced = kit.skills.map((skill) => {
|
|
602
|
+
const identity = bundlePin(skill), matches = snapshot.bundles.filter((bundle) => samePin(bundle, identity));
|
|
603
|
+
if (matches.length !== 1) throw Error("Missing or ambiguous exact Bundle snapshot.");
|
|
604
|
+
return matches[0];
|
|
605
|
+
});
|
|
606
|
+
if (request.kind === "kit") {
|
|
607
|
+
if ((await snapshot.listFiles()).some((path) => path === "bundles" || path.startsWith("bundles/"))) throw Error("Kit-owned files collide with archive Bundle transport paths.");
|
|
608
|
+
await append(snapshot, "");
|
|
609
|
+
for (const bundle of referenced) await append(bundle.snapshot, bundlePrefix(bundle));
|
|
610
|
+
} else await append(referenced.find((bundle) => samePin(bundle, request.pin)).snapshot, "");
|
|
611
|
+
} else await append(snapshot, "", request.kind === "bundle" ? `skills/${selected.name}/` : "");
|
|
612
|
+
files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
613
|
+
const data = {
|
|
614
|
+
format: "studio-content-archive/v1",
|
|
615
|
+
kind: request.kind,
|
|
616
|
+
pin: request.pin,
|
|
617
|
+
...request.title === void 0 ? {} : { title: {
|
|
618
|
+
zh: request.title.zh,
|
|
619
|
+
en: request.title.en
|
|
620
|
+
} },
|
|
621
|
+
...request.provenance === void 0 ? {} : { provenance: request.provenance },
|
|
622
|
+
dependencies: request.kind === "kit" && validateDefinition ? kit.skills.map(bundlePin) : request.dependencies ?? (request.kind === "kit" ? snapshot.bundles?.map((bundle) => ({
|
|
623
|
+
name: bundle.name,
|
|
624
|
+
version: bundle.version
|
|
625
|
+
})) ?? [] : []),
|
|
626
|
+
files
|
|
627
|
+
};
|
|
628
|
+
if (validateDefinition && request.kind === "kit" && request.dependencies !== void 0 && JSON.stringify(request.dependencies) !== JSON.stringify(data.dependencies)) throw Error("Explicit Kit pins disagree with source membership.");
|
|
629
|
+
const serialized = JSON.stringify({
|
|
630
|
+
...data,
|
|
631
|
+
sha256: sha(canonical(data))
|
|
632
|
+
});
|
|
633
|
+
if (!validateDefinition) {
|
|
634
|
+
readStudioContentArchive(serialized);
|
|
635
|
+
return serialized;
|
|
636
|
+
}
|
|
637
|
+
const prepared = await prepareStudioContentImport(serialized, { validationContext: standalone ? void 0 : snapshot });
|
|
638
|
+
if (!prepared.validation.valid) throw Error(prepared.validation.diagnostics[0].message);
|
|
639
|
+
return serialized;
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/instance-changes.ts
|
|
643
|
+
const STUDIO_INSTANCE_CHANGE_LIMITS = Object.freeze({
|
|
644
|
+
files: 64,
|
|
645
|
+
fileBytes: 1048576,
|
|
646
|
+
totalBytes: 4194304,
|
|
647
|
+
diffBytes: 8388608,
|
|
648
|
+
sampleBytes: 262144
|
|
649
|
+
});
|
|
650
|
+
/** Copy before any asynchronous host work so callers cannot mutate an in-flight edit. */
|
|
651
|
+
function normalizeStudioInstanceChanges(value) {
|
|
652
|
+
const { files, fileBytes, totalBytes, sampleBytes } = STUDIO_INSTANCE_CHANGE_LIMITS;
|
|
653
|
+
if (!value || typeof value.id !== "string" || !value.id || value.id.length > 128 || !Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0 || value.expectedRevision >= Number.MAX_SAFE_INTEGER || !Array.isArray(value.changes) || !value.changes.length || value.changes.length > files) throw new Error(`Select an instance revision and 1–${files} file changes.`);
|
|
654
|
+
const seen = /* @__PURE__ */ new Set(), encoder = new TextEncoder();
|
|
655
|
+
let bytes = 0;
|
|
656
|
+
const changes = value.changes.map((change) => {
|
|
657
|
+
const { path, content } = change ?? {};
|
|
658
|
+
if (typeof path !== "string" || !path || path.length > 1024 || /[\\:\u0000-\u001f\u007f]/u.test(path) || path.split("/").length > 32 || path.split("/").some((part) => !part || [
|
|
659
|
+
".",
|
|
660
|
+
"..",
|
|
661
|
+
".git",
|
|
662
|
+
"node_modules",
|
|
663
|
+
"__proto__",
|
|
664
|
+
"constructor",
|
|
665
|
+
"prototype"
|
|
666
|
+
].includes(part)) || seen.has(path)) throw new Error("Invalid or duplicate instance source path.");
|
|
667
|
+
seen.add(path);
|
|
668
|
+
if (content !== null && (typeof content !== "string" || content.length > fileBytes || !content.isWellFormed() || content.includes("\0") || encoder.encode(content).length > fileBytes)) throw new Error("Source must be UTF-8 text of at most 1 MiB per file.");
|
|
669
|
+
bytes += encoder.encode(path).length + (content === null ? 0 : encoder.encode(content).length);
|
|
670
|
+
if (bytes > totalBytes) throw new Error("Instance changes exceed the 4 MiB total text budget.");
|
|
671
|
+
return Object.freeze({
|
|
672
|
+
path,
|
|
673
|
+
content
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
if (value.sampleSource !== void 0 && (typeof value.sampleSource !== "string" || value.sampleSource.length > sampleBytes || !value.sampleSource.isWellFormed() || encoder.encode(value.sampleSource).length > sampleBytes)) throw new Error("Isolated preview sample must be UTF-8 JSON text of at most 256 KiB.");
|
|
677
|
+
const bundle = value.bundle;
|
|
678
|
+
if (bundle !== void 0 && (typeof bundle.name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(bundle.name) || typeof bundle.version !== "string" || !/^\d+\.\d+$/.test(bundle.version))) throw new Error("Select an exact preview Bundle pin.");
|
|
679
|
+
return Object.freeze({
|
|
680
|
+
id: value.id,
|
|
681
|
+
expectedRevision: value.expectedRevision,
|
|
682
|
+
changes: Object.freeze(changes),
|
|
683
|
+
...bundle ? { bundle: Object.freeze({
|
|
684
|
+
name: bundle.name,
|
|
685
|
+
version: bundle.version
|
|
686
|
+
}) } : {},
|
|
687
|
+
...value.sampleSource === void 0 ? {} : { sampleSource: value.sampleSource }
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
//#endregion
|
|
691
|
+
export { readStudioArchiveResource as a, prepareStudioPanelPreview as c, exportStudioSourceArchive as i, loadStudioKit as l, normalizeStudioInstanceChanges as n, readStudioContentArchive as o, exportStudioContentArchive as r, finalizeStudioPanelPreview as s, STUDIO_INSTANCE_CHANGE_LIMITS as t };
|