Fix app.json vendoring and schema bounds
This commit is contained in:
@@ -408,9 +408,9 @@
|
||||
"properties": {
|
||||
"volume_name": { "type": "string" },
|
||||
"background": { "type": "string" },
|
||||
"window_width": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 128 },
|
||||
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
|
||||
"app_position": { "$ref": "#/$defs/position" },
|
||||
"applications_position": { "$ref": "#/$defs/position" },
|
||||
"applications_link": { "type": "boolean", "default": true },
|
||||
|
||||
@@ -408,9 +408,9 @@
|
||||
"properties": {
|
||||
"volume_name": { "type": "string" },
|
||||
"background": { "type": "string" },
|
||||
"window_width": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 128 },
|
||||
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
|
||||
"app_position": { "$ref": "#/$defs/position" },
|
||||
"applications_position": { "$ref": "#/$defs/position" },
|
||||
"applications_link": { "type": "boolean", "default": true },
|
||||
|
||||
@@ -18,6 +18,12 @@ assert.equal(schema.$defs.persist.properties.debounce_ms.minimum, 0);
|
||||
assert.equal(schema.$defs.persist.properties.debounce_ms.maximum, 60_000);
|
||||
assert.equal(schema.$defs.frontend.properties.dev.properties.timeout_ms.minimum, 1);
|
||||
assert.equal(schema.$defs.frontend.properties.dev.properties.timeout_ms.maximum, 4_294_967_295);
|
||||
assert.equal(schema.$defs.dmg.properties.window_width.minimum, 320);
|
||||
assert.equal(schema.$defs.dmg.properties.window_width.maximum, 2_000);
|
||||
assert.equal(schema.$defs.dmg.properties.window_height.minimum, 240);
|
||||
assert.equal(schema.$defs.dmg.properties.window_height.maximum, 1_400);
|
||||
assert.equal(schema.$defs.dmg.properties.icon_size.minimum, 32);
|
||||
assert.equal(schema.$defs.dmg.properties.icon_size.maximum, 256);
|
||||
assert.deepEqual(fs.readFileSync(legacyPath), publishedBytes, "legacy docs schema differs from canonical v1");
|
||||
assert.deepEqual(fs.readFileSync(packagePath), publishedBytes, "published and npm-packaged app schemas differ");
|
||||
assert.equal(deployment.outputDirectory, "public");
|
||||
|
||||
@@ -86,6 +86,89 @@ function parseJsonManifest(source) {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
function skipJsonWhitespace(source, start) {
|
||||
let at = start;
|
||||
while (/\s/.test(source[at] ?? "")) at++;
|
||||
return at;
|
||||
}
|
||||
|
||||
function scanJsonString(source, start) {
|
||||
let escaped = false;
|
||||
for (let at = start + 1; at < source.length; at++) {
|
||||
const char = source[at];
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === '"') return at + 1;
|
||||
}
|
||||
throw new Error("app.json contains an unterminated string");
|
||||
}
|
||||
|
||||
function scanJsonValue(source, start) {
|
||||
const first = source[start];
|
||||
if (first === '"') return scanJsonString(source, start);
|
||||
if (first === "{" || first === "[") {
|
||||
const close = first === "{" ? "}" : "]";
|
||||
let depth = 1;
|
||||
let string = false;
|
||||
let escaped = false;
|
||||
for (let at = start + 1; at < source.length; at++) {
|
||||
const char = source[at];
|
||||
if (string) {
|
||||
if (escaped) escaped = false;
|
||||
else if (char === "\\") escaped = true;
|
||||
else if (char === '"') string = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') string = true;
|
||||
else if (char === first) depth++;
|
||||
else if (char === close && --depth === 0) return at + 1;
|
||||
}
|
||||
throw new Error("app.json contains an unterminated value");
|
||||
}
|
||||
let at = start;
|
||||
while (at < source.length && !/[\s,}\]]/.test(source[at])) at++;
|
||||
return at;
|
||||
}
|
||||
|
||||
function jsonObjectField(source, fieldName) {
|
||||
let at = skipJsonWhitespace(source, 0);
|
||||
if (source[at] !== "{") throw new Error("app.json must contain one object");
|
||||
at = skipJsonWhitespace(source, at + 1);
|
||||
let memberCount = 0;
|
||||
let match = null;
|
||||
while (source[at] !== "}") {
|
||||
if (source[at] !== '"') throw new Error("app.json contains an invalid object key");
|
||||
const keyStart = at;
|
||||
const keyEnd = scanJsonString(source, keyStart);
|
||||
const key = JSON.parse(source.slice(keyStart, keyEnd));
|
||||
at = skipJsonWhitespace(source, keyEnd);
|
||||
if (source[at] !== ":") throw new Error("app.json contains an invalid object field");
|
||||
const valueStart = skipJsonWhitespace(source, at + 1);
|
||||
const valueEnd = scanJsonValue(source, valueStart);
|
||||
memberCount++;
|
||||
if (key === fieldName) {
|
||||
if (match !== null) throw new Error(`app.json contains duplicate ${fieldName} fields`);
|
||||
match = { valueStart, valueEnd };
|
||||
}
|
||||
at = skipJsonWhitespace(source, valueEnd);
|
||||
if (source[at] === ",") at = skipJsonWhitespace(source, at + 1);
|
||||
else if (source[at] !== "}") throw new Error("app.json contains an invalid object separator");
|
||||
}
|
||||
return { match, close: at, memberCount };
|
||||
}
|
||||
|
||||
function jsonTopLevelIndent(source, close) {
|
||||
const firstKey = source.indexOf('"', source.indexOf("{") + 1);
|
||||
const key = firstKey >= 0 && firstKey < close ? firstKey : close;
|
||||
const lineStart = source.lastIndexOf("\n", key - 1) + 1;
|
||||
const indent = source.slice(lineStart, key);
|
||||
return /^[ \t]+$/.test(indent) ? indent : " ";
|
||||
}
|
||||
|
||||
function renderJsonValue(value, indent) {
|
||||
return JSON.stringify(value, null, 2).replaceAll("\n", `\n${indent}`);
|
||||
}
|
||||
|
||||
export function readServicePackages(source, format = "zon") {
|
||||
if (format === "json") {
|
||||
const entries = parseJsonManifest(source).service_packages ?? [];
|
||||
@@ -128,9 +211,15 @@ export function mergePackageSpecs(source, requested, format = "zon") {
|
||||
|
||||
export function replaceServicePackages(source, entries, format = "zon") {
|
||||
if (format === "json") {
|
||||
const manifest = parseJsonManifest(source);
|
||||
manifest.service_packages = entries;
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
parseJsonManifest(source);
|
||||
const field = jsonObjectField(source, "service_packages");
|
||||
const indent = jsonTopLevelIndent(source, field.close);
|
||||
const rendered = renderJsonValue(entries, indent);
|
||||
if (field.match) {
|
||||
return source.slice(0, field.match.valueStart) + rendered + source.slice(field.match.valueEnd);
|
||||
}
|
||||
const comma = field.memberCount === 0 ? "" : ",";
|
||||
return `${source.slice(0, field.close).trimEnd()}${comma}\n${indent}"service_packages": ${rendered}\n${source.slice(field.close)}`;
|
||||
}
|
||||
const rendered = [
|
||||
" .service_packages = .{",
|
||||
|
||||
@@ -592,18 +592,31 @@ test("incremental vendoring preserves prior package facts and lets explicit upda
|
||||
});
|
||||
|
||||
test("app.json vendoring preserves schema metadata and package facts", () => {
|
||||
const source = JSON.stringify({
|
||||
$schema: "https://schema.native-sdk.dev/app/v1.json",
|
||||
id: "dev.example.fixture",
|
||||
name: "fixture",
|
||||
version: "1.0.0",
|
||||
service_packages: [{ name: "alpha", version: "1.2.3", content_hash: "a".repeat(64) }],
|
||||
});
|
||||
const source = `{
|
||||
"$schema": "https://schema.native-sdk.dev/app/v1.json",
|
||||
"id": "dev.example.fixture",
|
||||
"name": "fixture",
|
||||
"version": "1.0.0",
|
||||
"assets": { "images": [{ "id": 9007199254740993, "path": "assets/cover.png" }] },
|
||||
"frontend": { "dev": { "url": "http://127.0.0.1:5173/", "timeout_ms": 1e3 } },
|
||||
"service_packages": [{ "name": "alpha", "version": "1.2.3", "content_hash": "${"a".repeat(64)}" }]
|
||||
}\n`;
|
||||
assert.deepEqual(mergePackageSpecs(source, ["beta@2.0.0"], "json"), ["alpha@1.2.3", "beta@2.0.0"]);
|
||||
const replaced = replaceServicePackages(source, [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }], "json");
|
||||
const parsed = JSON.parse(replaced);
|
||||
assert.equal(parsed.$schema, "https://schema.native-sdk.dev/app/v1.json");
|
||||
assert.deepEqual(readServicePackages(replaced, "json"), [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }]);
|
||||
assert.match(replaced, /"id": 9007199254740993/);
|
||||
assert.match(replaced, /"timeout_ms": 1e3/);
|
||||
|
||||
const inserted = replaceServicePackages(`{
|
||||
"id": "dev.example.fixture",
|
||||
"name": "fixture",
|
||||
"version": "1.0.0",
|
||||
"assets": { "images": [{ "id": 9007199254740993, "path": "assets/cover.png" }] }
|
||||
}\n`, [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }], "json");
|
||||
assert.match(inserted, /"id": 9007199254740993/);
|
||||
assert.deepEqual(readServicePackages(inserted, "json"), [{ name: "beta", version: "2.0.0", content_hash: "b".repeat(64) }]);
|
||||
});
|
||||
|
||||
test("service staging lowers tagged throws across the service-host graph", () => {
|
||||
|
||||
@@ -408,9 +408,9 @@
|
||||
"properties": {
|
||||
"volume_name": { "type": "string" },
|
||||
"background": { "type": "string" },
|
||||
"window_width": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 0, "maximum": 65535, "default": 128 },
|
||||
"window_width": { "type": "integer", "minimum": 320, "maximum": 2000, "default": 660 },
|
||||
"window_height": { "type": "integer", "minimum": 240, "maximum": 1400, "default": 400 },
|
||||
"icon_size": { "type": "integer", "minimum": 32, "maximum": 256, "default": 128 },
|
||||
"app_position": { "$ref": "#/$defs/position" },
|
||||
"applications_position": { "$ref": "#/$defs/position" },
|
||||
"applications_link": { "type": "boolean", "default": true },
|
||||
|
||||
Reference in New Issue
Block a user