@morit/cli 1.1.1 → 1.3.0

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/src/cli.js CHANGED
@@ -7,6 +7,7 @@ import { clearCredential, loadCredential, saveCredential } from "./secure-store.
7
7
  import {
8
8
  buildProjectDirectory,
9
9
  internal as sdkInternal,
10
+ previewProjectDirectory,
10
11
  readProjectDirectory,
11
12
  scaffoldProjectDirectory,
12
13
  validateProjectDirectory,
@@ -21,6 +22,7 @@ Usage:
21
22
  morit plugin setup [directory] --id <plugin.id> --name <name> --publisher <publisher>
22
23
  morit plugin add [directory]
23
24
  morit plugin validate [directory]
25
+ morit plugin preview [directory] [--output <file.html>]
24
26
  morit plugin build [directory] [--output <file.mplg>]
25
27
  morit plugin sync [directory] [--pull] [--force]
26
28
  morit plugin deploy [directory] [--visibility private|public] [--file-name <file.mplg>]
@@ -359,6 +361,13 @@ export async function runCli(argv, options = {}) {
359
361
  output(stdout, await validateProjectDirectory(root), json);
360
362
  return 0;
361
363
  }
364
+ if (action === "preview") {
365
+ output(stdout, await previewProjectDirectory(
366
+ root,
367
+ parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
368
+ ), json);
369
+ return 0;
370
+ }
362
371
  if (action === "build") {
363
372
  output(stdout, await buildProjectDirectory(root, {
364
373
  output: parsed.flags.output ? resolve(cwd, parsed.flags.output) : undefined,
package/src/preview.js ADDED
@@ -0,0 +1,132 @@
1
+ const HEX = /^#(?:[0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
2
+
3
+ const COLORS = {
4
+ light: {
5
+ primary: "#006B58", on_primary: "#FFFFFF", primary_container: "#7CF8D6",
6
+ on_primary_container: "#002019", secondary_container: "#CDE9DE",
7
+ on_secondary_container: "#072019", tertiary_container: "#C2E8FC",
8
+ on_tertiary_container: "#001F29", error_container: "#FFDAD6",
9
+ on_error_container: "#410002", surface: "#F5FBF8", on_surface: "#171D1B",
10
+ surface_variant: "#DBE5E0", on_surface_variant: "#3F4945",
11
+ outline: "#6F7975", outline_variant: "#BEC9C4",
12
+ },
13
+ dark: {
14
+ primary: "#5CDBBA", on_primary: "#00382D", primary_container: "#005142",
15
+ on_primary_container: "#7CF8D6", secondary_container: "#344C45",
16
+ on_secondary_container: "#CDE9DE", tertiary_container: "#264B5B",
17
+ on_tertiary_container: "#C2E8FC", error_container: "#93000A",
18
+ on_error_container: "#FFDAD6", surface: "#0F1513", on_surface: "#DFE4E1",
19
+ surface_variant: "#3F4945", on_surface_variant: "#BEC9C4",
20
+ outline: "#89938F", outline_variant: "#3F4945",
21
+ },
22
+ };
23
+
24
+ function escape(value) {
25
+ return String(value ?? "")
26
+ .replaceAll("&", "&amp;")
27
+ .replaceAll("<", "&lt;")
28
+ .replaceAll(">", "&gt;")
29
+ .replaceAll('"', "&quot;")
30
+ .replaceAll("'", "&#39;");
31
+ }
32
+
33
+ function cssColor(value) {
34
+ if (typeof value !== "string" || !HEX.test(value)) return null;
35
+ return value.length === 9 ? "#" + value.slice(3) + value.slice(1, 3) : value;
36
+ }
37
+
38
+ function themeVariables(config) {
39
+ const theme = config?.theme || {};
40
+ const values = [];
41
+ for (const mode of ["light", "dark"]) {
42
+ const variant = { ...theme, ...(theme[mode] || {}) };
43
+ const colors = { ...COLORS[mode], ...(theme.color_scheme || {}), ...(variant.color_scheme || {}) };
44
+ for (const [role, fallback] of Object.entries(COLORS[mode])) {
45
+ values.push("--" + mode + "-" + role.replaceAll("_", "-") + ":" + (cssColor(colors[role]) || fallback));
46
+ }
47
+ values.push("--" + mode + "-radius:" + Number(variant.border?.radius ?? variant.radius ?? 18) + "px");
48
+ values.push("--" + mode + "-space:" + Number(variant.spacing ?? 12) + "px");
49
+ values.push("--" + mode + "-scale:" + Number(variant.typography?.scale ?? 1));
50
+ }
51
+ return values.join(";");
52
+ }
53
+
54
+ function nodeHtml(node) {
55
+ if (!node || typeof node !== "object") return "";
56
+ const type = String(node.type || "surface");
57
+ const props = node.props && typeof node.props === "object" ? node.props : {};
58
+ const children = Array.isArray(node.children) ? node.children.map(nodeHtml).join("") : "";
59
+ const label = escape(props.label || props.title || type);
60
+ const action = node.action ? ' data-action="' + escape(node.action.type) + '"' : "";
61
+ if (type === "text") return '<p class="ui-text ' + escape(props.style) + '">' + escape(props.text) + "</p>";
62
+ if (type === "icon") return '<span class="ui-icon" role="img" aria-label="' + escape(props.semantic_label || props.icon) + '">' + escape(props.icon) + "</span>";
63
+ if (type === "image") return '<div class="ui-image" role="img" aria-label="' + escape(props.semantic_label || "image") + '">🖼 ' + escape(props.asset || props.url || "image") + "</div>";
64
+ if (type === "avatar") return '<span class="ui-avatar">' + escape(props.label || "M").slice(0, 1) + "</span>";
65
+ if (type === "badge") return '<span class="ui-badge">' + label + children + "</span>";
66
+ if (type === "divider") return "<hr>";
67
+ if (type === "spacer") return '<span class="ui-spacer" aria-hidden="true"></span>';
68
+ if (type === "button") return "<button" + action + ">" + (props.icon ? escape(props.icon) + " " : "") + label + "</button>";
69
+ if (type === "chip") return '<span class="ui-chip"' + action + ">" + label + "</span>";
70
+ if (type === "metric") return '<div class="ui-metric"><strong>' + escape(props.value) + "</strong><span>" + label + "</span></div>";
71
+ if (type === "progress") return '<div class="ui-progress"><span>' + label + '</span><progress value="' + Number(props.value ?? .5) + '" max="1"></progress></div>';
72
+ if (type === "field") return '<label class="ui-field">' + label + '<input disabled placeholder="' + escape(props.hint || props.placeholder) + '"></label>';
73
+ if (type === "select") return '<label class="ui-field">' + label + '<select disabled><option>' + escape(props.placeholder || "선택") + "</option></select></label>";
74
+ if (type === "switch") return '<label class="ui-switch"><input type="checkbox" disabled> ' + label + "</label>";
75
+ if (type === "empty") return '<div class="ui-empty"><strong>' + escape(props.title || "표시할 내용이 없어요.") + "</strong><p>" + escape(props.supporting) + "</p></div>";
76
+ if (["list", "timeline", "calendar", "chart", "table"].includes(type)) {
77
+ return '<section class="ui-collection"><small>' + escape(type + (props.source ? " · " + props.source : "")) + "</small>" + (children || '<p>' + escape(props.empty_text || "데이터가 연결되면 표시됩니다.") + "</p>") + "</section>";
78
+ }
79
+ if (["dialog", "sheet"].includes(type)) return '<details class="ui-surface"><summary>' + label + "</summary>" + children + "</details>";
80
+ const layout = ["row", "wrap", "grid", "stack", "column"].includes(type);
81
+ const surface = ["card", "surface", "section", "form"].includes(type);
82
+ const className = layout ? "ui-layout " + type : surface ? "ui-surface " + type : "ui-layout " + type;
83
+ const heading = surface && (props.title || props.label)
84
+ ? "<h3>" + label + "</h3>" + (props.subtitle ? "<p>" + escape(props.subtitle) + "</p>" : "")
85
+ : "";
86
+ return '<div class="' + className + '"' + action + ">" + heading + children + "</div>";
87
+ }
88
+
89
+ function chromeHtml(config) {
90
+ const appBar = config.app_bar;
91
+ const navigation = config.navigation;
92
+ const bar = appBar
93
+ ? '<header class="app-bar"><div><strong>' + escape(appBar.title) + "</strong>" +
94
+ (appBar.subtitle ? "<small>" + escape(appBar.subtitle) + "</small>" : "") +
95
+ '</div><div class="app-actions">' + (appBar.actions || []).map((item) => "<button>" + escape(item.icon || item.label) + "</button>").join("") + "</div></header>"
96
+ : "";
97
+ const nav = navigation
98
+ ? '<nav class="ui-nav" aria-label="Plugin navigation">' + navigation.items.map((item) => '<span class="ui-chip">' + escape(item.icon ? item.icon + " " + item.label : item.label) + "</span>").join("") + "</nav>"
99
+ : "";
100
+ return bar + nav;
101
+ }
102
+
103
+ const CSS = [
104
+ "*{box-sizing:border-box}body{margin:0;font:15px/1.5 system-ui,sans-serif;background:#0d1211}",
105
+ ".theme-toggle{position:fixed;right:18px;top:16px;z-index:3;padding:9px 13px;border-radius:999px;background:#fff;color:#17211f;box-shadow:0 4px 18px #0003;cursor:pointer}",
106
+ "#preview-dark{position:fixed;opacity:0}.preview{min-height:100vh;padding:72px 20px 56px;background:#eef3f1}",
107
+ ".preview-head{max-width:980px;margin:0 auto 22px}.preview-head h1{margin:0}.screens{display:grid;gap:24px;max-width:980px;margin:auto}",
108
+ ".screen{--primary:var(--light-primary);--on-primary:var(--light-on-primary);--primary-container:var(--light-primary-container);--on-primary-container:var(--light-on-primary-container);--secondary-container:var(--light-secondary-container);--on-secondary-container:var(--light-on-secondary-container);--tertiary-container:var(--light-tertiary-container);--on-tertiary-container:var(--light-on-tertiary-container);--error-container:var(--light-error-container);--on-error-container:var(--light-on-error-container);--surface:var(--light-surface);--on-surface:var(--light-on-surface);--surface-variant:var(--light-surface-variant);--on-surface-variant:var(--light-on-surface-variant);--outline:var(--light-outline);--outline-variant:var(--light-outline-variant);--radius:var(--light-radius);--space:var(--light-space);--scale:var(--light-scale);overflow:hidden;border:1px solid var(--outline-variant);border-radius:calc(var(--radius) + 6px);background:var(--surface);color:var(--on-surface);font-size:calc(1rem * var(--scale));box-shadow:0 12px 34px #0002}",
109
+ "#preview-dark:checked~.preview{background:#090d0c;color:#e1e5e2}#preview-dark:checked~.preview .screen{--primary:var(--dark-primary);--on-primary:var(--dark-on-primary);--primary-container:var(--dark-primary-container);--on-primary-container:var(--dark-on-primary-container);--secondary-container:var(--dark-secondary-container);--on-secondary-container:var(--dark-on-secondary-container);--tertiary-container:var(--dark-tertiary-container);--on-tertiary-container:var(--dark-on-tertiary-container);--error-container:var(--dark-error-container);--on-error-container:var(--dark-on-error-container);--surface:var(--dark-surface);--on-surface:var(--dark-on-surface);--surface-variant:var(--dark-surface-variant);--on-surface-variant:var(--dark-on-surface-variant);--outline:var(--dark-outline);--outline-variant:var(--dark-outline-variant);--radius:var(--dark-radius);--space:var(--dark-space);--scale:var(--dark-scale)}",
110
+ ".screen-meta,.app-bar,.screen-body,.ui-nav{padding:var(--space)}.screen-meta{display:flex;gap:8px;background:var(--surface-variant);color:var(--on-surface-variant)}.screen-meta span{margin-right:auto}.app-bar{display:flex;align-items:center;border-bottom:1px solid var(--outline-variant)}.app-bar div:first-child{display:grid}.app-bar small{color:var(--on-surface-variant)}.app-actions{margin-left:auto;display:flex;gap:6px}",
111
+ ".screen-body,.ui-layout,.ui-surface,.ui-collection{display:flex;flex-direction:column;gap:var(--space)}.ui-layout.row{flex-direction:row;align-items:center}.ui-layout.wrap,.ui-nav{display:flex;flex-flow:row wrap;gap:8px}.ui-layout.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr))}.ui-surface,.ui-collection,.ui-empty{padding:var(--space);border:1px solid var(--outline-variant);border-radius:var(--radius);background:color-mix(in srgb,var(--surface),var(--primary-container) 9%)}",
112
+ "h3,p{margin:0}.ui-text.heading{font-size:1.25em;font-weight:750}.ui-text.caption,small{color:var(--on-surface-variant)}button,.ui-chip{border:0;border-radius:999px;padding:8px 13px;background:var(--primary-container);color:var(--on-primary-container);font:inherit}.ui-icon{font:600 .75em monospace;color:var(--primary)}.ui-image{min-height:90px;display:grid;place-items:center;border-radius:var(--radius);background:var(--surface-variant);color:var(--on-surface-variant)}.ui-avatar{display:grid;place-items:center;width:42px;height:42px;border-radius:50%;background:var(--primary);color:var(--on-primary)}.ui-badge{padding:3px 7px;border-radius:99px;background:var(--error-container);color:var(--on-error-container)}.ui-metric{display:grid;padding:12px;border-radius:var(--radius);background:var(--secondary-container);color:var(--on-secondary-container)}.ui-metric strong{font-size:1.35em}.ui-field{display:grid;gap:5px}input,select{width:100%;padding:11px;border:1px solid var(--outline);border-radius:var(--radius);background:var(--surface);color:var(--on-surface)}progress{accent-color:var(--primary);width:100%}hr{width:100%;border:0;border-top:1px solid var(--outline-variant)}.ui-spacer{min-height:12px}.ui-nav{border-top:1px solid var(--outline-variant)}",
113
+ ].join("");
114
+
115
+ export function renderPreviewHtml(manifest) {
116
+ const extensions = manifest.ui_extensions.filter((item) => item.config?.ui_schema === 2);
117
+ const screens = extensions.length
118
+ ? extensions.map((extension) => {
119
+ const config = extension.config;
120
+ return '<article class="screen" style="' + themeVariables(config) + '">' +
121
+ '<div class="screen-meta"><span>' + escape(extension.point) + "</span><strong>" + escape(extension.title) + "</strong></div>" +
122
+ chromeHtml(config) + '<main class="screen-body">' + nodeHtml(config.view) + "</main></article>";
123
+ }).join("")
124
+ : '<article class="screen"><div class="ui-empty"><strong>UI extension 없음</strong><p>UI Runtime v2 화면을 추가하면 여기에 표시됩니다.</p></div></article>';
125
+ const title = escape(manifest.name);
126
+ const content = '<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>' +
127
+ title + " preview</title><style>" + CSS +
128
+ '</style></head><body><input id="preview-dark" type="checkbox"><label class="theme-toggle" for="preview-dark">☀︎ / ☾</label><div class="preview"><header class="preview-head"><small>Morit UI Runtime v2 · 안전한 계약 Preview</small><h1>' +
129
+ title + "</h1><p>" + escape(manifest.description || "") +
130
+ '</p></header><section class="screens">' + screens + "</section></div></body></html>";
131
+ return Buffer.from(content, "utf8");
132
+ }
package/src/workspace.js CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  signEntries,
23
23
  verifySignedEntries,
24
24
  } from "./archive.js";
25
+ import { renderPreviewHtml } from "./preview.js";
25
26
 
26
27
  const contract = JSON.parse(
27
28
  readFileSync(new URL("../assets/plugin_contract.json", import.meta.url), "utf8"),
@@ -63,6 +64,10 @@ const CONNECTOR_FIELDS = new Set(contract.object_fields.connector);
63
64
  const DEPENDENCY_FIELDS = new Set(contract.object_fields.dependency);
64
65
  const DEVELOPER_FIELDS = new Set(contract.object_fields.developer);
65
66
  const REQUIRED_SECRET_FIELDS = new Set(contract.object_fields.required_secret);
67
+ const STORAGE_FIELDS = new Set(contract.object_fields.storage);
68
+ const STORAGE_NAMESPACE_FIELDS = new Set(contract.object_fields.storage_namespace);
69
+ const STORAGE_MIGRATION_FIELDS = new Set(contract.object_fields.storage_migration);
70
+ const STORAGE_TOOLS = new Set(contract.storage_tools);
66
71
  const CAPABILITY_RUNTIME_SHORTHAND = contract.capability_runtime_shorthand;
67
72
  const UI_POINTS = new Set(contract.ui_points);
68
73
  const RUNTIME_ADAPTERS = new Set(contract.runtime_adapters);
@@ -147,7 +152,7 @@ export class LocalWorkspace {
147
152
  version: "1.0.0",
148
153
  cloud_project_id: null,
149
154
  required_secrets: [],
150
- min_morit_version: "1.7.6",
155
+ min_morit_version: "1.7.8",
151
156
  max_morit_version: "1.999.999",
152
157
  permissions: [],
153
158
  capabilities: [],
@@ -269,6 +274,7 @@ export class LocalWorkspace {
269
274
  revision: project.revision,
270
275
  valid: true,
271
276
  message: `validated ${manifest.id} ${manifest.version} (${packageEntries.size} package entries)`,
277
+ warnings: collectThemeWarnings(manifest),
272
278
  workspace_path: project.workspace_path,
273
279
  };
274
280
  }
@@ -515,6 +521,7 @@ export async function verifyPackageFile(packagePath) {
515
521
  size_bytes: content.length,
516
522
  sha256: sha256(content),
517
523
  signature: verified.signature,
524
+ warnings: collectThemeWarnings(verified.manifest),
518
525
  };
519
526
  }
520
527
 
@@ -535,6 +542,7 @@ function projectDirectoryResult({ root, manifest, entries }) {
535
542
  publisher: manifest.publisher,
536
543
  version: manifest.version,
537
544
  package_entries: entries.size + 1,
545
+ warnings: collectThemeWarnings(manifest),
538
546
  };
539
547
  }
540
548
 
@@ -719,14 +727,10 @@ async function signingKeyForPublisher(keyDirectory, publisher) {
719
727
  }
720
728
  }
721
729
 
722
- function previewHtml(manifest) {
723
- const serialized = JSON.stringify(manifest, null, 2).replaceAll("<", "\\u003c");
724
- return Buffer.from(
725
- `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(manifest.name)} preview</title><style>body{font:15px system-ui;margin:0;background:#f5f7fb;color:#182033}.wrap{max-width:960px;margin:auto;padding:32px}.card{background:white;border-radius:18px;padding:24px;box-shadow:0 8px 30px #17203a18}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#101727;color:#e8eefc;padding:18px;border-radius:12px}</style></head><body><main class="wrap"><section class="card"><h1>${escapeHtml(manifest.name)}</h1><p>${escapeHtml(manifest.description || "")}</p><p>${manifest.capabilities.length} capabilities · ${manifest.ui_extensions.length} UI extensions</p><pre>${escapeHtml(serialized)}</pre></section></main></body></html>`,
726
- "utf8",
727
- );
728
- }
729
-
730
+ function previewHtml(manifest) {
731
+ return renderPreviewHtml(manifest);
732
+ }
733
+
730
734
  function validateMetadata(pluginId, name, publisher, description) {
731
735
  if (!PLUGIN_ID.test(pluginId) || pluginId.length > 120) throw new Error("plugin_id must be a reverse-domain lowercase identifier");
732
736
  if (typeof name !== "string" || name.trim().length < 1 || name.trim().length > 80) throw new Error("name must contain 1 to 80 characters");
@@ -790,6 +794,7 @@ function validateManifest(manifest, files) {
790
794
  for (const capability of capabilities) {
791
795
  rejectUnknownFields(capability, CAPABILITY_FIELDS, "capability");
792
796
  requireUniqueIdentifier(capability.id, capabilityIds, "capability");
797
+ if (STORAGE_TOOLS.has(capability.id)) throw new Error(`capability ${capability.id} is reserved by Morit`);
793
798
  capabilityIds.add(capability.id);
794
799
  if (!CAPABILITY_KINDS.has(capability.kind)) throw new Error(`unknown capability kind: ${capability.kind}`);
795
800
  requireText(capability.title, 80, `capability ${capability.id} title`);
@@ -832,7 +837,8 @@ function validateManifest(manifest, files) {
832
837
  // level before semantic UI validation runs.
833
838
  assertJsonSize(extensionConfig, 32 * 1024, `UI ${extension.id} config`, 40);
834
839
  }
835
- for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds, files);
840
+ const storage = validateStorage(manifest, requestedPermissions);
841
+ for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds, files, storage !== null);
836
842
 
837
843
  const credentialIds = validateCredentials(credentials, requestedPermissions);
838
844
  const secretIds = validateRequiredSecrets(requiredSecrets);
@@ -879,6 +885,79 @@ function validatePermissions(values, label) {
879
885
  return new Set(values);
880
886
  }
881
887
 
888
+ function validateStorage(manifest, permissions) {
889
+ if (manifest.storage === undefined) {
890
+ if (permissions.has("ai_storage")) throw new Error("ai_storage requires manifest.storage");
891
+ return permissions.has("storage") ? { version: 1, namespaces: [{ id: "default", max_bytes: 128 * 1024, ai_access: "none" }], migrations: [] } : null;
892
+ }
893
+ if (manifest.schema_version !== 2 || !permissions.has("storage")) {
894
+ throw new Error("manifest.storage requires schema 2 and storage permission");
895
+ }
896
+ const storage = manifest.storage;
897
+ assertObject(storage, "manifest.storage");
898
+ rejectUnknownFields(storage, STORAGE_FIELDS, "manifest.storage");
899
+ if (!Number.isInteger(storage.version) || storage.version < 1 || storage.version > 1000) {
900
+ throw new Error("manifest.storage.version is invalid");
901
+ }
902
+ const namespaces = objectCollection(storage.namespaces, "manifest.storage.namespaces", 16);
903
+ if (!namespaces.length) throw new Error("manifest.storage.namespaces must not be empty");
904
+ const namespaceIds = new Set();
905
+ let hasAiAccess = false;
906
+ for (const namespace of namespaces) {
907
+ rejectUnknownFields(namespace, STORAGE_NAMESPACE_FIELDS, "storage namespace");
908
+ requireUniqueIdentifier(namespace.id, namespaceIds, "storage namespace");
909
+ namespaceIds.add(namespace.id);
910
+ const maxBytes = namespace.max_bytes === undefined ? 128 * 1024 : namespace.max_bytes;
911
+ if (!Number.isInteger(maxBytes) || maxBytes < 1024 || maxBytes > 256 * 1024) {
912
+ throw new Error(`storage namespace ${namespace.id} max_bytes is invalid`);
913
+ }
914
+ const aiAccess = namespace.ai_access === undefined ? "none" : namespace.ai_access;
915
+ if (!contract.storage_ai_access.includes(aiAccess)) throw new Error(`storage namespace ${namespace.id} ai_access is invalid`);
916
+ hasAiAccess ||= aiAccess !== "none";
917
+ }
918
+ const migrations = objectCollection(storage.migrations === undefined ? [] : storage.migrations, "manifest.storage.migrations", 64);
919
+ const migrationSteps = new Set();
920
+ for (const migration of migrations) {
921
+ rejectUnknownFields(migration, STORAGE_MIGRATION_FIELDS, "storage migration");
922
+ if (!Number.isInteger(migration.from) || migration.from < 1 || migration.to !== migration.from + 1 || migration.to > storage.version) {
923
+ throw new Error("storage migration version is invalid");
924
+ }
925
+ if (!namespaceIds.has(migration.namespace)) throw new Error("storage migration namespace is unknown");
926
+ const step = `${migration.from}\0${migration.namespace}`;
927
+ if (migrationSteps.has(step)) throw new Error("storage migration step is duplicated");
928
+ migrationSteps.add(step);
929
+ const rename = migration.rename === undefined ? {} : migration.rename;
930
+ assertObject(rename, "storage migration rename");
931
+ const deleted = migration.delete === undefined ? [] : migration.delete;
932
+ if (Object.keys(rename).length > 32 || !Array.isArray(deleted) || deleted.length > 32 || new Set(deleted).size !== deleted.length) {
933
+ throw new Error("storage migration operations are invalid");
934
+ }
935
+ const targets = new Set();
936
+ for (const [source, target] of Object.entries(rename)) {
937
+ validateStorageKey(source);
938
+ validateStorageKey(target);
939
+ if (source === target || targets.has(target) || deleted.includes(source)) throw new Error("storage migration rename is invalid");
940
+ targets.add(target);
941
+ }
942
+ for (const key of deleted) validateStorageKey(key);
943
+ }
944
+ if (permissions.has("ai_storage") !== hasAiAccess) {
945
+ throw new Error("AI-accessible storage and ai_storage permission must be declared together");
946
+ }
947
+ return storage;
948
+ }
949
+
950
+ function validateStorageKey(value) {
951
+ if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/.test(value) || value.startsWith("_")) {
952
+ throw new Error("storage key is invalid");
953
+ }
954
+ const compact = value.toLowerCase().replace(/[^a-z0-9]/g, "");
955
+ if (/(^|[_.-])(authorization|credential|password|secret|token|api[_-]?key|private[_-]?key)($|[_.-])/i.test(value)
956
+ || ["authorization", "credential", "credentials", "password", "secret", "token", "accesstoken", "refreshtoken", "apikey", "privatekey"].includes(compact)) {
957
+ throw new Error("credential-shaped storage keys are not allowed");
958
+ }
959
+ }
960
+
882
961
  function assertObject(value, label) {
883
962
  if (!value || typeof value !== "object" || Array.isArray(value)) {
884
963
  throw new Error(`${label} must be an object`);
@@ -1160,12 +1239,15 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
1160
1239
  }
1161
1240
  }
1162
1241
 
1163
- function validateUiConfig(extension, capabilities, routeIds, files) {
1242
+ function validateUiConfig(extension, capabilities, routeIds, files, hasStorage = false) {
1164
1243
  const config = extension.config === undefined ? {} : extension.config;
1165
1244
  const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
1245
+ if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
1246
+ if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
1166
1247
  if (config.ui_schema === 2) {
1167
1248
  if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1168
1249
  validateUiRuntimeV2(config, executable, routeIds);
1250
+ validateResponseComponent(config, extension.point === "response");
1169
1251
  for (const asset of uiRuntimeAssetPaths(config.view)) {
1170
1252
  if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
1171
1253
  }
@@ -1199,6 +1281,72 @@ function validateUiConfig(extension, capabilities, routeIds, files) {
1199
1281
  if (config.form !== undefined) validateLegacyUiForm(config.form, executable, extension.id);
1200
1282
  }
1201
1283
 
1284
+ function validateResponseComponent(config, responsePoint) {
1285
+ if (!responsePoint) {
1286
+ if (Object.hasOwn(config, "a2ui")) throw new Error("A2UI metadata is only valid for response UI");
1287
+ return;
1288
+ }
1289
+ const value = config.a2ui;
1290
+ assertObject(value, "response A2UI metadata");
1291
+ rejectUnknownFields(value, new Set(["version", "description", "schema"]), "response A2UI metadata");
1292
+ if (Object.keys(value).length !== 3 || value.version !== "v0.9") throw new Error("response UI requires complete A2UI v0.9 metadata");
1293
+ requireText(value.description, 500, "response A2UI description");
1294
+ assertObject(value.schema, "response A2UI schema");
1295
+ if (Buffer.byteLength(JSON.stringify(value.schema), "utf8") > 16 * 1024) throw new Error("response A2UI schema is too large");
1296
+ validateResponseSchema(value.schema, 0, true);
1297
+ }
1298
+
1299
+ function validateResponseSchema(schema, depth, root = false) {
1300
+ const fields = new Set([
1301
+ "type", "properties", "required", "additionalProperties", "items", "enum",
1302
+ "minItems", "maxItems", "minLength", "maxLength", "minimum", "maximum",
1303
+ ]);
1304
+ if (depth > 6) throw new Error("response A2UI schema is too deep");
1305
+ rejectUnknownFields(schema, fields, "response A2UI schema");
1306
+ const types = new Set(["object", "array", "string", "number", "integer", "boolean", "null"]);
1307
+ if (!types.has(schema.type) || (root && schema.type !== "object")) throw new Error("response A2UI schema type is invalid");
1308
+ if (schema.enum !== undefined) {
1309
+ if (!Array.isArray(schema.enum) || schema.enum.length < 1 || schema.enum.length > 32 || schema.enum.some((item) => item !== null && typeof item === "object")) throw new Error("response A2UI schema enum is invalid");
1310
+ for (const item of schema.enum) validateUiJson(item, 0);
1311
+ }
1312
+ const byType = {
1313
+ object: new Set(["properties", "required", "additionalProperties"]),
1314
+ array: new Set(["items", "minItems", "maxItems"]),
1315
+ string: new Set(["minLength", "maxLength"]),
1316
+ number: new Set(["minimum", "maximum"]),
1317
+ integer: new Set(["minimum", "maximum"]),
1318
+ boolean: new Set(),
1319
+ null: new Set(),
1320
+ }[schema.type];
1321
+ if (Object.keys(schema).some((key) => !["type", "enum"].includes(key) && !byType.has(key))) throw new Error("response A2UI schema keyword does not match its type");
1322
+ if (schema.type === "object") {
1323
+ const properties = schema.properties === undefined ? {} : schema.properties;
1324
+ const required = schema.required === undefined ? [] : schema.required;
1325
+ const additional = schema.additionalProperties === undefined ? true : schema.additionalProperties;
1326
+ assertObject(properties, "response A2UI properties");
1327
+ if (Object.keys(properties).length > 64 || !Array.isArray(required) || required.length > 64 || new Set(required).size !== required.length || typeof additional !== "boolean") throw new Error("response A2UI object schema is invalid");
1328
+ for (const [key, child] of Object.entries(properties)) {
1329
+ if (!UI_STATE_KEY.test(key)) throw new Error("response A2UI property is invalid");
1330
+ assertObject(child, "response A2UI property schema");
1331
+ validateResponseSchema(child, depth + 1);
1332
+ }
1333
+ if (required.some((key) => typeof key !== "string" || !Object.hasOwn(properties, key))) throw new Error("response A2UI required property is invalid");
1334
+ } else if (schema.type === "array") {
1335
+ assertObject(schema.items, "response A2UI item schema");
1336
+ const minimum = schema.minItems === undefined ? 0 : schema.minItems;
1337
+ const maximum = schema.maxItems === undefined ? 64 : schema.maxItems;
1338
+ if (!Number.isInteger(minimum) || !Number.isInteger(maximum) || minimum < 0 || minimum > maximum || maximum > 64) throw new Error("response A2UI array schema is invalid");
1339
+ validateResponseSchema(schema.items, depth + 1);
1340
+ } else if (schema.type === "string") {
1341
+ const minimum = schema.minLength === undefined ? 0 : schema.minLength;
1342
+ const maximum = schema.maxLength === undefined ? 2000 : schema.maxLength;
1343
+ if (!Number.isInteger(minimum) || !Number.isInteger(maximum) || minimum < 0 || minimum > maximum || maximum > 2000) throw new Error("response A2UI string schema is invalid");
1344
+ } else if (["number", "integer"].includes(schema.type)) {
1345
+ for (const bound of [schema.minimum, schema.maximum]) if (bound !== undefined && (typeof bound !== "number" || !Number.isFinite(bound))) throw new Error("response A2UI number bound is invalid");
1346
+ if (schema.minimum !== undefined && schema.maximum !== undefined && schema.minimum > schema.maximum) throw new Error("response A2UI number range is invalid");
1347
+ }
1348
+ }
1349
+
1202
1350
  function validateLegacyUiForm(form, executable, extensionId) {
1203
1351
  rejectUnknownFields(form, new Set(["submit_capability", "submit_label", "fields"]), `UI ${extensionId} form`);
1204
1352
  if (!executable.has(form.submit_capability)) throw new Error(`UI ${extensionId} form targets an unknown capability`);
@@ -1283,9 +1431,9 @@ function validateUiRuntimeV2(config, executable, routes) {
1283
1431
  validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
1284
1432
  }
1285
1433
 
1286
- function validateUiTheme(theme) {
1434
+ function validateUiTheme(theme, variant = false) {
1287
1435
  if (theme == null) return;
1288
- rejectUnknownFields(theme, new Set(contract.ui_runtime.theme_fields), "UI theme");
1436
+ rejectUnknownFields(theme, new Set(variant ? contract.ui_runtime.theme_variant_fields : contract.ui_runtime.theme_fields), "UI theme");
1289
1437
  const colors = theme.color_scheme === undefined ? {} : theme.color_scheme;
1290
1438
  assertObject(colors, "UI color scheme");
1291
1439
  const roles = new Set([...UI_COLOR_TOKENS].filter((value) => value !== "transparent"));
@@ -1297,6 +1445,79 @@ function validateUiTheme(theme) {
1297
1445
  if (theme.spacing !== undefined) validateUiNumber(theme.spacing, "UI theme spacing", 0, 32);
1298
1446
  const density = theme.density === undefined ? "standard" : theme.density;
1299
1447
  if (!["compact", "standard", "comfortable"].includes(density)) throw new Error("UI theme density is invalid");
1448
+ const typography = theme.typography === undefined ? {} : theme.typography;
1449
+ rejectUnknownFields(typography, new Set(contract.ui_runtime.theme_typography_fields), "UI theme typography");
1450
+ if (typography.scale !== undefined) validateUiNumber(typography.scale, "UI theme typography scale", 0.8, 1.4);
1451
+ for (const key of ["body_weight", "title_weight"]) {
1452
+ if (typography[key] !== undefined && ![400, 500, 600, 700].includes(typography[key])) throw new Error("UI theme typography weight is invalid");
1453
+ }
1454
+ validateThemeComponent(theme.surface, contract.ui_runtime.theme_surface_fields, ["color", "container_color"], { elevation: [0, 24] }, "surface");
1455
+ validateThemeComponent(theme.border, contract.ui_runtime.theme_border_fields, ["color"], { width: [0, 8], radius: [0, 64] }, "border");
1456
+ validateThemeComponent(theme.icon, contract.ui_runtime.theme_icon_fields, ["color"], { size: [12, 64] }, "icon");
1457
+ validateThemeComponent(theme.states, contract.ui_runtime.theme_state_fields, ["selected_color", "focus_color"], { disabled_opacity: [0.2, 0.8] }, "states");
1458
+ if (!variant) for (const mode of ["light", "dark"]) if (theme[mode] !== undefined) validateUiTheme(theme[mode], true);
1459
+ validateThemeContrast(theme);
1460
+ }
1461
+
1462
+ function validateThemeComponent(value, fields, colors, numbers, label) {
1463
+ if (value === undefined) return;
1464
+ rejectUnknownFields(value, new Set(fields), `UI theme ${label}`);
1465
+ for (const key of colors) if (value[key] !== undefined && (typeof value[key] !== "string" || !UI_HEX_COLOR.test(value[key]))) throw new Error(`UI theme ${label} color is invalid`);
1466
+ for (const [key, [minimum, maximum]] of Object.entries(numbers)) if (value[key] !== undefined) validateUiNumber(value[key], `UI theme ${label} ${key}`, minimum, maximum);
1467
+ }
1468
+
1469
+ function hexRgb(value) {
1470
+ if (typeof value !== "string" || !UI_HEX_COLOR.test(value)) return null;
1471
+ let raw = value.slice(1);
1472
+ if (raw.length === 8) {
1473
+ if (Number.parseInt(raw.slice(0, 2), 16) < 242) return null;
1474
+ raw = raw.slice(2);
1475
+ }
1476
+ return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16) / 255);
1477
+ }
1478
+
1479
+ function contrastRatio(first, second) {
1480
+ const values = [hexRgb(first), hexRgb(second)];
1481
+ if (values.some((value) => value === null)) return null;
1482
+ const luminance = (rgb) => {
1483
+ const [r, g, b] = rgb.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
1484
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1485
+ };
1486
+ const [one, two] = values.map(luminance);
1487
+ return (Math.max(one, two) + 0.05) / (Math.min(one, two) + 0.05);
1488
+ }
1489
+
1490
+ const THEME_CONTRAST_PAIRS = [
1491
+ ["primary", "on_primary"], ["primary_container", "on_primary_container"],
1492
+ ["secondary", "on_secondary"], ["secondary_container", "on_secondary_container"],
1493
+ ["tertiary", "on_tertiary"], ["error", "on_error"],
1494
+ ["error_container", "on_error_container"], ["surface", "on_surface"],
1495
+ ["surface_variant", "on_surface_variant"],
1496
+ ];
1497
+
1498
+ function validateThemeContrast(theme) {
1499
+ const colors = theme.color_scheme || {};
1500
+ for (const [background, foreground] of THEME_CONTRAST_PAIRS) {
1501
+ const ratio = contrastRatio(colors[background], colors[foreground]);
1502
+ if (ratio !== null && ratio < 1.5) throw new Error(`UI theme ${foreground} is not visible on ${background}`);
1503
+ }
1504
+ }
1505
+
1506
+ function collectThemeWarnings(manifest) {
1507
+ const warnings = [];
1508
+ for (const extension of manifest.ui_extensions || []) {
1509
+ const theme = extension?.config?.theme;
1510
+ if (!theme || typeof theme !== "object" || Array.isArray(theme)) continue;
1511
+ for (const [mode, variant] of [["base", theme], ["light", theme.light], ["dark", theme.dark]]) {
1512
+ if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
1513
+ const colors = variant.color_scheme || {};
1514
+ for (const [background, foreground] of [["primary", "on_primary"], ["surface", "on_surface"]]) {
1515
+ const ratio = contrastRatio(colors[background], colors[foreground]);
1516
+ if (ratio !== null && ratio < 4.5) warnings.push(`${extension.id} ${mode} theme ${foreground}/${background} contrast is ${ratio.toFixed(2)}:1; verify text accessibility`);
1517
+ }
1518
+ }
1519
+ }
1520
+ return warnings;
1300
1521
  }
1301
1522
 
1302
1523
  function validateUiAppBar(appBar, executable, routes, sources, stateKeys) {
@@ -1385,7 +1606,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
1385
1606
  validateUiAction(node.action, executable, routes, sources, stateKeys);
1386
1607
  const children = node.children === undefined ? [] : node.children;
1387
1608
  if (!Array.isArray(children) || children.length > 32) throw new Error("UI component children are invalid");
1388
- const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "field", "select", "switch", "empty"]);
1609
+ const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "table", "field", "select", "switch", "empty"]);
1389
1610
  if (leaves.has(node.type) && children.length) throw new Error("leaf UI component cannot have children");
1390
1611
  if (["list", "timeline"].includes(node.type) && children.length !== 1) throw new Error("list UI component requires one item template");
1391
1612
  if (["positioned", "scroll", "padding", "center", "expanded", "badge"].includes(node.type) && children.length !== 1) throw new Error("UI component requires exactly one child");
@@ -1460,11 +1681,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1460
1681
  if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
1461
1682
  if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
1462
1683
  if (nodeType !== "scroll" && ["scroll_direction", "shrink_wrap"].some((key) => props[key] !== undefined)) throw new Error("scroll properties require a scroll UI component");
1463
- if (["list", "timeline", "calendar", "chart"].includes(nodeType)) {
1684
+ if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType)) {
1464
1685
  if (typeof props.source !== "string" || !UI_BINDING.test(props.source) || !props.source.startsWith("data.") || !sources.has(props.source.split(".")[1])) throw new Error("UI list source is invalid");
1465
1686
  }
1466
1687
  const chartType = props.chart_type === undefined ? "bar" : props.chart_type;
1467
- if (nodeType === "chart" && !["bar", "line", "donut"].includes(chartType)) throw new Error("UI chart type is invalid");
1688
+ if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
1468
1689
  if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
1469
1690
  if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
1470
1691
  if (props.options != null) {
@@ -1832,6 +2053,7 @@ function escapeHtml(value) {
1832
2053
 
1833
2054
  export const internal = {
1834
2055
  artifactFileName,
2056
+ collectThemeWarnings,
1835
2057
  compileManifest,
1836
2058
  normalizeEntrypointPath,
1837
2059
  projectFileBytes,