create-aiaui-module 0.0.0-stage → 0.1.1-TEST

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.
Files changed (43) hide show
  1. package/README.md +101 -2
  2. package/bin/create-aiaui-module.js +137 -0
  3. package/package.json +30 -4
  4. package/runtime/__init__.py +82 -0
  5. package/runtime/_runtime/__init__.py +40 -0
  6. package/runtime/_runtime/host.py +88 -0
  7. package/runtime/_runtime/loader.py +39 -0
  8. package/runtime/_runtime/registry.py +93 -0
  9. package/src/errors.js +6 -0
  10. package/src/generator.js +194 -0
  11. package/src/protocol.js +98 -0
  12. package/templates/HelloWorld/README.md +13 -0
  13. package/templates/HelloWorld/contractVersion +1 -0
  14. package/templates/HelloWorld/resources/build.mjs +9 -0
  15. package/templates/HelloWorld/resources/dist/manifest.json +6 -0
  16. package/templates/HelloWorld/resources/dist/modules.js +3753 -0
  17. package/templates/HelloWorld/resources/package.json +12 -0
  18. package/templates/HelloWorld/resources/src/Hello.ce.vue +25 -0
  19. package/templates/HelloWorld/resources/src/index.ts +32 -0
  20. package/templates/HelloWorld/resources/vite.config.mjs +13 -0
  21. package/templates/JobInformation/CUSTOMIZATION.md +173 -0
  22. package/templates/JobInformation/README.md +114 -0
  23. package/templates/JobInformation/api.py +53 -0
  24. package/templates/JobInformation/contractVersion +1 -0
  25. package/templates/JobInformation/resources/build.mjs +9 -0
  26. package/templates/JobInformation/resources/dist/manifest.json +6 -0
  27. package/templates/JobInformation/resources/dist/modules.js +2919 -0
  28. package/templates/JobInformation/resources/package-lock.json +1032 -0
  29. package/templates/JobInformation/resources/package.json +15 -0
  30. package/templates/JobInformation/resources/src/bus.ts +38 -0
  31. package/templates/JobInformation/resources/src/contract.ts +74 -0
  32. package/templates/JobInformation/resources/src/customization.ts +25 -0
  33. package/templates/JobInformation/resources/src/defaults/DefaultPanel.vue +22 -0
  34. package/templates/JobInformation/resources/src/defaults/Header.vue +12 -0
  35. package/templates/JobInformation/resources/src/defaults/InputCarriers.vue +15 -0
  36. package/templates/JobInformation/resources/src/defaults/OutputCarriers.vue +14 -0
  37. package/templates/JobInformation/resources/src/defaults/Recipe.vue +18 -0
  38. package/templates/JobInformation/resources/src/defaults/Timeline.vue +22 -0
  39. package/templates/JobInformation/resources/src/defaults/slots.ts +16 -0
  40. package/templates/JobInformation/resources/src/defaults/styles.css +90 -0
  41. package/templates/JobInformation/resources/src/index.ts +45 -0
  42. package/templates/JobInformation/resources/src/vite-env.d.ts +1 -0
  43. package/templates/JobInformation/resources/vite.config.mjs +27 -0
