@morit/cli 1.1.0 → 1.2.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/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,9 +1239,10 @@ 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);
1166
1246
  if (config.ui_schema === 2) {
1167
1247
  if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
1168
1248
  validateUiRuntimeV2(config, executable, routeIds);
@@ -1283,9 +1363,9 @@ function validateUiRuntimeV2(config, executable, routes) {
1283
1363
  validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
1284
1364
  }
1285
1365
 
1286
- function validateUiTheme(theme) {
1366
+ function validateUiTheme(theme, variant = false) {
1287
1367
  if (theme == null) return;
1288
- rejectUnknownFields(theme, new Set(contract.ui_runtime.theme_fields), "UI theme");
1368
+ rejectUnknownFields(theme, new Set(variant ? contract.ui_runtime.theme_variant_fields : contract.ui_runtime.theme_fields), "UI theme");
1289
1369
  const colors = theme.color_scheme === undefined ? {} : theme.color_scheme;
1290
1370
  assertObject(colors, "UI color scheme");
1291
1371
  const roles = new Set([...UI_COLOR_TOKENS].filter((value) => value !== "transparent"));
@@ -1297,6 +1377,79 @@ function validateUiTheme(theme) {
1297
1377
  if (theme.spacing !== undefined) validateUiNumber(theme.spacing, "UI theme spacing", 0, 32);
1298
1378
  const density = theme.density === undefined ? "standard" : theme.density;
1299
1379
  if (!["compact", "standard", "comfortable"].includes(density)) throw new Error("UI theme density is invalid");
1380
+ const typography = theme.typography === undefined ? {} : theme.typography;
1381
+ rejectUnknownFields(typography, new Set(contract.ui_runtime.theme_typography_fields), "UI theme typography");
1382
+ if (typography.scale !== undefined) validateUiNumber(typography.scale, "UI theme typography scale", 0.8, 1.4);
1383
+ for (const key of ["body_weight", "title_weight"]) {
1384
+ if (typography[key] !== undefined && ![400, 500, 600, 700].includes(typography[key])) throw new Error("UI theme typography weight is invalid");
1385
+ }
1386
+ validateThemeComponent(theme.surface, contract.ui_runtime.theme_surface_fields, ["color", "container_color"], { elevation: [0, 24] }, "surface");
1387
+ validateThemeComponent(theme.border, contract.ui_runtime.theme_border_fields, ["color"], { width: [0, 8], radius: [0, 64] }, "border");
1388
+ validateThemeComponent(theme.icon, contract.ui_runtime.theme_icon_fields, ["color"], { size: [12, 64] }, "icon");
1389
+ validateThemeComponent(theme.states, contract.ui_runtime.theme_state_fields, ["selected_color", "focus_color"], { disabled_opacity: [0.2, 0.8] }, "states");
1390
+ if (!variant) for (const mode of ["light", "dark"]) if (theme[mode] !== undefined) validateUiTheme(theme[mode], true);
1391
+ validateThemeContrast(theme);
1392
+ }
1393
+
1394
+ function validateThemeComponent(value, fields, colors, numbers, label) {
1395
+ if (value === undefined) return;
1396
+ rejectUnknownFields(value, new Set(fields), `UI theme ${label}`);
1397
+ 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`);
1398
+ for (const [key, [minimum, maximum]] of Object.entries(numbers)) if (value[key] !== undefined) validateUiNumber(value[key], `UI theme ${label} ${key}`, minimum, maximum);
1399
+ }
1400
+
1401
+ function hexRgb(value) {
1402
+ if (typeof value !== "string" || !UI_HEX_COLOR.test(value)) return null;
1403
+ let raw = value.slice(1);
1404
+ if (raw.length === 8) {
1405
+ if (Number.parseInt(raw.slice(0, 2), 16) < 242) return null;
1406
+ raw = raw.slice(2);
1407
+ }
1408
+ return [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16) / 255);
1409
+ }
1410
+
1411
+ function contrastRatio(first, second) {
1412
+ const values = [hexRgb(first), hexRgb(second)];
1413
+ if (values.some((value) => value === null)) return null;
1414
+ const luminance = (rgb) => {
1415
+ const [r, g, b] = rgb.map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
1416
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
1417
+ };
1418
+ const [one, two] = values.map(luminance);
1419
+ return (Math.max(one, two) + 0.05) / (Math.min(one, two) + 0.05);
1420
+ }
1421
+
1422
+ const THEME_CONTRAST_PAIRS = [
1423
+ ["primary", "on_primary"], ["primary_container", "on_primary_container"],
1424
+ ["secondary", "on_secondary"], ["secondary_container", "on_secondary_container"],
1425
+ ["tertiary", "on_tertiary"], ["error", "on_error"],
1426
+ ["error_container", "on_error_container"], ["surface", "on_surface"],
1427
+ ["surface_variant", "on_surface_variant"],
1428
+ ];
1429
+
1430
+ function validateThemeContrast(theme) {
1431
+ const colors = theme.color_scheme || {};
1432
+ for (const [background, foreground] of THEME_CONTRAST_PAIRS) {
1433
+ const ratio = contrastRatio(colors[background], colors[foreground]);
1434
+ if (ratio !== null && ratio < 1.5) throw new Error(`UI theme ${foreground} is not visible on ${background}`);
1435
+ }
1436
+ }
1437
+
1438
+ function collectThemeWarnings(manifest) {
1439
+ const warnings = [];
1440
+ for (const extension of manifest.ui_extensions || []) {
1441
+ const theme = extension?.config?.theme;
1442
+ if (!theme || typeof theme !== "object" || Array.isArray(theme)) continue;
1443
+ for (const [mode, variant] of [["base", theme], ["light", theme.light], ["dark", theme.dark]]) {
1444
+ if (!variant || typeof variant !== "object" || Array.isArray(variant)) continue;
1445
+ const colors = variant.color_scheme || {};
1446
+ for (const [background, foreground] of [["primary", "on_primary"], ["surface", "on_surface"]]) {
1447
+ const ratio = contrastRatio(colors[background], colors[foreground]);
1448
+ if (ratio !== null && ratio < 4.5) warnings.push(`${extension.id} ${mode} theme ${foreground}/${background} contrast is ${ratio.toFixed(2)}:1; verify text accessibility`);
1449
+ }
1450
+ }
1451
+ }
1452
+ return warnings;
1300
1453
  }
1301
1454
 
1302
1455
  function validateUiAppBar(appBar, executable, routes, sources, stateKeys) {
@@ -1385,7 +1538,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
1385
1538
  validateUiAction(node.action, executable, routes, sources, stateKeys);
1386
1539
  const children = node.children === undefined ? [] : node.children;
1387
1540
  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"]);
1541
+ const leaves = new Set(["text", "icon", "image", "avatar", "divider", "spacer", "button", "chip", "metric", "progress", "calendar", "chart", "table", "field", "select", "switch", "empty"]);
1389
1542
  if (leaves.has(node.type) && children.length) throw new Error("leaf UI component cannot have children");
1390
1543
  if (["list", "timeline"].includes(node.type) && children.length !== 1) throw new Error("list UI component requires one item template");
1391
1544
  if (["positioned", "scroll", "padding", "center", "expanded", "badge"].includes(node.type) && children.length !== 1) throw new Error("UI component requires exactly one child");
@@ -1460,11 +1613,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
1460
1613
  if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
1461
1614
  if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
1462
1615
  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)) {
1616
+ if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType)) {
1464
1617
  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
1618
  }
1466
1619
  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");
1620
+ if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
1468
1621
  if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
1469
1622
  if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
1470
1623
  if (props.options != null) {
@@ -1832,6 +1985,7 @@ function escapeHtml(value) {
1832
1985
 
1833
1986
  export const internal = {
1834
1987
  artifactFileName,
1988
+ collectThemeWarnings,
1835
1989
  compileManifest,
1836
1990
  normalizeEntrypointPath,
1837
1991
  projectFileBytes,