@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/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.13",
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 preview(projectId) {
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
- return this.recordArtifact(projectId, "preview", fileName, "text/html", target, html, null);
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 entries = new Map([["manifest.json", canonicalJson(manifest)]]);
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
- validateUiNode(config.view, 0, { value: 0 }, executable, routes, sourceIds, stateKeys, null);
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");