@morit/cli 1.3.0 → 1.4.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 +3 -5
- package/assets/plugin_contract.json +33 -8
- package/bin/morit.js +0 -0
- package/package.json +1 -1
- package/src/cli.js +5 -0
- package/src/preview.js +254 -17
- package/src/workspace.js +218 -19
- package/assets/docs/README.md +0 -107
- package/assets/docs/ai-response-and-timeline.md +0 -203
- package/assets/docs/ai-skill-and-docx-workflow.md +0 -83
- package/assets/docs/app-builder.md +0 -56
- package/assets/docs/authentication.md +0 -140
- package/assets/docs/components.md +0 -216
- package/assets/docs/design-tokens-responsive.md +0 -171
- package/assets/docs/docs-index.json +0 -94
- package/assets/docs/examples-notion.md +0 -83
- package/assets/docs/examples-school-life.md +0 -79
- package/assets/docs/getting-started.md +0 -132
- package/assets/docs/information-hierarchy.md +0 -81
- package/assets/docs/instances-and-connectors.md +0 -93
- package/assets/docs/lifecycle-and-api.md +0 -169
- package/assets/docs/local-cli.md +0 -125
- package/assets/docs/manifest.md +0 -234
- package/assets/docs/packaging-and-testing.md +0 -121
- package/assets/docs/permissions-and-data.md +0 -149
- package/assets/docs/platform-compatibility.md +0 -62
- package/assets/docs/plugin-storage.md +0 -175
- package/assets/docs/project-structure.md +0 -102
- package/assets/docs/remote-mcp.md +0 -158
- package/assets/docs/school-life-privacy.md +0 -55
- package/assets/docs/screens-layout-navigation.md +0 -95
- package/assets/docs/sdk-and-mcp.md +0 -199
- package/assets/docs/tool-and-skill.md +0 -172
- package/assets/docs/troubleshooting.md +0 -121
- package/assets/docs/ui-extensions.md +0 -75
- package/assets/docs/ui-runtime-v2.md +0 -343
- package/assets/docs/verification.md +0 -133
package/src/workspace.js
CHANGED
|
@@ -40,7 +40,7 @@ const SAFE_SEGMENT = /^[A-Za-z0-9._-]+$/;
|
|
|
40
40
|
const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|clock\$|com[1-9]|lpt[1-9])(?:\.|$)/i;
|
|
41
41
|
const UI_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
42
42
|
const UI_STATE_KEY = /^[A-Za-z][A-Za-z0-9_]{0,63}$/;
|
|
43
|
-
const UI_BINDING = /^(?:state|data|item)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
43
|
+
const UI_BINDING = /^(?:state|data|item|context)(?:\.[A-Za-z0-9_-]+){0,12}$/;
|
|
44
44
|
const UI_TEMPLATE_BINDING = /\{\{\s*([^{}]+?)\s*\}\}/g;
|
|
45
45
|
const UI_SINGLE_TEMPLATE_BINDING = /^\{\{\s*([^{}]+?)\s*\}\}$/;
|
|
46
46
|
const UI_ASSET_PATH = /^assets\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*\.(?:gif|jpe?g|png|webp)$/i;
|
|
@@ -152,7 +152,7 @@ export class LocalWorkspace {
|
|
|
152
152
|
version: "1.0.0",
|
|
153
153
|
cloud_project_id: null,
|
|
154
154
|
required_secrets: [],
|
|
155
|
-
min_morit_version: "1.7.
|
|
155
|
+
min_morit_version: "1.7.12",
|
|
156
156
|
max_morit_version: "1.999.999",
|
|
157
157
|
permissions: [],
|
|
158
158
|
capabilities: [],
|
|
@@ -727,10 +727,10 @@ async function signingKeyForPublisher(keyDirectory, publisher) {
|
|
|
727
727
|
}
|
|
728
728
|
}
|
|
729
729
|
|
|
730
|
-
function previewHtml(manifest) {
|
|
731
|
-
return renderPreviewHtml(manifest);
|
|
732
|
-
}
|
|
733
|
-
|
|
730
|
+
function previewHtml(manifest) {
|
|
731
|
+
return renderPreviewHtml(manifest);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
734
|
function validateMetadata(pluginId, name, publisher, description) {
|
|
735
735
|
if (!PLUGIN_ID.test(pluginId) || pluginId.length > 120) throw new Error("plugin_id must be a reverse-domain lowercase identifier");
|
|
736
736
|
if (typeof name !== "string" || name.trim().length < 1 || name.trim().length > 80) throw new Error("name must contain 1 to 80 characters");
|
|
@@ -813,6 +813,12 @@ function validateManifest(manifest, files) {
|
|
|
813
813
|
throw new Error(`unsupported runtime adapter: ${adapter || "missing"}`);
|
|
814
814
|
}
|
|
815
815
|
validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies);
|
|
816
|
+
for (const [field, schema] of [["input_schema", capability.input_schema], ["output_schema", capability.output_schema]]) {
|
|
817
|
+
if (schema === undefined) continue;
|
|
818
|
+
assertObject(schema, `capability ${capability.id} ${field}`);
|
|
819
|
+
if (Buffer.byteLength(JSON.stringify(schema), "utf8") > 16 * 1024) throw new Error(`capability ${capability.id} ${field} is too large`);
|
|
820
|
+
validateResponseSchema(schema, 0, true);
|
|
821
|
+
}
|
|
816
822
|
if (adapter === "sandbox_python") {
|
|
817
823
|
const entrypoint = normalizeEntrypointPath(runtime.entrypoint);
|
|
818
824
|
if (!entrypoint.startsWith("src/") || !entrypoint.endsWith(".py")) throw new Error("sandbox_python entrypoint must be src/*.py");
|
|
@@ -1182,6 +1188,54 @@ function validateSlashCommands(commands, capabilities) {
|
|
|
1182
1188
|
}
|
|
1183
1189
|
}
|
|
1184
1190
|
|
|
1191
|
+
function validateHttpRuntime(runtime, endpoint) {
|
|
1192
|
+
let url;
|
|
1193
|
+
try { url = new URL(endpoint); } catch { throw new Error("http_json requires a valid HTTPS endpoint"); }
|
|
1194
|
+
if (url.protocol !== "https:" || url.username || url.password || url.hash || (url.port && url.port !== "443") || endpoint.length > 2048) {
|
|
1195
|
+
throw new Error("http_json requires a valid HTTPS endpoint");
|
|
1196
|
+
}
|
|
1197
|
+
if (!["GET", "POST"].includes(String(runtime.method || "POST").toUpperCase())) {
|
|
1198
|
+
throw new Error("http_json supports GET and POST");
|
|
1199
|
+
}
|
|
1200
|
+
if (runtime.request_body != null) assertObject(runtime.request_body, "http_json request_body");
|
|
1201
|
+
if (runtime.request_params != null) {
|
|
1202
|
+
const params = runtime.request_params;
|
|
1203
|
+
assertObject(params, "http_json request_params");
|
|
1204
|
+
if (Object.keys(params).length > 32) throw new Error("http_json request_params accepts at most 32 parameters");
|
|
1205
|
+
const entries = [];
|
|
1206
|
+
for (const [name, value] of Object.entries(params)) {
|
|
1207
|
+
if (!name.length || name.length > 80 || /[\x00-\x1f]/.test(name)) throw new Error("invalid http_json parameter name");
|
|
1208
|
+
const values = Array.isArray(value) ? value : [value];
|
|
1209
|
+
if (values.length > 64 || values.some(item => item != null && (
|
|
1210
|
+
!["string", "number", "boolean"].includes(typeof item) || typeof item === "number" && !Number.isFinite(item)
|
|
1211
|
+
))) throw new Error("http_json parameters must be scalars or scalar lists");
|
|
1212
|
+
for (const item of values) if (item != null) entries.push([name, String(item)]);
|
|
1213
|
+
}
|
|
1214
|
+
if (new URLSearchParams(entries).toString().length > 16 * 1024) throw new Error("http_json request_params is too large");
|
|
1215
|
+
}
|
|
1216
|
+
const response = runtime.response || {};
|
|
1217
|
+
rejectUnknownFields(response, new Set(contract.object_fields.http_json_response), "http_json response");
|
|
1218
|
+
const projection = "summary_path" in response || "data_path" in response;
|
|
1219
|
+
if (projection && (Object.keys(response).some(name => !["summary_path", "data_path", "summary_prefix"].includes(name)) ||
|
|
1220
|
+
!("summary_path" in response) && !("summary_prefix" in response))) {
|
|
1221
|
+
throw new Error("http_json cannot mix object and collection response settings");
|
|
1222
|
+
}
|
|
1223
|
+
const validPath = value => typeof value === "string" && value.length > 0 && value.length <= 240 && value.split(".").every(part => /^(?:\*|[A-Za-z0-9_-]{1,80})$/.test(part));
|
|
1224
|
+
for (const name of ["summary_path", "data_path", "collection_path", "id_path", "url_path"]) {
|
|
1225
|
+
if (name in response && (!validPath(response[name]) || ["summary_path", "data_path"].includes(name) && response[name].split(".").includes("*"))) {
|
|
1226
|
+
throw new Error(`invalid http_json response.${name}`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
for (const name of ["title_paths", "content_paths"]) {
|
|
1230
|
+
if (name in response && (!Array.isArray(response[name]) || response[name].length > 24 || response[name].some(path => !validPath(path)))) {
|
|
1231
|
+
throw new Error(`invalid http_json response.${name}`);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
if ("max_items" in response && (!Number.isInteger(response.max_items) || response.max_items < 1 || response.max_items > 20)) {
|
|
1235
|
+
throw new Error("invalid http_json response.max_items");
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1185
1239
|
function validateRuntime(capability, runtime, permissions, credentials, connectors, dependencies) {
|
|
1186
1240
|
const adapter = runtime.adapter;
|
|
1187
1241
|
const credentialIds = new Set(credentials.map((value) => value.id));
|
|
@@ -1192,9 +1246,13 @@ function validateRuntime(capability, runtime, permissions, credentials, connecto
|
|
|
1192
1246
|
if (adapter === "text_template" && (typeof runtime.template !== "string" || !runtime.template.trim() || runtime.template.length > 2000)) {
|
|
1193
1247
|
throw new Error("text_template requires a bounded template");
|
|
1194
1248
|
}
|
|
1195
|
-
if (adapter === "http_json"
|
|
1196
|
-
|
|
1197
|
-
|
|
1249
|
+
if (adapter === "http_json") {
|
|
1250
|
+
if (!permissions.has("network")) throw new Error("http_json requires network permission");
|
|
1251
|
+
if (effectiveCredential && (!credentialIds.has(effectiveCredential) || !permissions.has("credentials"))) {
|
|
1252
|
+
throw new Error("http_json requires a declared credential");
|
|
1253
|
+
}
|
|
1254
|
+
validateHttpRuntime(runtime, effectiveEndpoint);
|
|
1255
|
+
}
|
|
1198
1256
|
if (adapter === "mcp_http") {
|
|
1199
1257
|
if (!permissions.has("network")) throw new Error("mcp_http requires network permission");
|
|
1200
1258
|
if (effectiveCredential && (!credentialIds.has(effectiveCredential) || !permissions.has("credentials"))) throw new Error("mcp_http requires a declared credential");
|
|
@@ -1212,7 +1270,7 @@ function validateRuntime(capability, runtime, permissions, credentials, connecto
|
|
|
1212
1270
|
}
|
|
1213
1271
|
if (adapter === "neis_school") {
|
|
1214
1272
|
if (!permissions.has("network") || !permissions.has("storage")) throw new Error("neis_school requires network and storage permissions");
|
|
1215
|
-
if (!["setup", "lookup", "overview", "search", "reminder", "briefing"].includes(runtime.operation)) throw new Error("invalid neis_school operation");
|
|
1273
|
+
if (!["school_search", "setup", "lookup", "overview", "search", "reminder", "briefing"].includes(runtime.operation)) throw new Error("invalid neis_school operation");
|
|
1216
1274
|
if (["reminder", "briefing"].includes(runtime.operation) && !permissions.has("notifications")) throw new Error("NEIS reminder requires notifications permission");
|
|
1217
1275
|
}
|
|
1218
1276
|
if (capability.kind === "provider" && (runtime.role !== "search" || !adapter)) throw new Error("provider capabilities must declare a search runtime");
|
|
@@ -1242,11 +1300,15 @@ function validateRuntimeReferences(capabilities, connectors, connectorIds, depen
|
|
|
1242
1300
|
function validateUiConfig(extension, capabilities, routeIds, files, hasStorage = false) {
|
|
1243
1301
|
const config = extension.config === undefined ? {} : extension.config;
|
|
1244
1302
|
const executable = new Set(capabilities.filter((value) => ["tool", "skill", "provider", "notification"].includes(value.kind)).map((value) => value.id));
|
|
1303
|
+
const capabilitySchemas = new Map(capabilities.map((value) => [value.id, {
|
|
1304
|
+
input_schema: value.input_schema,
|
|
1305
|
+
output_schema: value.output_schema,
|
|
1306
|
+
}]));
|
|
1245
1307
|
if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
|
|
1246
1308
|
if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
|
|
1247
1309
|
if (config.ui_schema === 2) {
|
|
1248
1310
|
if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
|
|
1249
|
-
validateUiRuntimeV2(config, executable, routeIds);
|
|
1311
|
+
validateUiRuntimeV2(config, executable, routeIds, capabilitySchemas);
|
|
1250
1312
|
validateResponseComponent(config, extension.point === "response");
|
|
1251
1313
|
for (const asset of uiRuntimeAssetPaths(config.view)) {
|
|
1252
1314
|
if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
|
|
@@ -1376,7 +1438,7 @@ function validateLegacyUiForm(form, executable, extensionId) {
|
|
|
1376
1438
|
}
|
|
1377
1439
|
}
|
|
1378
1440
|
|
|
1379
|
-
function validateUiRuntimeV2(config, executable, routes) {
|
|
1441
|
+
function validateUiRuntimeV2(config, executable, routes, capabilitySchemas = new Map()) {
|
|
1380
1442
|
rejectUnknownFields(config, new Set(contract.ui_runtime.config_fields), "UI Runtime v2");
|
|
1381
1443
|
if (config.ui_schema !== 2) throw new Error("UI Runtime version must be 2");
|
|
1382
1444
|
validateUiIcon(config.icon);
|
|
@@ -1429,6 +1491,7 @@ function validateUiRuntimeV2(config, executable, routes) {
|
|
|
1429
1491
|
validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
|
|
1430
1492
|
assertObject(config.view, "UI Runtime v2 view");
|
|
1431
1493
|
validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
|
|
1494
|
+
validateUiCapabilityBindings(config, sources, capabilitySchemas);
|
|
1432
1495
|
}
|
|
1433
1496
|
|
|
1434
1497
|
function validateUiTheme(theme, variant = false) {
|
|
@@ -1600,7 +1663,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
|
|
|
1600
1663
|
const props = node.props === undefined ? {} : node.props;
|
|
1601
1664
|
validateUiProps(node.type, props, sources, stateKeys);
|
|
1602
1665
|
validateUiCondition(node.visible_when, stateKeys, sources);
|
|
1603
|
-
if (node.action != null && !["surface", "card", "button", "chip"].includes(node.type)) {
|
|
1666
|
+
if (node.action != null && !["surface", "card", "button", "chip", "form", "field", "select", "switch"].includes(node.type)) {
|
|
1604
1667
|
throw new Error("UI action requires an interactive component");
|
|
1605
1668
|
}
|
|
1606
1669
|
validateUiAction(node.action, executable, routes, sources, stateKeys);
|
|
@@ -1633,7 +1696,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1633
1696
|
"border_radius", "elevation", "opacity", "clip", "enabled", "tooltip", "semantic_label", "exclude_semantics",
|
|
1634
1697
|
]);
|
|
1635
1698
|
if (nodeType === "surface") rejectUnknownFields(props, surfaceProps, "surface UI component props");
|
|
1636
|
-
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label"]) {
|
|
1699
|
+
for (const key of ["text", "title", "subtitle", "label", "supporting", "placeholder", "empty_text", "value", "tooltip", "semantic_label", "error_text", "submit_label"]) {
|
|
1637
1700
|
if (props[key] !== undefined) {
|
|
1638
1701
|
optionalUiTemplate(props[key], 1200, true);
|
|
1639
1702
|
validateTemplateReferences(props[key], stateKeys, sources);
|
|
@@ -1644,6 +1707,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1644
1707
|
}
|
|
1645
1708
|
if (props.icon !== undefined) validateUiIcon(props.icon, true);
|
|
1646
1709
|
if (props.max_lines !== undefined && (!Number.isInteger(props.max_lines) || props.max_lines < 1 || props.max_lines > 100)) throw new Error("UI numeric property max_lines is invalid");
|
|
1710
|
+
if (props.input_type !== undefined && !["text", "number", "email", "url"].includes(props.input_type)) throw new Error("UI input type is invalid");
|
|
1711
|
+
for (const key of ["min_length", "max_length"]) if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 2000)) throw new Error(`UI ${key} is invalid`);
|
|
1712
|
+
if ((props.min_length ?? 0) > (props.max_length ?? 2000)) throw new Error("UI text length constraints are invalid");
|
|
1713
|
+
for (const key of ["minimum", "maximum"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI ${key}`, -1000000000, 1000000000);
|
|
1714
|
+
if ((props.minimum ?? -1000000000) > (props.maximum ?? 1000000000)) throw new Error("UI numeric constraints are invalid");
|
|
1647
1715
|
for (const key of ["columns", "size", "limit"]) {
|
|
1648
1716
|
if (props[key] !== undefined && (!Number.isInteger(props[key]) || props[key] < 0 || props[key] > 100)) throw new Error(`UI numeric property ${key} is invalid`);
|
|
1649
1717
|
}
|
|
@@ -1668,7 +1736,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1668
1736
|
for (const key of ["left", "top", "right", "bottom"]) if (props[key] !== undefined) validateUiNumber(props[key], `UI position ${key}`, -4096, 4096);
|
|
1669
1737
|
if (props.stack_at !== undefined && (!Number.isInteger(props.stack_at) || props.stack_at < 0 || props.stack_at > 1200)) throw new Error("UI stack_at is invalid");
|
|
1670
1738
|
if (props.min_item_width !== undefined && (!Number.isInteger(props.min_item_width) || props.min_item_width < 96 || props.min_item_width > 600)) throw new Error("UI min_item_width is invalid");
|
|
1671
|
-
for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1739
|
+
for (const key of ["persist", "selected", "dense", "show_legend", "full_width", "exclude_semantics", "clip", "shrink_wrap", "enabled", "required", "searchable"]) if (props[key] !== undefined && typeof props[key] !== "boolean") throw new Error(`UI boolean property ${key} is invalid`);
|
|
1672
1740
|
if (props.fit !== undefined && !["contain", "cover", "fill", "fit_width", "fit_height", "none", "scale_down"].includes(props.fit)) throw new Error("UI image fit is invalid");
|
|
1673
1741
|
if (props.scroll_direction !== undefined && !["vertical", "horizontal"].includes(props.scroll_direction)) throw new Error("UI scroll direction is invalid");
|
|
1674
1742
|
if (["image", "avatar"].includes(nodeType)) validateUiImageSource(props, sources);
|
|
@@ -1688,6 +1756,11 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1688
1756
|
if (nodeType === "chart" && !["bar", "line", "donut", "scatter"].includes(chartType)) throw new Error("UI chart type is invalid");
|
|
1689
1757
|
if (nodeType === "calendar" && props.state_key != null && !stateKeys.has(props.state_key)) throw new Error("UI calendar state is unknown");
|
|
1690
1758
|
if (["field", "select", "switch"].includes(nodeType) && !stateKeys.has(props.state_key)) throw new Error("UI state field is unknown");
|
|
1759
|
+
if (props.suggestions !== undefined || props.suggestions_source !== undefined) {
|
|
1760
|
+
if (nodeType !== "field" || (props.suggestions !== undefined && props.suggestions_source !== undefined)) throw new Error("UI autocomplete options are invalid");
|
|
1761
|
+
if (props.suggestions !== undefined && (!Array.isArray(props.suggestions) || props.suggestions.length < 1 || props.suggestions.length > 32 || props.suggestions.some((value) => typeof value !== "string" || !value.trim() || value.length > 120))) throw new Error("UI autocomplete options are invalid");
|
|
1762
|
+
if (props.suggestions_source !== undefined) validateUiDataBinding(props.suggestions_source, sources, "autocomplete");
|
|
1763
|
+
}
|
|
1691
1764
|
if (props.options != null) {
|
|
1692
1765
|
if (nodeType !== "select" || !Array.isArray(props.options) || props.options.length < 1 || props.options.length > 32) throw new Error("UI select options are invalid");
|
|
1693
1766
|
for (const option of props.options) {
|
|
@@ -1696,6 +1769,18 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1696
1769
|
optionalUiText(option.label, 80, { required: true });
|
|
1697
1770
|
}
|
|
1698
1771
|
}
|
|
1772
|
+
if (props.options !== undefined && props.options_source !== undefined) throw new Error("UI select cannot declare two options sources");
|
|
1773
|
+
if (props.options_source !== undefined) {
|
|
1774
|
+
if (nodeType !== "select") throw new Error("UI options source requires a select");
|
|
1775
|
+
validateUiDataBinding(props.options_source, sources, "select options");
|
|
1776
|
+
}
|
|
1777
|
+
if (props.searchable !== undefined && nodeType !== "select") throw new Error("UI searchable requires a select");
|
|
1778
|
+
for (const key of ["required", "min_length", "max_length", "minimum", "maximum", "error_text"]) if (props[key] !== undefined && !["field", "select", "switch"].includes(nodeType)) throw new Error("UI validation properties require an input");
|
|
1779
|
+
if (props.submit_label !== undefined && nodeType !== "form") throw new Error("UI submit label requires a form");
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
function validateUiDataBinding(value, sources, label) {
|
|
1783
|
+
if (typeof value !== "string" || !UI_BINDING.test(value) || !value.startsWith("data.") || !sources.has(value.split(".")[1])) throw new Error(`UI ${label} source is invalid`);
|
|
1699
1784
|
}
|
|
1700
1785
|
|
|
1701
1786
|
function validateUiImageSource(props, sources) {
|
|
@@ -1737,8 +1822,21 @@ function validateUiInsets(value, label) {
|
|
|
1737
1822
|
for (const item of Object.values(value)) validateUiNumber(item, label, 0, 128);
|
|
1738
1823
|
}
|
|
1739
1824
|
|
|
1740
|
-
function validateUiCondition(condition, stateKeys, sources) {
|
|
1825
|
+
function validateUiCondition(condition, stateKeys, sources, depth = 0, counter = { value: 0 }) {
|
|
1741
1826
|
if (condition == null) return;
|
|
1827
|
+
assertObject(condition, "UI visibility condition");
|
|
1828
|
+
counter.value += 1;
|
|
1829
|
+
if (depth > 4 || counter.value > 32) throw new Error("UI visibility condition is too complex");
|
|
1830
|
+
const composites = ["all", "any", "not"].filter((key) => Object.hasOwn(condition, key));
|
|
1831
|
+
if (composites.length) {
|
|
1832
|
+
if (composites.length !== 1 || Object.keys(condition).length !== 1) throw new Error("UI visibility condition is invalid");
|
|
1833
|
+
const key = composites[0];
|
|
1834
|
+
if (key === "not") return validateUiCondition(condition.not, stateKeys, sources, depth + 1, counter);
|
|
1835
|
+
const children = condition[key];
|
|
1836
|
+
if (!Array.isArray(children) || children.length < 1 || children.length > 8) throw new Error("UI visibility condition group is invalid");
|
|
1837
|
+
for (const child of children) validateUiCondition(child, stateKeys, sources, depth + 1, counter);
|
|
1838
|
+
return;
|
|
1839
|
+
}
|
|
1742
1840
|
rejectUnknownFields(condition, new Set(["path", "equals", "not_equals", "exists"]), "UI visibility condition");
|
|
1743
1841
|
if (typeof condition.path !== "string" || !UI_BINDING.test(condition.path)) throw new Error("UI visibility path is invalid");
|
|
1744
1842
|
validateKnownBinding(condition.path, stateKeys, sources);
|
|
@@ -1747,13 +1845,23 @@ function validateUiCondition(condition, stateKeys, sources) {
|
|
|
1747
1845
|
if (checks[0] !== "exists") validateUiJson(condition[checks[0]], 0);
|
|
1748
1846
|
}
|
|
1749
1847
|
|
|
1750
|
-
function validateUiAction(action, executable, routes, sources, stateKeys, required = false) {
|
|
1848
|
+
function validateUiAction(action, executable, routes, sources, stateKeys, required = false, depth = 0, counter = { value: 0 }) {
|
|
1751
1849
|
if (action == null) {
|
|
1752
1850
|
if (required) throw new Error("UI action is required");
|
|
1753
1851
|
return;
|
|
1754
1852
|
}
|
|
1853
|
+
assertObject(action, "UI action");
|
|
1854
|
+
counter.value += 1;
|
|
1855
|
+
if (depth > 4 || counter.value > 16) throw new Error("UI action flow is too complex");
|
|
1755
1856
|
rejectUnknownFields(action, new Set(contract.ui_runtime.action_fields), "UI action");
|
|
1756
|
-
if (!["invoke", "navigate", "set_state", "refresh", "back"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1857
|
+
if (!["invoke", "navigate", "set_state", "refresh", "back", "flow"].includes(action.type)) throw new Error("UI action type is invalid");
|
|
1858
|
+
if (action.validate !== undefined && typeof action.validate !== "boolean") throw new Error("UI action validation flag is invalid");
|
|
1859
|
+
if (action.type === "flow") {
|
|
1860
|
+
rejectUnknownFields(action, new Set(["type", "mode", "actions", "continue_on_error", "validate"]), "UI action flow");
|
|
1861
|
+
if (!["sequential", "parallel"].includes(action.mode) || !Array.isArray(action.actions) || action.actions.length < 1 || action.actions.length > 8 || (action.continue_on_error !== undefined && typeof action.continue_on_error !== "boolean")) throw new Error("UI action flow is invalid");
|
|
1862
|
+
for (const child of action.actions) validateUiAction(child, executable, routes, sources, stateKeys, true, depth + 1, counter);
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1757
1865
|
if (action.type === "invoke") {
|
|
1758
1866
|
if (!executable.has(action.capability)) throw new Error("UI action targets a non-executable capability");
|
|
1759
1867
|
optionalUiTemplate(action.query, 2000);
|
|
@@ -1768,6 +1876,11 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1768
1876
|
if (!["platform", "fade", "slide", "none"].includes(transition)) throw new Error("UI navigation transition is invalid");
|
|
1769
1877
|
const replace = action.replace === undefined ? false : action.replace;
|
|
1770
1878
|
if (typeof replace !== "boolean") throw new Error("UI navigation replacement is invalid");
|
|
1879
|
+
const parameters = action.parameters === undefined ? {} : action.parameters;
|
|
1880
|
+
assertObject(parameters, "UI navigation parameters");
|
|
1881
|
+
validateUiBindingValue(parameters, 0, stateKeys, sources);
|
|
1882
|
+
if (action.result_state !== undefined && !stateKeys.has(action.result_state)) throw new Error("UI navigation result state is unknown");
|
|
1883
|
+
if (action.result_state !== undefined && replace) throw new Error("replacement navigation cannot return a result");
|
|
1771
1884
|
} else if (action.type === "set_state") {
|
|
1772
1885
|
assertObject(action.values, "UI state action values");
|
|
1773
1886
|
if (!Object.keys(action.values).length || Object.keys(action.values).some((key) => !stateKeys.has(key))) throw new Error("UI state action targets an unknown state key");
|
|
@@ -1776,8 +1889,21 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1776
1889
|
if (typeof persist !== "boolean") throw new Error("UI state persistence flag is invalid");
|
|
1777
1890
|
} else if (action.type === "refresh") {
|
|
1778
1891
|
if (!sources.has(action.source)) throw new Error("UI refresh source is unknown");
|
|
1779
|
-
} else
|
|
1892
|
+
} else {
|
|
1893
|
+
rejectUnknownFields(action, new Set(["type", "result"]), "back UI action");
|
|
1894
|
+
const result = action.result === undefined ? {} : action.result;
|
|
1895
|
+
assertObject(result, "UI route result");
|
|
1896
|
+
validateUiBindingValue(result, 0, stateKeys, sources);
|
|
1897
|
+
}
|
|
1780
1898
|
if (action.type !== "navigate" && (action.transition !== undefined || action.replace !== undefined)) throw new Error("navigation options require a navigate UI action");
|
|
1899
|
+
const allowed = {
|
|
1900
|
+
invoke: new Set(["type", "capability", "query", "arguments", "store", "validate"]),
|
|
1901
|
+
navigate: new Set(["type", "target", "transition", "replace", "parameters", "result_state", "validate"]),
|
|
1902
|
+
set_state: new Set(["type", "values", "persist"]),
|
|
1903
|
+
refresh: new Set(["type", "source"]),
|
|
1904
|
+
back: new Set(["type", "result"]),
|
|
1905
|
+
}[action.type];
|
|
1906
|
+
rejectUnknownFields(action, allowed, "UI action");
|
|
1781
1907
|
}
|
|
1782
1908
|
|
|
1783
1909
|
function validateUiBindingValue(value, depth, stateKeys, sources) {
|
|
@@ -1818,6 +1944,73 @@ function validateUiJson(value, depth) {
|
|
|
1818
1944
|
for (const item of Object.values(value)) validateUiJson(item, depth + 1);
|
|
1819
1945
|
}
|
|
1820
1946
|
|
|
1947
|
+
function validateUiCapabilityBindings(config, sources, capabilitySchemas) {
|
|
1948
|
+
const sourceCapabilities = new Map(sources.map((source) => [source.id, source.capability]));
|
|
1949
|
+
for (const source of sources) {
|
|
1950
|
+
const schema = capabilitySchemas.get(source.capability)?.input_schema;
|
|
1951
|
+
if (schema !== undefined && !uiSchemaAccepts(schema, source.arguments === undefined ? {} : source.arguments)) {
|
|
1952
|
+
throw new Error(`UI data source ${source.id} does not match capability input_schema`);
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
function visit(value) {
|
|
1956
|
+
if (typeof value === "string") {
|
|
1957
|
+
for (const match of value.matchAll(UI_TEMPLATE_BINDING)) {
|
|
1958
|
+
const parts = match[1].trim().split(".");
|
|
1959
|
+
if (parts.length < 4 || parts[0] !== "data" || parts[2] !== "data") continue;
|
|
1960
|
+
const schema = capabilitySchemas.get(sourceCapabilities.get(parts[1]))?.output_schema;
|
|
1961
|
+
if (schema !== undefined && !uiSchemaHasPath(schema, parts.slice(3))) throw new Error("UI binding references an unknown capability output");
|
|
1962
|
+
}
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
if (Array.isArray(value)) return value.forEach(visit);
|
|
1966
|
+
if (!value || typeof value !== "object") return;
|
|
1967
|
+
if (value.type === "invoke") {
|
|
1968
|
+
const schema = capabilitySchemas.get(value.capability)?.input_schema;
|
|
1969
|
+
if (schema !== undefined && !uiSchemaAccepts(schema, value.arguments === undefined ? {} : value.arguments)) throw new Error("UI action does not match capability input_schema");
|
|
1970
|
+
}
|
|
1971
|
+
Object.values(value).forEach(visit);
|
|
1972
|
+
}
|
|
1973
|
+
visit(config);
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
function uiSchemaAccepts(schema, value) {
|
|
1977
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return false;
|
|
1978
|
+
if (typeof value === "string") {
|
|
1979
|
+
const exact = UI_SINGLE_TEMPLATE_BINDING.exec(value);
|
|
1980
|
+
if (exact) return true;
|
|
1981
|
+
if ([...value.matchAll(UI_TEMPLATE_BINDING)].length) return schema.type === "string";
|
|
1982
|
+
}
|
|
1983
|
+
if (schema.enum !== undefined && !schema.enum.some((item) => JSON.stringify(item) === JSON.stringify(value))) return false;
|
|
1984
|
+
if (schema.type === "null") return value === null;
|
|
1985
|
+
if (schema.type === "boolean") return typeof value === "boolean";
|
|
1986
|
+
if (schema.type === "integer") return Number.isInteger(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
|
|
1987
|
+
if (schema.type === "number") return typeof value === "number" && Number.isFinite(value) && (schema.minimum === undefined || value >= schema.minimum) && (schema.maximum === undefined || value <= schema.maximum);
|
|
1988
|
+
if (schema.type === "string") return typeof value === "string" && value.length >= (schema.minLength ?? 0) && value.length <= (schema.maxLength ?? 2000);
|
|
1989
|
+
if (schema.type === "array") return Array.isArray(value) && value.length >= (schema.minItems ?? 0) && value.length <= (schema.maxItems ?? 64) && value.every((item) => uiSchemaAccepts(schema.items, item));
|
|
1990
|
+
if (schema.type !== "object" || !value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
1991
|
+
const properties = schema.properties || {};
|
|
1992
|
+
if ((schema.required || []).some((key) => !Object.hasOwn(value, key))) return false;
|
|
1993
|
+
if (schema.additionalProperties === false && Object.keys(value).some((key) => !Object.hasOwn(properties, key))) return false;
|
|
1994
|
+
return Object.entries(value).every(([key, item]) => !Object.hasOwn(properties, key) || uiSchemaAccepts(properties[key], item));
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
function uiSchemaHasPath(schema, parts) {
|
|
1998
|
+
let current = schema;
|
|
1999
|
+
for (const part of parts) {
|
|
2000
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) return false;
|
|
2001
|
+
if (current.type === "array") {
|
|
2002
|
+
current = current.items;
|
|
2003
|
+
if (part === "length") return true;
|
|
2004
|
+
if (/^\d+$/.test(part)) continue;
|
|
2005
|
+
}
|
|
2006
|
+
if (!current || current.type !== "object") return false;
|
|
2007
|
+
const properties = current.properties || {};
|
|
2008
|
+
if (!Object.hasOwn(properties, part)) return current.additionalProperties !== false;
|
|
2009
|
+
current = properties[part];
|
|
2010
|
+
}
|
|
2011
|
+
return true;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1821
2014
|
function optionalUiTemplate(value, maximum, required = false) {
|
|
1822
2015
|
if (value === undefined || value === null) {
|
|
1823
2016
|
if (required) throw new Error("UI text template is required");
|
|
@@ -1836,6 +2029,12 @@ function validateKnownBinding(path, stateKeys, sources) {
|
|
|
1836
2029
|
const parts = path.split(".");
|
|
1837
2030
|
if (parts[0] === "state" && (parts.length < 2 || !stateKeys.has(parts[1]))) throw new Error("UI state binding is unknown");
|
|
1838
2031
|
if (parts[0] === "data" && (parts.length < 2 || !sources.has(parts[1]))) throw new Error("UI data binding is unknown");
|
|
2032
|
+
if (parts[0] === "context") {
|
|
2033
|
+
const roots = new Set(["platform", "screen", "permissions", "connections", "connected", "loading", "errors", "route"]);
|
|
2034
|
+
if (parts.length < 2 || !roots.has(parts[1])) throw new Error("UI runtime context binding is unknown");
|
|
2035
|
+
if (parts[1] === "screen" && (parts.length !== 3 || !["width", "height", "size_class"].includes(parts[2]))) throw new Error("UI screen context binding is unknown");
|
|
2036
|
+
if (["loading", "errors"].includes(parts[1]) && (parts.length !== 3 || !sources.has(parts[2]))) throw new Error("UI data-source context binding is unknown");
|
|
2037
|
+
}
|
|
1839
2038
|
}
|
|
1840
2039
|
|
|
1841
2040
|
function optionalUiText(value, maximum, { required = false, identifier = false } = {}) {
|
package/assets/docs/README.md
DELETED
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
# Morit Plugin 개발 문서
|
|
2
|
-
|
|
3
|
-
이 문서는 아이디어를 실제 설치 가능한 `.mplg`로 만드는 순서대로 구성되어 있습니다. 플러그인은
|
|
4
|
-
실행 코드를 앱에 직접 주입하지 않습니다. Manifest와 JSON fragment로 기능·화면·권한을 선언하고,
|
|
5
|
-
Morit Host가 서명과 계약을 확인한 뒤 공용 런타임으로 실행합니다.
|
|
6
|
-
|
|
7
|
-
## 가장 짧은 개발 경로
|
|
8
|
-
|
|
9
|
-
```text
|
|
10
|
-
요구사항 정리
|
|
11
|
-
→ 프로젝트 생성
|
|
12
|
-
→ manifest와 기능 fragment 작성
|
|
13
|
-
→ 화면·상태·내비게이션 작성
|
|
14
|
-
→ 권한·설정·알림 연결
|
|
15
|
-
→ validate
|
|
16
|
-
→ preview
|
|
17
|
-
→ build
|
|
18
|
-
→ verify
|
|
19
|
-
→ 실제 앱 설치·실사용 테스트
|
|
20
|
-
→ deploy
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
공식 CLI를 사용하는 기본 명령은 다음과 같습니다.
|
|
24
|
-
|
|
25
|
-
```bash
|
|
26
|
-
npx -y @morit/cli plugin setup . \
|
|
27
|
-
--id com.example.study \
|
|
28
|
-
--name "Study" \
|
|
29
|
-
--publisher example
|
|
30
|
-
npx -y @morit/cli plugin validate .
|
|
31
|
-
npx -y @morit/cli plugin preview . --output ./dist/preview.html
|
|
32
|
-
npx -y @morit/cli plugin build .
|
|
33
|
-
npx -y @morit/cli login
|
|
34
|
-
npx -y @morit/cli plugin add .
|
|
35
|
-
npx -y @morit/cli plugin deploy . --visibility private
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
같은 safe renderer는 공식 CLI, Local/Remote Plugin MCP의 `morit_project_preview`, 저장소 도구
|
|
39
|
-
`python tools/morit_plugin.py preview <project>`에서 사용합니다. preview의 라이트·다크 전환으로
|
|
40
|
-
계약과 배치를 확인하고, 실제 Flutter focus·navigation·Tool 흐름은 앱에서도 확인합니다.
|
|
41
|
-
|
|
42
|
-
## 개발 순서별 인덱스
|
|
43
|
-
|
|
44
|
-
### 1. 시작과 개발 흐름
|
|
45
|
-
|
|
46
|
-
1. [시작하기와 개발 흐름](getting-started.md) — 개발 방식 선택, 첫 프로젝트, 완료 조건
|
|
47
|
-
2. [앱에서 플러그인 만들기](app-builder.md) — 단순 Tool을 앱 Builder로 만드는 범위
|
|
48
|
-
|
|
49
|
-
### 2. 프로젝트와 계약
|
|
50
|
-
|
|
51
|
-
3. [프로젝트 구조와 fragment](project-structure.md) — 디렉터리, 병합 규칙, 패키지 포함 파일
|
|
52
|
-
4. [Manifest 레퍼런스](manifest.md) — ID, 버전, capability, connector, dependency
|
|
53
|
-
5. [Instance, Connector, 복합 패키지](instances-and-connectors.md) — 사용자별 실행 단위와 계정 연결
|
|
54
|
-
|
|
55
|
-
### 3. 화면과 사용자 경험
|
|
56
|
-
|
|
57
|
-
6. [화면, 레이아웃, 내비게이션](screens-layout-navigation.md) — 화면 역할과 이동 구조
|
|
58
|
-
7. [기본·커스텀 컴포넌트](components.md) — 각 node의 목적, 속성, 제약
|
|
59
|
-
8. [토큰, 크기, 색, 여백, 반응형](design-tokens-responsive.md) — Material 3 기반 시각 규칙
|
|
60
|
-
9. [화면 분리와 정보 계층](information-hierarchy.md) — 한 화면에 정보를 몰지 않는 설계
|
|
61
|
-
10. [UI extension point](ui-extensions.md) — Host의 어느 위치에 UI를 노출할지 선택
|
|
62
|
-
11. [UI Runtime v2 레퍼런스](ui-runtime-v2.md) — data, state, binding, event 계약
|
|
63
|
-
12. [Response UI와 Agent Timeline](ai-response-and-timeline.md) — AI 메시지 안의 결과 UI
|
|
64
|
-
|
|
65
|
-
### 4. 기능, 데이터, 사용자 제어
|
|
66
|
-
|
|
67
|
-
13. [Tool, Skill, Search, Slash Command](tool-and-skill.md) — capability와 runtime adapter
|
|
68
|
-
14. [권한, 설정, 저장소, 알림](permissions-and-data.md) — 최소 권한과 Host action
|
|
69
|
-
15. [Plugin Local Storage와 AI 접근](plugin-storage.md) — namespace, CRUD, migration, 격리, AI Tool
|
|
70
|
-
16. [외부 서비스 인증과 Cloud Secrets](authentication.md) — OAuth, API key, 비밀 값 경계
|
|
71
|
-
17. [AI Skill과 파일 산출물](ai-skill-and-docx-workflow.md) — 실제 파일을 반환하는 완료 흐름
|
|
72
|
-
|
|
73
|
-
### 5. 플랫폼
|
|
74
|
-
|
|
75
|
-
18. [Android, iOS, Desktop 호환](platform-compatibility.md) — 현재 지원 범위와 이식 원칙
|
|
76
|
-
|
|
77
|
-
### 6. 검증과 배포
|
|
78
|
-
|
|
79
|
-
19. [공식 CLI 개발 흐름](local-cli.md) — setup, sync, preview, build, deploy
|
|
80
|
-
20. [패키징과 테스트](packaging-and-testing.md) — 서명, 무결성, 테스트 층
|
|
81
|
-
21. [오류 해결](troubleshooting.md) — validate, preview, build, 설치, 실행 오류 구분
|
|
82
|
-
22. [예제 검증 방법과 확인 경계](verification.md) — 자동·live·실기기 검증의 차이
|
|
83
|
-
|
|
84
|
-
### 7. SDK, MCP, API
|
|
85
|
-
|
|
86
|
-
23. [CLI와 AI 에이전트 MCP](sdk-and-mcp.md) — Codex·Claude 등 로컬/원격 연결
|
|
87
|
-
24. [원격 Plugin MCP](remote-mcp.md) — Cloud Project를 다루는 Tool 계약
|
|
88
|
-
25. [수명주기와 HTTP API](lifecycle-and-api.md) — Host API와 설치·실행 상태
|
|
89
|
-
|
|
90
|
-
### 8. 실제 예제
|
|
91
|
-
|
|
92
|
-
26. [학교 생활 플러그인](examples-school-life.md) — 공개 NEIS와 AI 할 일을 사용하는 학생용 경험
|
|
93
|
-
27. [학교 생활 개인정보 처리](school-life-privacy.md) — 저장·전송·삭제 범위
|
|
94
|
-
28. [Notion 플러그인](examples-notion.md) — OAuth Connection과 실제 문서 검색
|
|
95
|
-
|
|
96
|
-
## 문서가 배포되는 위치
|
|
97
|
-
|
|
98
|
-
`docs/plugin_docs`가 문서 콘텐츠의 공통 원본입니다. 같은 파일이 다음 위치에 복사되거나 빌드 시
|
|
99
|
-
포함됩니다.
|
|
100
|
-
|
|
101
|
-
- `@morit/cli`: npm 패키지의 `assets/docs`
|
|
102
|
-
- Local MCP `@morit/plugin-mcp`: npm 패키지의 `assets/docs`
|
|
103
|
-
- Remote MCP: 서비스 이미지의 `/app/docs/plugin_docs`
|
|
104
|
-
- `developers.moring.co`: `docs-index.json`의 순서와 경로로 생성한 MDX
|
|
105
|
-
|
|
106
|
-
네 배포 위치는 같은 계약과 예제를 제공하며, 문서의 JSON은 공식 CLI와 Host 계약으로 지속
|
|
107
|
-
검증합니다. 각 경로에서 별도 규격을 정의하지 않습니다.
|