package/README.md CHANGED
@@ -1,3 +1,102 @@
1
- # Temporary Holding Version
1
+ # create-aiaui-module
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 在 AIAUI Instance 根目录一键生成可编辑的模块目录(FastAPI 后端路由 + Vue 前端)。
4
+ 通过 `npx` 运行,无需全局安装、无需克隆仓库,运行时无外部依赖(除 Instance
5
+ 侧已声明的 Python 依赖)。
6
+
7
+ | 步骤 | 命令 | 说明 |
8
+ | --- | --- | --- |
9
+ | 查看可用模块 | `npx create-aiaui-module@latest --list` | 列出模块 ID 与 contractVersion |
10
+ | 生成模块 | `npx create-aiaui-module@latest JobInformation` | 在实例根目录生成 `PyAIAUI/JobInformation` |
11
+ | 机器可读 | `npx create-aiaui-module@latest JobInformation --json` | 生成结果输出 JSON(接 CI) |
12
+
13
+ ## 环境要求
14
+
15
+ - 执行生成器的机器:Node.js >= 20
16
+ - Instance 运行侧:Python + `fastapi`、`uvicorn`(Instance 侧自备,`pip install fastapi uvicorn`)
17
+
18
+ ## 快速开始
19
+
20
+ 在 Instance 根目录(含 `Config/` 的目录)下执行:
21
+
22
+ ```bash
23
+ npx create-aiaui-module@latest JobInformation
24
+ ```
25
+
26
+ 生成后 `PyAIAUI/JobInformation/` 与 `PyAIAUI/_runtime/` 就位,`Config/AIAUIProtocol.json` 中登记该模块(默认 `enabled: false`、`port: null`)。
27
+
28
+ 启用前需要调整的配置:
29
+
30
+ ```json
31
+ {
32
+ "schemaVersion": 1,
33
+ "port": 18901,
34
+ "modules": [
35
+ { "name": "JobInformation", "enabled": true }
36
+ ]
37
+ }
38
+ ```
39
+
40
+ 把 Instance 启动/关闭流程接上(生成后 `PyAIAUI` 目录里就是这段代码的宿主实现):
41
+
42
+ ```python
43
+ from PyAIAUI import AIAUI
44
+
45
+ # 业务对象就绪后
46
+ aiaui = AIAUI.start(services={"job_repository": repo})
47
+ # aiaui 为 None 表示没有任何模块启用
48
+
49
+ # ... Instance 正常运行 ...
50
+
51
+ # 关闭前
52
+ if aiaui is not None:
53
+ aiaui.stop()
54
+ ```
55
+
56
+ `AIAUI.start()` 幂等(重复调用返回同一 handle),一个 handle 托管本 Instance 全部启用模块,共用一个 loopback 端口。后端接口由模块自己的 `api.py` 里的 `create_api(context)` 提供,`context.services` 就是上面注入的业务对象。
57
+
58
+ ## 生成结果
59
+
60
+ ```
61
+ <Instance root>/
62
+ ├── Config/AIAUIProtocol.json # 运行协议:端口 + 模块启停(生成器只登记,不配置)
63
+ └── PyAIAUI/
64
+ ├── __init__.py # 入口:AIAUI 类(AIAUI.start / aiaui.stop)
65
+ ├── JobInformation/ # 模块目录,目录名 == 模块 ID
66
+ │ ├── api.py # 后端契约:create_api(context) -> APIRouter
67
+ │ ├── resources/src/ # Vue 前端源码
68
+ │ ├── resources/dist/ # 预构建产物(开箱即跑,改源码后 npm run build 重出)
69
+ │ └── {README.md, CUSTOMIZATION.md}# 模块说明与定制手册
70
+ └── _runtime/ # 内嵌模块宿主(零外部依赖)
71
+ ```
72
+
73
+ 生成器不选端口、不装依赖、不启动进程、不覆盖已存在的模块目录;无效的既有协议会被拒绝且不留残留。
74
+
75
+ ## 内置模块清单
76
+
77
+ | 模块 ID | 说明 | 组成 |
78
+ | --- | --- | --- |
79
+ | `JobInformation` | 工单 / 载具 / 工序信息面板 | FastAPI 后端 + Vue 前端;内置 `header`、`inputCarriers`、`outputCarriers`、`timeline`、`recipe` 五个可替换槽位;三级定制(默认 / 局部替换 / 全量自定义) |
80
+ | `HelloWorld` | 最小前端模块示例 | 自定义元素;验证挂载链路,无后端 |
81
+ 自定义通过 `resources/src/customization.ts` 完成,槽位通信通过 `context.bus`(事件 / expose+peer),详见生成目录里的 `CUSTOMIZATION.md`。
82
+
83
+ ## UIService 集成
84
+
85
+ ```bash
86
+ npm i -g create-aiaui-module
87
+ export AIA_UI_MODULE_TEMPLATE_ROOT="$(npm root -g)/create-aiaui-module"
88
+ ```
89
+
90
+ 此后 UIService 调用包内 `bin/create-aiaui-module.js`(与 `npx` 行为一致),或在业务机上直接 `npx`。
91
+
92
+ ## 常用命令
93
+
94
+ - `npx create-aiaui-module@latest --list` — 查看模板
95
+ - `npx create-aiaui-module@latest <ModuleId>` — 生成(`--instance-root` 指定根目录)
96
+ - `npx create-aiaui-module@latest --json --list` — 机器可读
97
+
98
+ ## 维护者(发布新模板)
99
+
100
+ - 新增模块:在 `templates/<ModuleId>/` 下放实现(含 `contractVersion`、`README.md`、`resources/`)
101
+ - 前端模板发布前在 `resources/` 跑 `npm install && npm run build`,把产物一起随包发布;发布自动执行 `verify-dist` 校验每个模板的 `contractVersion`、入口、`dist/` 产物
102
+ - 发版流程:`npm test` → 改 `package.json` 版本号 → `npm publish`
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import {
7
+ createModule,
8
+ GeneratorError,
9
+ listTemplates,
10
+ } from "../src/generator.js";
11
+
12
+ const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
13
+ const DIVIDER = "─".repeat(48);
14
+
15
+ function briefDescription(templateId) {
16
+ try {
17
+ const text = readFileSync(
18
+ path.join(packageRoot, "templates", templateId, "README.md"),
19
+ "utf8",
20
+ );
21
+ for (const line of text.split("\n")) {
22
+ const trimmed = line.trim();
23
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("---"))
24
+ continue;
25
+ // Keep the first sentence so the list stays scannable.
26
+ const sentence = trimmed.split(/(?<=\.)\s([A-Z])/)[0];
27
+ const clipped = sentence.length > 120 ? `${sentence.slice(0, 117)}…` : sentence;
28
+ return clipped.length > 0 ? clipped : trimmed;
29
+ }
30
+ } catch {
31
+ /* template without README simply shows no description */
32
+ }
33
+ return "";
34
+ }
35
+
36
+ function printTemplateList(entries) {
37
+ console.log();
38
+ console.log("AIAUI 模块模板");
39
+ console.log(DIVIDER);
40
+ if (entries.length === 0) console.log(" (none)");
41
+ for (const entry of entries) {
42
+ console.log(` ${entry.id} · contract v${entry.contractVersion}`);
43
+ if (briefDescription(entry.id))
44
+ console.log(` ${briefDescription(entry.id)}`);
45
+ }
46
+ console.log(DIVIDER);
47
+ console.log("生成: npx create-aiaui-module <模块名> [--list] [--json]");
48
+ }
49
+
50
+ function printSuccess(result) {
51
+ console.log();
52
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
53
+ console.log(" [AIAUI] 生成成功");
54
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
55
+ console.log();
56
+ console.log(` ✅ 生成路径 ${result.path} (${result.filesCopied} 个文件)`);
57
+ console.log(` ✅ 协议配置 ${result.configPath} (enabled: ${result.enabled})`);
58
+ console.log();
59
+ console.log("⚠️ 配置调整(需要你手动做)");
60
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
61
+ console.log(" 📌 修改 port 字段为 18901(或你选择的其他空闲端口)");
62
+ console.log(" 📌 将 enabled 改为 true");
63
+ console.log(" 📌 模块接入 Instance 启动流程后再启用");
64
+ console.log();
65
+ console.log("🔧 接下来要做:");
66
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
67
+ console.log(" 1. 配置端口");
68
+ console.log(" 修改 AIAUIProtocol.json 中 port 字段为 18901(示例值)");
69
+ console.log(" 2. 配置模块 enable");
70
+ console.log(" 修改 AIAUIProtocol.json 中对应模块的 enabled 为 true");
71
+ console.log(" 3. 接入 Instance 启动流程");
72
+ console.log(" · 启动时: aiaui = AIAUI.start(services={...})");
73
+ console.log(" · 关闭时: aiaui.stop()");
74
+ console.log(" 4. 打开 UIClient 对应页面查看效果(JobInformation → /jobs)");
75
+ console.log();
76
+ console.log("❌ 没有做的(请确认)");
77
+ console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
78
+ console.log(" · 未启动服务(由 Instance 生命周期调用 AIAUI.start)");
79
+ console.log(" · 未安装依赖(模块运行需要 pip install fastapi uvicorn)");
80
+ console.log(" · 未选择/配置端口");
81
+ console.log(" · 未手动启用任何模块");
82
+ console.log();
83
+ console.log(" 初始化目录已就绪,按上面的顺序操作即可上线。");
84
+ console.log();
85
+ }
86
+
87
+ function parseArgs(args) {
88
+ const options = {
89
+ json: false,
90
+ list: false,
91
+ instanceRoot: process.cwd(),
92
+ templateId: null,
93
+ };
94
+ for (let index = 0; index < args.length; index += 1) {
95
+ const arg = args[index];
96
+ if (arg === "--json") options.json = true;
97
+ else if (arg === "--list") options.list = true;
98
+ else if (arg === "--instance-root" && args[index + 1])
99
+ options.instanceRoot = args[++index];
100
+ else if (!arg.startsWith("-") && options.templateId === null)
101
+ options.templateId = arg;
102
+ else
103
+ throw new GeneratorError(
104
+ "invalid_arguments",
105
+ `Unexpected argument: ${arg}`,
106
+ );
107
+ }
108
+ if (!options.list && !options.templateId) {
109
+ throw new GeneratorError(
110
+ "invalid_arguments",
111
+ "Usage: create-aiaui-module <ModuleId> [--instance-root <path>] [--json]",
112
+ );
113
+ }
114
+ return options;
115
+ }
116
+
117
+ try {
118
+ const options = parseArgs(process.argv.slice(2));
119
+ if (options.list) {
120
+ const entries = await listTemplates(packageRoot);
121
+ if (options.json) console.log(JSON.stringify(entries));
122
+ else printTemplateList(entries);
123
+ } else {
124
+ const result = await createModule({
125
+ templateId: options.templateId,
126
+ instanceRoot: options.instanceRoot,
127
+ root: packageRoot,
128
+ });
129
+ if (options.json) console.log(JSON.stringify(result));
130
+ else printSuccess(result);
131
+ }
132
+ } catch (error) {
133
+ const code =
134
+ error instanceof GeneratorError ? error.code : "generator_failed";
135
+ console.error(JSON.stringify({ code, message: error.message }));
136
+ process.exitCode = code === "module_exists" ? 3 : 2;
137
+ }
package/package.json CHANGED
@@ -1,6 +1,32 @@
1
1
  {
2
2
  "name": "create-aiaui-module",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1-TEST",
4
+ "description": "AIAUI Instance module scaffolder: generate editable module (FastAPI + Vue frontend) with one npx command",
5
+ "keywords": [
6
+ "aiaui",
7
+ "scaffolding",
8
+ "generator",
9
+ "vue",
10
+ "fastapi",
11
+ "module",
12
+ "instance"
13
+ ],
14
+ "type": "module",
15
+ "bin": {
16
+ "create-aiaui-module": "./bin/create-aiaui-module.js"
17
+ },
18
+ "files": [
19
+ "bin",
20
+ "src",
21
+ "runtime",
22
+ "templates"
23
+ ],
24
+ "engines": {
25
+ "node": ">=20"
26
+ },
27
+ "scripts": {
28
+ "test": "node tests/generator.test.js",
29
+ "verify-dist": "node scripts/verify-dist.mjs",
30
+ "prepack": "npm run verify-dist"
31
+ }
32
+ }
@@ -0,0 +1,82 @@
1
+ """Instance-side entry point for enabled AIAUI modules.
2
+
3
+ The owning Instance calls ``AIAUI.start(services={...})`` once its business
4
+ objects are ready and ``aiaui.stop()`` during shutdown. Generated modules
5
+ remain editable; this file only delegates discovery and hosting to the
6
+ inlined ``_runtime`` package (no external ``aiaui`` dependency is required).
7
+
8
+ Typical use::
9
+
10
+ from PyAIAUI import AIAUI
11
+
12
+ aiaui = AIAUI.start(services={"job_repository": repo})
13
+ # ... run the Instance ...
14
+ aiaui.stop()
15
+
16
+ ``AIAUI.start()`` returns ``None`` when no modules are enabled in
17
+ ``Config/AIAUIProtocol.json``.
18
+ """
19
+
20
+ from pathlib import Path
21
+
22
+ from ._runtime import attach
23
+
24
+
25
+ class AIAUI:
26
+ """Host handle for one Instance's enabled AIAUI modules.
27
+
28
+ One handle hosts every enabled module of the Instance on a single
29
+ loopback HTTP port (see ``Config/AIAUIProtocol.json``). ``start()`` is
30
+ idempotent per Instance: a second call returns the same handle while the
31
+ host lives, and ``stop()`` tears everything down.
32
+ """
33
+
34
+ _current: "AIAUI | None" = None
35
+
36
+ def __init__(self, host):
37
+ self._host = host
38
+ self.closed = False
39
+
40
+ @classmethod
41
+ def start(cls, *, services=None, logger=None) -> "AIAUI | None":
42
+ """Start this Instance's enabled modules and return the handle.
43
+
44
+ ``services`` are the Instance's live business objects; every module
45
+ backend (``create_api(context)``) reaches them through
46
+ ``context.services``. Returns ``None`` when no modules are enabled.
47
+
48
+ ``start()`` never selects a port, installs dependencies, or starts a
49
+ network socket outside the loopback interface; the port comes from
50
+ the Instance's ``Config/AIAUIProtocol.json``.
51
+ """
52
+ current = cls._current
53
+ if current is not None and not current.closed:
54
+ return current
55
+ host = attach(
56
+ Path(__file__).resolve().parent.parent,
57
+ services=services,
58
+ logger=logger,
59
+ )
60
+ if host is None:
61
+ return None
62
+ handle = cls(host)
63
+ cls._current = handle
64
+ return handle
65
+
66
+ @property
67
+ def host(self):
68
+ """The underlying loopback host, or ``None`` after ``stop()``.
69
+
70
+ Exposed for advanced inspection only; normal code should use
71
+ ``stop()`` and let this handle the lifecycle.
72
+ """
73
+ return None if self.closed else self._host
74
+
75
+ def stop(self) -> None:
76
+ """Stop every module hosted for this Instance (usually at shutdown)."""
77
+ if self.closed:
78
+ return
79
+ self.closed = True
80
+ self._host.close()
81
+ if AIAUI._current is self:
82
+ AIAUI._current = None
@@ -0,0 +1,40 @@
1
+ """Inlined AIAUI module host runtime for generated Instances.
2
+
3
+ Call attach() from the generated PyAIAUI entry point. This package is copied
4
+ verbatim into ``PyAIAUI/_runtime`` by the module generator so Instances do not
5
+ need an external ``aiaui`` dependency.
6
+ """
7
+
8
+ import threading
9
+ from pathlib import Path
10
+
11
+ from .registry import discover
12
+
13
+ __all__ = ["attach", "discover"]
14
+
15
+ _hosts = {}
16
+ _hosts_lock = threading.RLock()
17
+
18
+
19
+ def attach(instance_root, *, services=None, logger=None):
20
+ """Load every enabled module under PyAIAUI/ and serve them on the fixed proxy port.
21
+
22
+ Returns the running ProxyHost, or None when no modules are enabled.
23
+ Call host.close() during Instance shutdown.
24
+ """
25
+ from .host import ProxyHost
26
+ from .loader import ModuleContext
27
+
28
+ root = Path(instance_root).resolve()
29
+ with _hosts_lock:
30
+ existing = _hosts.get(root)
31
+ if existing is not None and not existing.closed:
32
+ return existing
33
+ _hosts.pop(root, None)
34
+ port, modules = discover(root)
35
+ if not modules:
36
+ return None
37
+ context = ModuleContext(root, services or {}, logger)
38
+ host = ProxyHost(root, port, modules, context)
39
+ _hosts[root] = host
40
+ return host
@@ -0,0 +1,88 @@
1
+ """One loopback HTTP host per Instance; every enabled module mounts its own router here."""
2
+
3
+ import threading
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from .loader import load_api
8
+ from .registry import ModuleDescriptor
9
+
10
+ OWNER = "aiaui-proxy"
11
+
12
+
13
+ class ProxyHost:
14
+ def __init__(self, instance_root: Path, port: int, modules: list[ModuleDescriptor], context):
15
+ import fcntl
16
+ import os
17
+
18
+ import uvicorn
19
+ from fastapi import FastAPI
20
+
21
+ self.root = Path(instance_root)
22
+ self.port = port
23
+ self.modules = modules
24
+ self.closed = False
25
+ self.thread: threading.Thread | None = None
26
+
27
+ config_dir = self.root / "Config"
28
+ config_dir.mkdir(parents=True, exist_ok=True)
29
+ lock_path = config_dir / ".aiaui.host.lock"
30
+ descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
31
+ self._lock_file = os.fdopen(descriptor, "a")
32
+ try:
33
+ fcntl.flock(self._lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
34
+ except BlockingIOError as error:
35
+ self._lock_file.close()
36
+ raise ValueError("Another process already hosts this Instance") from error
37
+
38
+ self._app = FastAPI(title=f"AIAUI modules for {self.root.name}")
39
+ for module in modules:
40
+ router = load_api(module, context)
41
+ self._app.include_router(router, prefix=f"/modules/{module.id}")
42
+ self._app.add_api_route(
43
+ "/modules",
44
+ lambda: {"modules": [m.id for m in modules]},
45
+ methods=["GET"],
46
+ include_in_schema=False,
47
+ )
48
+
49
+ self.server = uvicorn.Server(uvicorn.Config(self._app, host="127.0.0.1", port=port))
50
+ # Do not use uvicorn.Server.run(). In some Instance environments asyncio.run
51
+ # is replaced (for example by nest_asyncio) with a wrapper that does not
52
+ # accept ``loop_factory``, which uvicorn 0.54+ passes through. Running the
53
+ # coroutine on an event loop we own keeps this runtime self-contained.
54
+ self.thread = threading.Thread(target=self._serve, daemon=True, name="aiaui-proxy")
55
+ self.thread.start()
56
+
57
+ deadline_limit = 100
58
+ while not self.server.started and deadline_limit > 0:
59
+ if not self.thread.is_alive():
60
+ self.close()
61
+ raise RuntimeError("AIAUI proxy host failed to start")
62
+ time.sleep(0.05)
63
+ deadline_limit -= 1
64
+ if not self.server.started:
65
+ self.close()
66
+ raise RuntimeError("AIAUI proxy host did not become ready")
67
+ self.port = self.server.servers[0].sockets[0].getsockname()[1]
68
+
69
+ def _serve(self):
70
+ import asyncio
71
+
72
+ # Create a fresh loop for this thread instead of relying on the process
73
+ # global ``asyncio.run`` (it may have been patched by the Instance).
74
+ loop = asyncio.new_event_loop()
75
+ asyncio.set_event_loop(loop)
76
+ try:
77
+ loop.run_until_complete(self.server.serve())
78
+ finally:
79
+ loop.close()
80
+
81
+ def close(self):
82
+ if self.closed:
83
+ return
84
+ self.closed = True
85
+ if self.thread is not None:
86
+ self.server.should_exit = True
87
+ self.thread.join(timeout=5)
88
+ self._lock_file.close()
@@ -0,0 +1,39 @@
1
+ """Import a module api.py and call its create_api(context) factory. Nothing else is interpreted."""
2
+
3
+ import importlib.util
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .registry import ModuleDescriptor
8
+
9
+
10
+ class ModuleContext:
11
+ """Everything the platform hands to a module backend."""
12
+
13
+ def __init__(self, instance_root, services, logger=None):
14
+ self.instance_root = Path(instance_root)
15
+ self.services = dict(services or {})
16
+ self.logger = logger
17
+ self.directory = instance_root / "PyAIAUI" if False else None
18
+
19
+ @property
20
+ def resources(self) -> Path:
21
+ return self.instance_root / "PyAIAUI"
22
+
23
+
24
+ def load_api(module: ModuleDescriptor, context: ModuleContext):
25
+ module_name = f"aiaui_module_{module.id}".replace("-", "_")
26
+ spec = importlib.util.spec_from_file_location(module_name, module.api_file)
27
+ if spec is None or spec.loader is None:
28
+ raise ValueError(f"Cannot import {module.api_file}")
29
+ module_object = importlib.util.module_from_spec(spec)
30
+ sys.modules[module_name] = module_object
31
+ try:
32
+ spec.loader.exec_module(module_object)
33
+ except Exception:
34
+ sys.modules.pop(module_name, None)
35
+ raise
36
+ factory = getattr(module_object, "create_api", None)
37
+ if factory is None:
38
+ raise ValueError(f"{module.api_file} must define create_api(context)")
39
+ return factory(context)
@@ -0,0 +1,93 @@
1
+ """Discover modules exclusively from an Instance's AIAUIProtocol contract."""
2
+
3
+ import json
4
+ import re
5
+ from pathlib import Path
6
+
7
+ PROTOCOL_NAME = "AIAUIProtocol.json"
8
+ MODULE_NAME = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
9
+
10
+
11
+ class ProtocolError(ValueError):
12
+ pass
13
+
14
+
15
+ class ModuleDescriptor:
16
+ """What the platform needs to know about one module, derived purely by convention."""
17
+
18
+ def __init__(self, module_id: str, directory: Path):
19
+ self.id = module_id
20
+ self.directory = directory
21
+ self.api_file = directory / "api.py"
22
+ self.frontend_entry = directory / "resources" / "dist"
23
+
24
+ @property
25
+ def has_backend(self) -> bool:
26
+ return self.api_file.is_file()
27
+
28
+ @property
29
+ def has_frontend(self) -> bool:
30
+ return self.frontend_entry.is_dir() and any(self.frontend_entry.glob("*.js"))
31
+
32
+ @property
33
+ def frontend_manifest(self) -> Path:
34
+ return self.frontend_entry / "manifest.json"
35
+
36
+
37
+ def read_protocol(instance_root: Path) -> dict | None:
38
+ """Read the new protocol; a null port is valid while all modules are disabled."""
39
+
40
+ path = Path(instance_root) / "Config" / PROTOCOL_NAME
41
+ if path.is_symlink():
42
+ raise ProtocolError(PROTOCOL_NAME + " must be a real file")
43
+ if not path.exists():
44
+ return None
45
+ if not path.is_file():
46
+ raise ProtocolError(PROTOCOL_NAME + " must be a real file")
47
+ try:
48
+ data = json.loads(path.read_text(encoding="utf-8"))
49
+ except (OSError, ValueError) as cause:
50
+ raise ProtocolError(PROTOCOL_NAME + " is missing or invalid") from cause
51
+ if not isinstance(data, dict) or data.get("schemaVersion") != 1:
52
+ raise ProtocolError(PROTOCOL_NAME + " requires schemaVersion 1")
53
+ port = data.get("port")
54
+ if port is not None and (
55
+ not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535
56
+ ):
57
+ raise ProtocolError(PROTOCOL_NAME + " port must be null or a valid integer")
58
+ modules = data.get("modules")
59
+ if not isinstance(modules, list):
60
+ raise ProtocolError(PROTOCOL_NAME + " modules must be an array")
61
+ names = set()
62
+ for entry in modules:
63
+ if (
64
+ not isinstance(entry, dict)
65
+ or not isinstance(entry.get("name"), str)
66
+ or not MODULE_NAME.fullmatch(entry["name"])
67
+ or not isinstance(entry.get("enabled"), bool)
68
+ or entry["name"] in names
69
+ ):
70
+ raise ProtocolError(PROTOCOL_NAME + " contains an invalid or duplicate module")
71
+ names.add(entry["name"])
72
+ return data
73
+
74
+
75
+ def discover(instance_root: Path) -> tuple[int, list[ModuleDescriptor]]:
76
+ """Return the Instance proxy port and enabled modules from AIAUIProtocol."""
77
+
78
+ root = Path(instance_root)
79
+ modules_root = root / "PyAIAUI"
80
+ protocol = read_protocol(root)
81
+ if protocol is None:
82
+ return 0, []
83
+ names = sorted(entry["name"] for entry in protocol["modules"] if entry["enabled"])
84
+ port = protocol["port"]
85
+ if names and port is None:
86
+ raise ProtocolError(PROTOCOL_NAME + " needs a port before enabling modules")
87
+ descriptors = []
88
+ for name in names:
89
+ directory = modules_root / name
90
+ if not directory.is_dir() or directory.is_symlink():
91
+ raise ProtocolError("PyAIAUI/" + name + " is missing")
92
+ descriptors.append(ModuleDescriptor(name, directory))
93
+ return port or 0, descriptors
package/src/errors.js ADDED
@@ -0,0 +1,6 @@
1
+ export class GeneratorError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.code = code;
5
+ }
6
+ }