@morit/cli 1.5.0 → 1.9.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/README.md +3 -2
- package/assets/plugin_contract.json +839 -84
- package/package.json +3 -2
- package/src/cli.js +19 -1
- package/src/morit-script.js +174 -0
- package/src/preview-v3.js +83 -0
- package/src/preview.js +38 -1
- package/src/script-contract.js +62 -0
- package/src/script-data.js +41 -0
- package/src/script-project.js +60 -0
- package/src/script-runtime.js +252 -0
- package/src/script-schema.js +32 -0
- package/src/script-spec.js +113 -0
- package/src/ui-v3.js +159 -0
- package/src/workspace.js +242 -20
package/src/workspace.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
import { renderV3PreviewHtml } from './preview-v3.js';
|
|
2
|
+
import { compileUiV3, requiresMorit19, uiV3AllowedProps } from './ui-v3.js';
|
|
3
|
+
import { compileScriptProject } from './script-project.js';
|
|
4
|
+
import { ScriptRuntime } from './script-runtime.js';
|
|
5
|
+
import { runtimeSnapshot } from './script-data.js';
|
|
1
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
2
8
|
import {
|
|
3
9
|
chmod,
|
|
4
10
|
copyFile,
|
|
@@ -11,6 +17,7 @@ import {
|
|
|
11
17
|
} from "node:fs/promises";
|
|
12
18
|
import { readFileSync } from "node:fs";
|
|
13
19
|
import { homedir } from "node:os";
|
|
20
|
+
import { promisify } from "node:util";
|
|
14
21
|
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
15
22
|
import { pathToFileURL } from "node:url";
|
|
16
23
|
import {
|
|
@@ -27,6 +34,7 @@ import { renderPreviewHtml } from "./preview.js";
|
|
|
27
34
|
const contract = JSON.parse(
|
|
28
35
|
readFileSync(new URL("../assets/plugin_contract.json", import.meta.url), "utf8"),
|
|
29
36
|
);
|
|
37
|
+
const execFileAsync = promisify(execFile);
|
|
30
38
|
|
|
31
39
|
const MAX_FILES = 64;
|
|
32
40
|
const MAX_FILE_BYTES = 512 * 1024;
|
|
@@ -130,7 +138,7 @@ export class LocalWorkspace {
|
|
|
130
138
|
await rename(temporary, this.statePath);
|
|
131
139
|
}
|
|
132
140
|
|
|
133
|
-
async createProject({ plugin_id, name, publisher, description = "", advanced = true, directory }) {
|
|
141
|
+
async createProject({ plugin_id, name, publisher, description = "", advanced = true, directory, ui_schema = 3 }) {
|
|
134
142
|
validateMetadata(plugin_id, name, publisher, description);
|
|
135
143
|
const relativePath = safeProjectPath(directory || join("morit-plugins", plugin_id.split(".").at(-1)));
|
|
136
144
|
const projectRoot = this.inside(relativePath);
|
|
@@ -152,7 +160,7 @@ export class LocalWorkspace {
|
|
|
152
160
|
version: "1.0.0",
|
|
153
161
|
cloud_project_id: null,
|
|
154
162
|
required_secrets: [],
|
|
155
|
-
min_morit_version: "1.7.
|
|
163
|
+
min_morit_version: ui_schema === 3 ? "1.9.0" : "1.7.14",
|
|
156
164
|
max_morit_version: "1.999.999",
|
|
157
165
|
permissions: [],
|
|
158
166
|
capabilities: [],
|
|
@@ -163,6 +171,13 @@ export class LocalWorkspace {
|
|
|
163
171
|
slash_commands: [],
|
|
164
172
|
data_policy: "purge",
|
|
165
173
|
};
|
|
174
|
+
if (![2,3].includes(ui_schema)) throw new Error('ui_schema must be 2 or 3');
|
|
175
|
+
if(ui_schema===3){
|
|
176
|
+
manifest.min_morit_version='1.9.0';
|
|
177
|
+
manifest.ui_extensions=[{id:plugin_id+'.screen',point:'screen',title:name,permissions:[],config:{ui_schema:3,initial_state:{},view:{type:'column',children:[{type:'text',props:{text:'{{= runtime.today }}'}}]}}}];
|
|
178
|
+
} else {
|
|
179
|
+
manifest.ui_extensions=[{id:plugin_id+'.screen',point:'screen',title:name,permissions:[],config:{ui_schema:2,initial_state:{},view:{type:'column',children:[{type:'text',props:{text:name}}]}}}];
|
|
180
|
+
}
|
|
166
181
|
await writeFile(join(projectRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
167
182
|
await writeFile(
|
|
168
183
|
join(projectRoot, "README.md"),
|
|
@@ -279,16 +294,39 @@ export class LocalWorkspace {
|
|
|
279
294
|
};
|
|
280
295
|
}
|
|
281
296
|
|
|
282
|
-
async
|
|
297
|
+
async runtimeTest(projectId, options) {
|
|
298
|
+
const project = await this.getProject(projectId, true);
|
|
299
|
+
const result = await runtimeTestProjectFiles(project.files, options);
|
|
300
|
+
const manifest = compileManifest(project.files);
|
|
301
|
+
const fixture = options.fixture ?? 'fixtures/live.json';
|
|
302
|
+
const files = {...project.files, [fixture]: JSON.stringify(result.inspector.raw_response)};
|
|
303
|
+
const html = renderV3PreviewHtml(manifest, files);
|
|
304
|
+
const fileName = `${manifest.id}-runtime-test.html`;
|
|
305
|
+
const target = join(project.workspace_path, "dist", fileName);
|
|
306
|
+
await mkdir(dirname(target), { recursive: true });
|
|
307
|
+
await writeFile(target, html);
|
|
308
|
+
const preview = await this.recordArtifact(projectId, "preview", fileName, "text/html", target, html, null);
|
|
309
|
+
return { project_id: projectId, revision: project.revision, ...result, preview };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async preview(projectId, {target: previewTarget = 'semantic'} = {}) {
|
|
283
313
|
const project = await this.getProject(projectId, true);
|
|
284
314
|
const manifest = compileManifest(project.files);
|
|
285
315
|
validateManifest(manifest, project.files);
|
|
286
316
|
const fileName = `${manifest.id}-preview.html`;
|
|
287
317
|
const target = join(project.workspace_path, "dist", fileName);
|
|
288
318
|
await mkdir(dirname(target), { recursive: true });
|
|
289
|
-
const html = previewHtml(manifest);
|
|
319
|
+
const html = manifest.ui_extensions.some(e=>e.config?.ui_schema===3) ? renderV3PreviewHtml(manifest,project.files) : previewHtml(manifest);
|
|
290
320
|
await writeFile(target, html);
|
|
291
|
-
|
|
321
|
+
const artifact=await this.recordArtifact(projectId, "preview", fileName, "text/html", target, html, null);
|
|
322
|
+
if(previewTarget!=='android')return {...artifact,preview_mode:'semantic'};
|
|
323
|
+
try{
|
|
324
|
+
const {stdout}=await execFileAsync(process.env.ADB??'adb',['devices'],{timeout:3000});
|
|
325
|
+
const serial=stdout.split('\n').map(line=>line.trim().split(/\s+/)).find(parts=>parts[1]==='device')?.[0];
|
|
326
|
+
if(!serial)return {...artifact,preview_mode:'semantic',fallback_reason:'No Android emulator or device is available'};
|
|
327
|
+
await execFileAsync(process.env.ADB??'adb',['-s',serial,'shell','am','start','-W','-a','android.intent.action.VIEW','-d',`morit://open/plugin/${encodeURIComponent(manifest.id)}`],{timeout:10000});
|
|
328
|
+
return {...artifact,preview_mode:'android',device_serial:serial};
|
|
329
|
+
}catch(error){return {...artifact,preview_mode:'semantic',fallback_reason:`Android Host preview unavailable: ${error.message}`};}
|
|
292
330
|
}
|
|
293
331
|
|
|
294
332
|
async sourceDownload(projectId, requestedFileName) {
|
|
@@ -415,6 +453,136 @@ export class LocalWorkspace {
|
|
|
415
453
|
}
|
|
416
454
|
}
|
|
417
455
|
|
|
456
|
+
export async function runtimeTestProjectDirectory(source, options) {
|
|
457
|
+
const number=value=>value===undefined?undefined:Number(value);
|
|
458
|
+
const screen=options?.screen??{
|
|
459
|
+
width:number(options?.['screen-width']),height:number(options?.['screen-height']),text_scale:number(options?.['screen-text-scale']),
|
|
460
|
+
orientation:options?.['screen-orientation'],size_class:options?.['screen-size-class'],platform:options?.['screen-platform'],
|
|
461
|
+
};
|
|
462
|
+
return runtimeTestProjectFiles(await readStandaloneProjectFiles(resolve(source)), {...options,screen});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export async function runtimeTestProjectFiles(files, options = {}) {
|
|
466
|
+
if ((options.mode ?? 'fixture') === 'fixture') return runtimeTestFiles(files, options);
|
|
467
|
+
if (options.mode !== 'live') throw new Error('mode must be fixture or live');
|
|
468
|
+
const origin = new URL(process.env.MORIT_RUNTIME_TEST_BACKEND_URL ?? 'https://invalid.local');
|
|
469
|
+
const token = process.env.MORIT_RUNTIME_TEST_HOST_TOKEN;
|
|
470
|
+
if (origin.protocol !== 'https:' || origin.username || origin.password || !token || !process.env.MORIT_RUNTIME_TEST_BACKEND_URL) throw new Error('Live mode requires configured HTTPS Host URL and Host access token');
|
|
471
|
+
const manifest = JSON.parse(files['manifest.json']);
|
|
472
|
+
if (!manifest.capabilities?.some(c => c.id === options.capability)) throw new Error('Unknown project capability');
|
|
473
|
+
const response = await fetch(new URL(`/v1/plugins/${encodeURIComponent(manifest.id)}/capabilities/${encodeURIComponent(options.capability)}/runtime-test`, origin), {
|
|
474
|
+
method:'POST', redirect:'error', signal:AbortSignal.timeout(20000), headers:{Authorization:`Bearer ${token}`,'Content-Type':'application/json','X-Morit-Plugin-Sdk':'1.9.0'},body:JSON.stringify({arguments:options.arguments??{}}),
|
|
475
|
+
});
|
|
476
|
+
if (!response.ok) throw new Error('Live capability request failed; check installation and read-only connector permissions');
|
|
477
|
+
const reader=response.body.getReader();let size=0;const chunks=[];
|
|
478
|
+
try {for (;;) {const {value,done}=await reader.read();if(done)break;size+=value.length;if(size>262144)throw new Error('Live response exceeds 256 KiB');chunks.push(value);}} finally {await reader.cancel();}
|
|
479
|
+
const live=JSON.parse(Buffer.concat(chunks).toString());if(live.state!=='completed')throw new Error('Live capability did not complete');
|
|
480
|
+
const fixture=options.fixture??'fixtures/live.json';
|
|
481
|
+
return {...runtimeTestFiles({...files,[fixture]:JSON.stringify(live.data)},{...options,fixture}),mode:'live'};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export function runtimeTestFiles(files, { module, export: exportName, fixture, extension, now, timezone = 'UTC', locale = 'en-US', screen = {} } = {}) {
|
|
485
|
+
const fixturePath=fixture??['fixtures/live.json','fixtures/day.json'].find(path=>Object.hasOwn(files,path));
|
|
486
|
+
if (typeof fixturePath !== 'string' || !/^fixtures\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*\.json$/.test(fixturePath)) throw new Error('fixture must be fixtures/*.json');
|
|
487
|
+
if (!Object.hasOwn(files, fixturePath)) throw new Error('Fixture not found');
|
|
488
|
+
const manifest = files['manifest.json'] ? compileManifest(files) : null;
|
|
489
|
+
const config = extension ? manifest?.ui_extensions?.find(value => value.id === extension)?.config : files['runtime.json'] ? JSON.parse(files['runtime.json']) : manifest?.ui_extensions?.find(value => value.config?.ui_schema === 3)?.config;
|
|
490
|
+
if (!config || config.ui_schema !== 3) throw new Error('Select a Runtime v3 extension or provide runtime.json');
|
|
491
|
+
if(!config.compiled)compileUiV3(config,files,{capabilities:new Set((manifest?.capabilities??[]).map(value=>value.id)),routes:new Set((manifest?.ui_extensions??[]).map(value=>value.id)),nodeTypes:contract.ui_nodes,runtimeContract:contract.ui_runtime_v3,baseRuntimeContract:contract.ui_runtime});
|
|
492
|
+
const compiled = compileScriptProject(config, files);
|
|
493
|
+
const runtime = runtimeSnapshot({now, timezone, locale, screen: {
|
|
494
|
+
width: screen.width ?? 390, height: screen.height ?? 844,
|
|
495
|
+
orientation: screen.orientation ?? ((screen.width ?? 390) >= (screen.height ?? 844) ? 'landscape' : 'portrait'),
|
|
496
|
+
size_class: screen.size_class ?? ((screen.width ?? 390) < 600 ? 'compact' : (screen.width ?? 390) < 840 ? 'medium' : 'expanded'),
|
|
497
|
+
text_scale: screen.text_scale ?? 1, platform: screen.platform ?? 'preview',
|
|
498
|
+
}});
|
|
499
|
+
const input = JSON.parse(files[fixturePath]);
|
|
500
|
+
const redact = value => Array.isArray(value) ? value.map(redact) : value && typeof value === 'object'
|
|
501
|
+
? Object.fromEntries(Object.entries(value).map(([key,item]) => [key, /authorization|password|secret|token|api[_-]?key/i.test(key) ? '[REDACTED]' : redact(item)])) : value;
|
|
502
|
+
const state={},computed={},data={},raw={},errors={},locals={},transformSteps=[],timeline=[];
|
|
503
|
+
const scope=extra=>({state,computed,data,runtime,context:{platform:'preview'},...extra});
|
|
504
|
+
function evaluate(value, extra={}) {
|
|
505
|
+
if(typeof value==='string'){
|
|
506
|
+
const matches=[...value.matchAll(/\{\{=([\s\S]*?)\}\}/g)];
|
|
507
|
+
const run=source=>{
|
|
508
|
+
const key=createHash('sha256').update(source.trim()).digest('hex');
|
|
509
|
+
const ast=config.compiled.expressions[key];
|
|
510
|
+
if(!ast)throw new Error(`Missing compiled expression ${source.trim()}`);
|
|
511
|
+
return new ScriptRuntime({modules:compiled.modules,profile:'inline'}).evaluate(ast,scope(extra)).value;
|
|
512
|
+
};
|
|
513
|
+
if(matches.length===1&&matches[0][0]===value)return run(matches[0][1]);
|
|
514
|
+
return value.replace(/\{\{=([\s\S]*?)\}\}/g,(_,source)=>run(source)??'');
|
|
515
|
+
}
|
|
516
|
+
if(Array.isArray(value))return value.map(item=>evaluate(item,extra));
|
|
517
|
+
if(value&&typeof value==='object')return Object.fromEntries(Object.entries(value).map(([key,item])=>[key,evaluate(item,extra)]));
|
|
518
|
+
return value;
|
|
519
|
+
}
|
|
520
|
+
for(const [key,value] of Object.entries(config.initial_state??{}))state[key]=evaluate(value);
|
|
521
|
+
const invoke=(transform,value,extra={})=>new ScriptRuntime({modules:compiled.modules}).invoke(transform.module,transform.export,value,scope(extra));
|
|
522
|
+
if(module&&exportName){
|
|
523
|
+
const result=new ScriptRuntime({modules:compiled.modules}).invoke(module,exportName,input,{runtime});
|
|
524
|
+
data.result=result.value;transformSteps.push({module,export:exportName,input:redact(input),output:redact(result.value),diagnostics:result.diagnostics});
|
|
525
|
+
}else for(const id of config.compiled.source_order??[]){
|
|
526
|
+
const source=config.data_sources.find(item=>item.id===id);
|
|
527
|
+
if(!source||source.when!==undefined&&!evaluate(source.when))continue;
|
|
528
|
+
const sourceInput=input&&typeof input==='object'&&Object.hasOwn(input,id)?input[id]:input;
|
|
529
|
+
raw[id]=redact(sourceInput);let value=sourceInput;
|
|
530
|
+
try{
|
|
531
|
+
for(const transform of Array.isArray(source.transform)?source.transform:source.transform?[source.transform]:[]){
|
|
532
|
+
const before=redact(value),result=invoke(transform,value);value=result.value;
|
|
533
|
+
transformSteps.push({source:id,module:transform.module,export:transform.export,input:before,output:redact(value),diagnostics:result.diagnostics});
|
|
534
|
+
}
|
|
535
|
+
data[id]=value;timeline.push({type:'source_completed',source:id});
|
|
536
|
+
}catch(error){errors[id]=String(error.message??error);timeline.push({type:'source_failed',source:id,error:errors[id]});}
|
|
537
|
+
}
|
|
538
|
+
for(const key of config.compiled.computed_order??[]){
|
|
539
|
+
const definition=config.computed_state?.[key];
|
|
540
|
+
computed[key]=definition?.module?invoke(definition,evaluate(definition.input)).value:evaluate(definition?.expression??definition);
|
|
541
|
+
}
|
|
542
|
+
let rendered=0;
|
|
543
|
+
const slot=(value,extra)=>[...(Array.isArray(value)?value:value?[value]:[])].map(item=>resolve(item,extra));
|
|
544
|
+
function resolve(node,extra={}){
|
|
545
|
+
if(!node||typeof node!=='object'||++rendered>2000)throw new Error('Resolved UI tree exceeds 2000 nodes');
|
|
546
|
+
if(node.visible_when!==undefined&&!evaluate(node.visible_when,extra))return {type:'hidden'};
|
|
547
|
+
const rawProps=node.props??{},deferred={...rawProps},slots=node.slots??{};
|
|
548
|
+
if(node.type==='repeat')delete deferred.key;if(node.type==='component')delete deferred.events;
|
|
549
|
+
const props=evaluate(deferred,extra);
|
|
550
|
+
for(const [key,value] of Object.entries(props))if(value&&typeof value==='object'&&!Array.isArray(value)&&['compact','medium','expanded'].some(name=>Object.hasOwn(value,name)))props[key]=value[runtime.screen.size_class]??value.compact??Object.values(value)[0];
|
|
551
|
+
if(node.type==='if')return {type:'fragment',children:slot(slots[props.condition?'then':'else'],extra)};
|
|
552
|
+
if(node.type==='repeat')return {type:'repeat',children:(Array.isArray(props.items)?props.items:[]).slice(0,200).map((item,index)=>({key:evaluate(rawProps.key,{...extra,[rawProps.as]:item,[rawProps.index_as??'index']:index}),children:slot(slots.item??node.children,{...extra,[rawProps.as]:item,[rawProps.index_as??'index']:index})}))};
|
|
553
|
+
if(node.type==='component'){
|
|
554
|
+
const definition=config.components[props.component],key=`${props.component}:${props.key??extra._key??node.id??''}`;
|
|
555
|
+
const local=locals[key]??=evaluate(definition.initial_state??{},extra),componentProps={...evaluate(definition.default_props??{},extra),...(props.props??{})},localComputed={};
|
|
556
|
+
const next={...extra,props:componentProps,state:local,root:{state},computed:localComputed,_slots:slots,_parent:extra};
|
|
557
|
+
for(const [name,value] of Object.entries(definition.computed_state??{}))localComputed[name]=evaluate(value,next);
|
|
558
|
+
return {type:'component',component:props.component,key,props:componentProps,child:resolve(definition.view,next)};
|
|
559
|
+
}
|
|
560
|
+
if(node.type==='slot')return {type:'fragment',children:slot(extra._slots?.[props.name],extra._parent??extra)};
|
|
561
|
+
if(node.type==='data_state'){
|
|
562
|
+
const status=errors[props.source]?'error':data[props.source]===undefined?'loading':props.empty_when===true||Array.isArray(data[props.source])&&!data[props.source].length?'empty':'content';
|
|
563
|
+
return {type:'data_state',source:props.source,status,children:slot(slots[status],{...extra,error:errors[props.source]??null})};
|
|
564
|
+
}
|
|
565
|
+
return {type:node.type,...(node.id?{id:node.id}:{}),props,...(node.action?{action:evaluate(node.action,extra)}:{}),children:(node.children??[]).map(child=>resolve(child,extra))};
|
|
566
|
+
}
|
|
567
|
+
const semanticTree=resolve(config.view);
|
|
568
|
+
const output=redact(Object.keys(data).length===1?Object.values(data)[0]:data);
|
|
569
|
+
return {
|
|
570
|
+
mode: 'fixture', valid: true, runtime, output,
|
|
571
|
+
diagnostics: transformSteps.map(step=>step.diagnostics), warnings: compiled.warnings,
|
|
572
|
+
inspector: {
|
|
573
|
+
root_state:redact(state),local_state:redact(locals),computed_state:redact(computed),raw_response:redact(input),raw_data:raw,data:redact(data),errors,
|
|
574
|
+
transform_steps:transformSteps,dependencies:Object.fromEntries((config.data_sources??[]).map(source=>[source.id,source.depends_on??[]])),
|
|
575
|
+
request_timeline:timeline.slice(-200),cache:[],execution:transformSteps.map(step=>step.diagnostics),component_instances:Object.keys(locals),
|
|
576
|
+
repeat_scopes:[],layout:{screen:runtime.screen,overflow:[]},source_map:[],runtime,resolved_ui_tree:redact(semanticTree),
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function compileProjectDirectory(source) {
|
|
582
|
+
const loaded = await loadProjectDirectory(source);
|
|
583
|
+
return {manifest: loaded.manifest};
|
|
584
|
+
}
|
|
585
|
+
|
|
418
586
|
export async function validateProjectDirectory(source) {
|
|
419
587
|
const loaded = await loadProjectDirectory(source);
|
|
420
588
|
return projectDirectoryResult(loaded);
|
|
@@ -444,7 +612,7 @@ export async function previewProjectDirectory(source, output) {
|
|
|
444
612
|
join(loaded.root, "dist", `${loaded.manifest.id}-preview.html`),
|
|
445
613
|
".html",
|
|
446
614
|
);
|
|
447
|
-
const content = previewHtml(loaded.manifest);
|
|
615
|
+
const content = loaded.manifest.ui_extensions.some(e=>e.config?.ui_schema===3) ? renderV3PreviewHtml(loaded.manifest,loaded.files) : previewHtml(loaded.manifest);
|
|
448
616
|
await mkdir(dirname(target), { recursive: true });
|
|
449
617
|
await writeFile(target, content);
|
|
450
618
|
return {
|
|
@@ -694,6 +862,7 @@ function installablePackagePath(name) {
|
|
|
694
862
|
if (["manifest.json", "signature.json", "README.md"].includes(name)) return true;
|
|
695
863
|
const parts = name.split("/");
|
|
696
864
|
if (parts[0] === "assets" && parts.length >= 2) return SAFE_ASSET.has(extname(name).toLowerCase());
|
|
865
|
+
if (parts[0] === "logic" && parts.length >= 2) return /\.(morit|ast\.json)$/.test(name);
|
|
697
866
|
if (parts[0] === "src" && parts.length >= 2) return name.toLowerCase().endsWith(".py");
|
|
698
867
|
return parts[0] === "children" && parts.length === 2 && name.toLowerCase().endsWith(".mplg");
|
|
699
868
|
}
|
|
@@ -738,6 +907,12 @@ function validateMetadata(pluginId, name, publisher, description) {
|
|
|
738
907
|
if (typeof description !== "string" || description.length > 4000) throw new Error("description must not exceed 4000 characters");
|
|
739
908
|
}
|
|
740
909
|
|
|
910
|
+
function usesUi1714(node) {
|
|
911
|
+
if (!node || typeof node !== "object") return false;
|
|
912
|
+
const props = node.props || {};
|
|
913
|
+
return ["page_size", "page_state_key", "item_height"].some(key => Object.hasOwn(props, key)) || (node.type === "grid" && props.source !== undefined) || (node.children || []).some(usesUi1714);
|
|
914
|
+
}
|
|
915
|
+
|
|
741
916
|
function usesUi1713(node) {
|
|
742
917
|
if (!node || typeof node !== "object") return false;
|
|
743
918
|
const props = node.props || {};
|
|
@@ -791,7 +966,10 @@ function validateManifest(manifest, files) {
|
|
|
791
966
|
const capabilities = objectCollection(manifest.capabilities, "manifest.capabilities", 64);
|
|
792
967
|
const uiExtensions = objectCollection(manifest.ui_extensions, "manifest.ui_extensions", 64);
|
|
793
968
|
const minimum = manifest.min_morit_version.split(".").map(Number);
|
|
969
|
+
if ((minimum[0] < 1 || (minimum[0] === 1 && (minimum[1] < 7 || (minimum[1] === 7 && minimum[2] < 14)))) && uiExtensions.some(extension => extension.config?.bottom_bar != null || usesUi1714(extension.config?.view))) throw new Error("pagination and bottom_bar require min_morit_version 1.7.14 or newer");
|
|
794
970
|
if ((minimum[0] < 1 || (minimum[0] === 1 && (minimum[1] < 7 || (minimum[1] === 7 && minimum[2] < 13)))) && uiExtensions.some(extension => usesUi1713(extension.config?.view))) throw new Error("new UI properties require min_morit_version 1.7.13 or newer");
|
|
971
|
+
if (uiExtensions.some(e => e.config?.ui_schema === 3) && (minimum[0] < 1 || (minimum[0] === 1 && minimum[1] < 8))) throw new Error("Runtime v3 requires min_morit_version 1.8.0");
|
|
972
|
+
if (uiExtensions.some(e => requiresMorit19(e.config, contract.ui_runtime_v3)) && (minimum[0] < 1 || (minimum[0] === 1 && minimum[1] < 9))) throw new Error("Runtime v3.1 features require min_morit_version 1.9.0");
|
|
795
973
|
const credentials = objectCollection(manifest.credentials === undefined ? [] : manifest.credentials, "manifest.credentials", 16);
|
|
796
974
|
const slashCommands = objectCollection(manifest.slash_commands === undefined ? [] : manifest.slash_commands, "manifest.slash_commands", 32);
|
|
797
975
|
const connectors = objectCollection(manifest.connectors === undefined ? [] : manifest.connectors, "manifest.connectors", 32);
|
|
@@ -851,7 +1029,7 @@ function validateManifest(manifest, files) {
|
|
|
851
1029
|
assertObject(extensionConfig, `UI ${extension.id} config`);
|
|
852
1030
|
// Twelve legal component levels add an object and a children array per
|
|
853
1031
|
// level before semantic UI validation runs.
|
|
854
|
-
assertJsonSize(extensionConfig, 32 * 1024, `UI ${extension.id} config`, 40);
|
|
1032
|
+
assertJsonSize(extensionConfig, extensionConfig.ui_schema === 3 ? 512 * 1024 : 32 * 1024, `UI ${extension.id} config`, 40);
|
|
855
1033
|
}
|
|
856
1034
|
const storage = validateStorage(manifest, requestedPermissions);
|
|
857
1035
|
for (const extension of uiExtensions) validateUiConfig(extension, capabilities, uiIds, files, storage !== null);
|
|
@@ -878,13 +1056,22 @@ function validateManifest(manifest, files) {
|
|
|
878
1056
|
].filter(Boolean).join("; ");
|
|
879
1057
|
throw new Error(`every src/*.py file must be declared by exactly one sandbox_python entrypoint (${details})`);
|
|
880
1058
|
}
|
|
881
|
-
const
|
|
1059
|
+
const compiledManifest = canonicalJson(manifest);
|
|
1060
|
+
if (compiledManifest.length > 256 * 1024) throw new Error("compiled manifest.json exceeds Morit's 256 KiB install limit");
|
|
1061
|
+
const entries = new Map([["manifest.json", compiledManifest]]);
|
|
882
1062
|
for (const [name, content] of Object.entries(files)) {
|
|
883
1063
|
if (name === "README.md") entries.set(name, projectFileBytes(name, content));
|
|
884
1064
|
else if (name.startsWith("assets/") && SAFE_ASSET.has(extname(name).toLowerCase())) entries.set(name, projectFileBytes(name, content));
|
|
1065
|
+
else if (name.startsWith("logic/") && name.endsWith(".morit")) entries.set(name, projectFileBytes(name, content));
|
|
885
1066
|
else if (name.startsWith("src/") && name.endsWith(".py")) entries.set(name, projectFileBytes(name, content));
|
|
886
1067
|
else if (name.startsWith("children/") && name.endsWith(".mplg")) entries.set(name, projectFileBytes(name, content));
|
|
887
1068
|
}
|
|
1069
|
+
for (const extension of uiExtensions) {
|
|
1070
|
+
for (const declaration of extension.config?.logic_modules ?? []) {
|
|
1071
|
+
const compiled = extension.config.compiled.modules[declaration.id];
|
|
1072
|
+
entries.set(declaration.path.replace(/\.morit$/, '.ast.json'), canonicalJson({version:compiled.version,source_sha256:compiled.source_sha256,ast:compiled.ast}));
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
888
1075
|
const declaredChildren = new Set(dependencies.filter((value) => value.package_path).map((value) => value.package_path));
|
|
889
1076
|
const packagedChildren = new Set([...entries.keys()].filter((name) => name.startsWith("children/")));
|
|
890
1077
|
if (!equalSets(declaredChildren, packagedChildren)) {
|
|
@@ -1224,6 +1411,7 @@ function validateHttpRuntime(runtime, endpoint) {
|
|
|
1224
1411
|
if (new URLSearchParams(entries).toString().length > 16 * 1024) throw new Error("http_json request_params is too large");
|
|
1225
1412
|
}
|
|
1226
1413
|
const response = runtime.response || {};
|
|
1414
|
+
if(response.result_mode!==undefined&&response.result_mode!=="raw")throw new Error("invalid response.result_mode");
|
|
1227
1415
|
rejectUnknownFields(response, new Set(contract.object_fields.http_json_response), "http_json response");
|
|
1228
1416
|
const projection = "summary_path" in response || "data_path" in response;
|
|
1229
1417
|
if (projection && (Object.keys(response).some(name => !["summary_path", "data_path", "summary_prefix"].includes(name)) ||
|
|
@@ -1278,11 +1466,6 @@ function validateRuntime(capability, runtime, permissions, credentials, connecto
|
|
|
1278
1466
|
if (!permissions.has("storage")) throw new Error("calendar_store requires storage permission");
|
|
1279
1467
|
if (runtime.operation === "reminder" && !permissions.has("notifications")) throw new Error("calendar reminder requires notifications permission");
|
|
1280
1468
|
}
|
|
1281
|
-
if (adapter === "neis_school") {
|
|
1282
|
-
if (!permissions.has("network") || !permissions.has("storage")) throw new Error("neis_school requires network and storage permissions");
|
|
1283
|
-
if (!["school_search", "setup", "lookup", "overview", "search", "reminder", "briefing"].includes(runtime.operation)) throw new Error("invalid neis_school operation");
|
|
1284
|
-
if (["reminder", "briefing"].includes(runtime.operation) && !permissions.has("notifications")) throw new Error("NEIS reminder requires notifications permission");
|
|
1285
|
-
}
|
|
1286
1469
|
if (capability.kind === "provider" && (runtime.role !== "search" || !adapter)) throw new Error("provider capabilities must declare a search runtime");
|
|
1287
1470
|
if (capability.kind === "background" && Object.keys(runtime).length) {
|
|
1288
1471
|
if (!Number.isInteger(runtime.interval_minutes) || runtime.interval_minutes < 15 || runtime.interval_minutes > 10080) {
|
|
@@ -1315,12 +1498,17 @@ function validateUiConfig(extension, capabilities, routeIds, files, hasStorage =
|
|
|
1315
1498
|
output_schema: value.output_schema,
|
|
1316
1499
|
}]));
|
|
1317
1500
|
if (hasStorage) for (const capability of STORAGE_TOOLS) executable.add(capability);
|
|
1501
|
+
if (config.ui_schema === 3) {
|
|
1502
|
+
compileUiV3(config, files, {capabilities: executable, routes: routeIds, nodeTypes: contract.ui_nodes, runtimeContract: contract.ui_runtime_v3, baseRuntimeContract: contract.ui_runtime});
|
|
1503
|
+
validateUiCapabilityBindings(config, config.data_sources ?? [], capabilitySchemas);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1318
1506
|
if (extension.point === "response" && config.ui_schema !== 2) throw new Error("response UI requires UI Runtime v2");
|
|
1319
1507
|
if (config.ui_schema === 2) {
|
|
1320
1508
|
if (config.placement != null && !["card", "action"].includes(extension.point)) throw new Error("UI placement is only valid for home extensions");
|
|
1321
1509
|
validateUiRuntimeV2(config, executable, routeIds, capabilitySchemas);
|
|
1322
1510
|
validateResponseComponent(config, extension.point === "response");
|
|
1323
|
-
for (const asset of uiRuntimeAssetPaths(config.view)) {
|
|
1511
|
+
for (const asset of [...uiRuntimeAssetPaths(config.view), ...uiRuntimeAssetPaths(config.bottom_bar)]) {
|
|
1324
1512
|
if (!(asset in files)) throw new Error(`UI image asset is not packaged: ${asset}`);
|
|
1325
1513
|
}
|
|
1326
1514
|
return;
|
|
@@ -1500,7 +1688,9 @@ function validateUiRuntimeV2(config, executable, routes, capabilitySchemas = new
|
|
|
1500
1688
|
validateUiAppBar(config.app_bar, executable, routes, sourceIds, stateKeys);
|
|
1501
1689
|
validateUiNavigation(config.navigation, executable, routes, sourceIds, stateKeys);
|
|
1502
1690
|
assertObject(config.view, "UI Runtime v2 view");
|
|
1503
|
-
|
|
1691
|
+
const nodeCounter = { value: 0 };
|
|
1692
|
+
validateUiNode(config.view, 0, nodeCounter, executable, routes, sourceIds, stateKeys, null);
|
|
1693
|
+
if (config.bottom_bar != null) validateUiNode(config.bottom_bar, 0, nodeCounter, executable, routes, sourceIds, stateKeys, null, "bottom_bar");
|
|
1504
1694
|
validateUiCapabilityBindings(config, sources, capabilitySchemas);
|
|
1505
1695
|
}
|
|
1506
1696
|
|
|
@@ -1579,6 +1769,21 @@ function validateThemeContrast(theme) {
|
|
|
1579
1769
|
function collectThemeWarnings(manifest) {
|
|
1580
1770
|
const warnings = [];
|
|
1581
1771
|
for (const extension of manifest.ui_extensions || []) {
|
|
1772
|
+
const v3 = extension.config?.ui_schema === 3;
|
|
1773
|
+
function visit(node, path) {
|
|
1774
|
+
if (!node || typeof node !== "object") return;
|
|
1775
|
+
const allowed = v3
|
|
1776
|
+
? uiV3AllowedProps(node.type, contract.ui_runtime_v3, contract.ui_runtime)
|
|
1777
|
+
: new Set([
|
|
1778
|
+
...(contract.ui_runtime.component_props[node.type] || []),
|
|
1779
|
+
...(contract.ui_runtime.common_props_excluded_components.includes(node.type) ? [] : contract.ui_runtime.common_props),
|
|
1780
|
+
]);
|
|
1781
|
+
for (const key of Object.keys(node.props || {})) if (!allowed.has(key)) warnings.push(`${path}.props.${key}: ignored by ${node.type}; remove or move to a supported component`);
|
|
1782
|
+
(node.children || []).forEach((child, index) => visit(child, `${path}.children[${index}]`));
|
|
1783
|
+
for (const [name, value] of Object.entries(node.slots || {})) (Array.isArray(value) ? value : [value]).forEach((child, index) => visit(child, `${path}.slots.${name}[${index}]`));
|
|
1784
|
+
}
|
|
1785
|
+
for (const field of ["view", "bottom_bar"]) visit(extension.config?.[field], `${extension.id}.${field}`);
|
|
1786
|
+
if (v3) for (const [name, component] of Object.entries(extension.config?.components || {})) visit(component.view, `${extension.id}.components.${name}.view`);
|
|
1582
1787
|
const theme = extension?.config?.theme;
|
|
1583
1788
|
if (!theme || typeof theme !== "object" || Array.isArray(theme)) continue;
|
|
1584
1789
|
for (const [mode, variant] of [["base", theme], ["light", theme.light], ["dark", theme.dark]]) {
|
|
@@ -1663,7 +1868,16 @@ function uiRuntimeAssetPaths(view) {
|
|
|
1663
1868
|
return result;
|
|
1664
1869
|
}
|
|
1665
1870
|
|
|
1666
|
-
function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys, parentType) {
|
|
1871
|
+
function validateUiNode(node, depth, counter, executable, routes, sources, stateKeys, parentType, path = "view") {
|
|
1872
|
+
try { validateUiNodeAt(node, depth, counter, executable, routes, sources, stateKeys, parentType, path); }
|
|
1873
|
+
catch (error) {
|
|
1874
|
+
if (/^(view[.:]|bottom_bar)/.test(error.message)) throw error;
|
|
1875
|
+
const key = Object.keys(node?.props || {}).find(key => !contract.ui_runtime.prop_fields.includes(key) || error.message.split(" ").includes(key));
|
|
1876
|
+
throw new Error(`${key ? `${path}.props.${key}` : path}: ${error.message}`);
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
function validateUiNodeAt(node, depth, counter, executable, routes, sources, stateKeys, parentType, path) {
|
|
1667
1881
|
const nodeKeys = new Set(contract.ui_runtime.node_fields);
|
|
1668
1882
|
rejectUnknownFields(node, nodeKeys, "UI component");
|
|
1669
1883
|
counter.value += 1;
|
|
@@ -1672,6 +1886,7 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
|
|
|
1672
1886
|
if (node.id != null && (typeof node.id !== "string" || !UI_IDENTIFIER.test(node.id))) throw new Error("UI component id is invalid");
|
|
1673
1887
|
const props = node.props === undefined ? {} : node.props;
|
|
1674
1888
|
validateUiProps(node.type, props, sources, stateKeys);
|
|
1889
|
+
if (node.type === "grid" && props.source !== undefined && node.children?.length !== 1) throw new Error("data grid requires one item template");
|
|
1675
1890
|
validateUiCondition(node.visible_when, stateKeys, sources);
|
|
1676
1891
|
if (node.action != null && !["surface", "card", "button", "chip", "form", "field", "select", "switch"].includes(node.type)) {
|
|
1677
1892
|
throw new Error("UI action requires an interactive component");
|
|
@@ -1691,13 +1906,20 @@ function validateUiNode(node, depth, counter, executable, routes, sources, state
|
|
|
1691
1906
|
}
|
|
1692
1907
|
if (node.type === "positioned" && parentType !== "stack") throw new Error("positioned UI component requires a stack parent");
|
|
1693
1908
|
if (node.type === "expanded" && !["row", "column"].includes(parentType)) throw new Error("expanded UI component requires a row or column parent");
|
|
1694
|
-
for (const child of children) {
|
|
1909
|
+
for (const [index, child] of children.entries()) {
|
|
1695
1910
|
assertObject(child, "UI component child");
|
|
1696
|
-
validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys, node.type);
|
|
1911
|
+
validateUiNode(child, depth + 1, counter, executable, routes, sources, stateKeys, node.type, `${path}.children[${index}]`);
|
|
1697
1912
|
}
|
|
1698
1913
|
}
|
|
1699
1914
|
|
|
1700
1915
|
function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
1916
|
+
if (["page_size", "page_state_key", "item_height"].some(key => Object.hasOwn(props, key)) && !["list", "grid"].includes(nodeType)) throw new Error("pagination requires a list or grid");
|
|
1917
|
+
if (props.item_height !== undefined && nodeType !== "grid") throw new Error("item_height requires a data grid");
|
|
1918
|
+
if (nodeType === "grid" && props.source === undefined && ["page_size", "page_state_key", "item_height"].some(key => Object.hasOwn(props, key))) throw new Error("grid pagination requires source");
|
|
1919
|
+
if (Object.hasOwn(props, "page_size") !== Object.hasOwn(props, "page_state_key")) throw new Error("page_size and page_state_key must be supplied together");
|
|
1920
|
+
if (props.page_size !== undefined && (!Number.isInteger(props.page_size) || props.page_size < 1 || props.page_size > 100)) throw new Error("UI page_size is invalid");
|
|
1921
|
+
if (props.page_state_key !== undefined && !stateKeys.has(props.page_state_key)) throw new Error("UI page_state_key references unknown state");
|
|
1922
|
+
if (props.item_height !== undefined) validateUiNumber(props.item_height, "UI item_height", 48, 4096);
|
|
1701
1923
|
const allowed = new Set(contract.ui_runtime.prop_fields);
|
|
1702
1924
|
rejectUnknownFields(props, allowed, "UI component props");
|
|
1703
1925
|
const surfaceProps = new Set([
|
|
@@ -1768,7 +1990,7 @@ function validateUiProps(nodeType, props, sources, stateKeys) {
|
|
|
1768
1990
|
if (nodeType !== "positioned" && ["left", "top", "right", "bottom"].some((key) => props[key] !== undefined)) throw new Error("position offsets require a positioned UI component");
|
|
1769
1991
|
if (nodeType !== "expanded" && props.flex !== undefined) throw new Error("flex requires an expanded UI component");
|
|
1770
1992
|
if ((nodeType !== "scroll" && props.shrink_wrap !== undefined) || (!["scroll", "list"].includes(nodeType) && props.scroll_direction !== undefined)) throw new Error("scroll properties require a scroll UI component");
|
|
1771
|
-
if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType)) {
|
|
1993
|
+
if (["list", "timeline", "calendar", "chart", "table"].includes(nodeType) || (nodeType === "grid" && props.source !== undefined)) {
|
|
1772
1994
|
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");
|
|
1773
1995
|
}
|
|
1774
1996
|
const chartType = props.chart_type === undefined ? "bar" : props.chart_type;
|
|
@@ -1919,7 +2141,7 @@ function validateUiAction(action, executable, routes, sources, stateKeys, requir
|
|
|
1919
2141
|
invoke: new Set(["type", "capability", "query", "arguments", "store", "validate"]),
|
|
1920
2142
|
navigate: new Set(["type", "target", "transition", "replace", "parameters", "result_state", "validate"]),
|
|
1921
2143
|
set_state: new Set(["type", "values", "persist"]),
|
|
1922
|
-
refresh: new Set(["type", "source"]),
|
|
2144
|
+
refresh: new Set(["type", "source", "validate"]),
|
|
1923
2145
|
back: new Set(["type", "result"]),
|
|
1924
2146
|
}[action.type];
|
|
1925
2147
|
rejectUnknownFields(action, allowed, "UI action");
|