create-aiaui-module 0.0.0-stage → 0.1.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 +34 -2
- package/bin/create-aiaui-module.js +73 -0
- package/package.json +13 -4
- package/runtime/__init__.py +39 -0
- package/runtime/_runtime/__init__.py +40 -0
- package/runtime/_runtime/host.py +88 -0
- package/runtime/_runtime/loader.py +39 -0
- package/runtime/_runtime/registry.py +93 -0
- package/src/errors.js +6 -0
- package/src/generator.js +194 -0
- package/src/protocol.js +98 -0
- package/templates/HelloWorld/README.md +5 -0
- package/templates/HelloWorld/contractVersion +1 -0
- package/templates/HelloWorld/resources/build.mjs +9 -0
- package/templates/HelloWorld/resources/dist/manifest.json +6 -0
- package/templates/HelloWorld/resources/dist/modules.js +3753 -0
- package/templates/HelloWorld/resources/package.json +12 -0
- package/templates/HelloWorld/resources/src/Hello.ce.vue +25 -0
- package/templates/HelloWorld/resources/src/index.ts +32 -0
- package/templates/HelloWorld/resources/vite.config.mjs +13 -0
- package/templates/JobInformation/CUSTOMIZATION.md +173 -0
- package/templates/JobInformation/README.md +113 -0
- package/templates/JobInformation/api.py +53 -0
- package/templates/JobInformation/contractVersion +1 -0
- package/templates/JobInformation/resources/build.mjs +9 -0
- package/templates/JobInformation/resources/dist/manifest.json +6 -0
- package/templates/JobInformation/resources/dist/modules.js +2919 -0
- package/templates/JobInformation/resources/package-lock.json +1032 -0
- package/templates/JobInformation/resources/package.json +15 -0
- package/templates/JobInformation/resources/src/bus.ts +38 -0
- package/templates/JobInformation/resources/src/contract.ts +74 -0
- package/templates/JobInformation/resources/src/customization.ts +25 -0
- package/templates/JobInformation/resources/src/defaults/DefaultPanel.vue +22 -0
- package/templates/JobInformation/resources/src/defaults/Header.vue +12 -0
- package/templates/JobInformation/resources/src/defaults/InputCarriers.vue +15 -0
- package/templates/JobInformation/resources/src/defaults/OutputCarriers.vue +14 -0
- package/templates/JobInformation/resources/src/defaults/Recipe.vue +18 -0
- package/templates/JobInformation/resources/src/defaults/Timeline.vue +22 -0
- package/templates/JobInformation/resources/src/defaults/slots.ts +16 -0
- package/templates/JobInformation/resources/src/defaults/styles.css +90 -0
- package/templates/JobInformation/resources/src/index.ts +45 -0
- package/templates/JobInformation/resources/src/vite-env.d.ts +1 -0
- package/templates/JobInformation/resources/vite.config.mjs +27 -0
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# AIAUI module templates
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This directory is the source of truth for module starter code. Each subdirectory of `templates/` is named after the generated module ID and contains `contractVersion`, a README, and the module's editable files. `api.py` is optional. Frontend templates include Vue components plus a slot customization contract, a `resources/src/index.ts` bridge with `mount` and `unmount`, and a prebuilt `resources/dist/`.
|
|
4
|
+
|
|
5
|
+
## Use with npx (no global install)
|
|
6
|
+
|
|
7
|
+
From an Instance root containing `Config/`:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx create-aiaui-module@latest JobInformation
|
|
11
|
+
npx create-aiaui-module@latest --list # show available template IDs
|
|
12
|
+
npx create-aiaui-module@latest JobInformation --json # machine-readable summary
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`npx` fetches the package on demand; nothing needs to be installed globally.
|
|
16
|
+
Use `npx create-aiaui-module@<version>` to pin a specific release.
|
|
17
|
+
|
|
18
|
+
## Local use
|
|
19
|
+
|
|
20
|
+
From an Instance root in a checkout:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node /path/to/ServerManageForLiunx/UIModulesTemplate/bin/create-aiaui-module.js --list
|
|
24
|
+
node /path/to/ServerManageForLiunx/UIModulesTemplate/bin/create-aiaui-module.js JobInformation
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
From another directory, pass `--instance-root /path/to/Instance`. Existing `PyAIAUI/<ModuleId>` directories are never overwritten. Generation creates `Config/AIAUIProtocol.json` when absent, with `port: null` and a disabled module entry. If the protocol already exists, it appends a disabled entry without changing its port or other settings. It does not choose a port, install dependencies, or start a process. An invalid existing protocol is rejected without leaving a new module directory.
|
|
28
|
+
|
|
29
|
+
To test the npm snapshot locally, run `npm pack --pack-destination /tmp` here, then use `npm exec --offline --package=/tmp/create-aiaui-module-0.1.0.tgz -- create-aiaui-module --list`.
|
|
30
|
+
|
|
31
|
+
UIService invokes this same script. A deployed UIService needs Node.js (>= 20) and `AIA_UI_MODULE_TEMPLATE_ROOT` pointing to the installed package directory (for example the package installed under `npm root -g`).
|
|
32
|
+
|
|
33
|
+
## Add a template
|
|
34
|
+
|
|
35
|
+
Add `templates/<ModuleId>/` with a `contractVersion` file and a complete editable implementation. If it has a frontend, run `npm install` and `npm run build` from its `resources/` directory, then commit the updated `dist/`. Run `npm test` here and inspect `npm pack --dry-run --json` before packaging a new version.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createModule,
|
|
7
|
+
GeneratorError,
|
|
8
|
+
listTemplates,
|
|
9
|
+
} from "../src/generator.js";
|
|
10
|
+
|
|
11
|
+
const packageRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
12
|
+
|
|
13
|
+
function parseArgs(args) {
|
|
14
|
+
const options = {
|
|
15
|
+
json: false,
|
|
16
|
+
list: false,
|
|
17
|
+
instanceRoot: process.cwd(),
|
|
18
|
+
templateId: null,
|
|
19
|
+
};
|
|
20
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
21
|
+
const arg = args[index];
|
|
22
|
+
if (arg === "--json") options.json = true;
|
|
23
|
+
else if (arg === "--list") options.list = true;
|
|
24
|
+
else if (arg === "--instance-root" && args[index + 1])
|
|
25
|
+
options.instanceRoot = args[++index];
|
|
26
|
+
else if (!arg.startsWith("-") && options.templateId === null)
|
|
27
|
+
options.templateId = arg;
|
|
28
|
+
else
|
|
29
|
+
throw new GeneratorError(
|
|
30
|
+
"invalid_arguments",
|
|
31
|
+
`Unexpected argument: ${arg}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
if (!options.list && !options.templateId) {
|
|
35
|
+
throw new GeneratorError(
|
|
36
|
+
"invalid_arguments",
|
|
37
|
+
"Usage: create-aiaui-module <ModuleId> [--instance-root <path>] [--json]",
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return options;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const options = parseArgs(process.argv.slice(2));
|
|
45
|
+
const result = options.list
|
|
46
|
+
? await listTemplates(packageRoot)
|
|
47
|
+
: await createModule({
|
|
48
|
+
templateId: options.templateId,
|
|
49
|
+
instanceRoot: options.instanceRoot,
|
|
50
|
+
root: packageRoot,
|
|
51
|
+
});
|
|
52
|
+
if (options.json) console.log(JSON.stringify(result));
|
|
53
|
+
else if (options.list)
|
|
54
|
+
console.log(
|
|
55
|
+
result
|
|
56
|
+
.map((item) => `${item.id} (contract ${item.contractVersion})`)
|
|
57
|
+
.join("\n"),
|
|
58
|
+
);
|
|
59
|
+
else {
|
|
60
|
+
console.log(`Generated ${result.path} (${result.filesCopied} files)`);
|
|
61
|
+
console.log(
|
|
62
|
+
`Registered in ${result.configPath} (enabled: ${result.enabled}, port: ${result.port ?? "unset"})`,
|
|
63
|
+
);
|
|
64
|
+
console.log(
|
|
65
|
+
"Next: call PyAIAUI.start(services={...}) / PyAIAUI.stop() from the Instance lifecycle, set a free port, then enable the module. Generation does not start it.",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
const code =
|
|
70
|
+
error instanceof GeneratorError ? error.code : "generator_failed";
|
|
71
|
+
console.error(JSON.stringify({ code, message: error.message }));
|
|
72
|
+
process.exitCode = code === "module_exists" ? 3 : 2;
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-aiaui-module",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold AIAUI Instance modules (FastAPI router + editable Vue frontend) via npx",
|
|
5
|
+
"keywords": ["aiaui", "scaffolding", "generator", "vue", "fastapi", "module", "instance"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "create-aiaui-module": "./bin/create-aiaui-module.js" },
|
|
8
|
+
"files": ["bin", "src", "runtime", "templates"],
|
|
9
|
+
"engines": { "node": ">=20" },
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node tests/generator.test.js",
|
|
12
|
+
"verify-dist": "node scripts/verify-dist.mjs",
|
|
13
|
+
"prepack": "npm run verify-dist"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Instance-side entry point for enabled AIAUI modules.
|
|
2
|
+
|
|
3
|
+
The owning Instance calls start() after its business objects are ready and
|
|
4
|
+
stop() before those objects are torn down. Generated modules remain editable;
|
|
5
|
+
this file only delegates discovery and hosting to the inlined ``_runtime``
|
|
6
|
+
package (no external ``aiaui`` dependency is required).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from ._runtime import attach
|
|
12
|
+
|
|
13
|
+
_host = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def start(*, services=None, logger=None):
|
|
17
|
+
"""Start the enabled modules for this Instance, returning their host.
|
|
18
|
+
|
|
19
|
+
Pass the Instance's live business objects through ``services`` so module
|
|
20
|
+
backends (``create_api(context)``) can reach them via ``context.services``.
|
|
21
|
+
Returns None when no modules are enabled in ``Config/AIAUIProtocol.json``.
|
|
22
|
+
"""
|
|
23
|
+
global _host
|
|
24
|
+
if _host is not None and not _host.closed:
|
|
25
|
+
return _host
|
|
26
|
+
_host = attach(
|
|
27
|
+
Path(__file__).resolve().parent.parent,
|
|
28
|
+
services=services,
|
|
29
|
+
logger=logger,
|
|
30
|
+
)
|
|
31
|
+
return _host
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stop():
|
|
35
|
+
"""Stop the host when the Instance's business objects are shutting down."""
|
|
36
|
+
global _host
|
|
37
|
+
host, _host = _host, None
|
|
38
|
+
if host is not None:
|
|
39
|
+
host.close()
|
|
@@ -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
package/src/generator.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
copyFile,
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
readFile,
|
|
8
|
+
readdir,
|
|
9
|
+
rm,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { GeneratorError } from "./errors.js";
|
|
14
|
+
import { registerModule } from "./protocol.js";
|
|
15
|
+
|
|
16
|
+
export { GeneratorError } from "./errors.js";
|
|
17
|
+
|
|
18
|
+
const MODULE_ID = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
19
|
+
|
|
20
|
+
async function realDirectory(directory) {
|
|
21
|
+
try {
|
|
22
|
+
const entry = await lstat(directory);
|
|
23
|
+
return entry.isDirectory() && !entry.isSymbolicLink();
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error.code === "ENOENT") return false;
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function templateInfo(root, id) {
|
|
31
|
+
if (!MODULE_ID.test(id)) return null;
|
|
32
|
+
const directory = path.join(root, "templates", id);
|
|
33
|
+
if (!(await realDirectory(directory))) return null;
|
|
34
|
+
try {
|
|
35
|
+
const contractVersion = (
|
|
36
|
+
await readFile(path.join(directory, "contractVersion"), "utf8")
|
|
37
|
+
).trim();
|
|
38
|
+
if (!/^\d+$/.test(contractVersion)) return null;
|
|
39
|
+
return { id, contractVersion, directory };
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (error.code === "ENOENT") return null;
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function listTemplates(root) {
|
|
47
|
+
let entries;
|
|
48
|
+
try {
|
|
49
|
+
entries = await readdir(path.join(root, "templates"), {
|
|
50
|
+
withFileTypes: true,
|
|
51
|
+
});
|
|
52
|
+
} catch (error) {
|
|
53
|
+
if (error.code === "ENOENT")
|
|
54
|
+
throw new GeneratorError(
|
|
55
|
+
"package_missing",
|
|
56
|
+
"Template package is missing",
|
|
57
|
+
);
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
const templates = await Promise.all(
|
|
61
|
+
entries.map((entry) => templateInfo(root, entry.name)),
|
|
62
|
+
);
|
|
63
|
+
return templates
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map(({ id, contractVersion }) => ({ id, contractVersion }))
|
|
66
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function copyTree(source, target) {
|
|
70
|
+
let filesCopied = 0;
|
|
71
|
+
for (const entry of (await readdir(source, { withFileTypes: true })).sort(
|
|
72
|
+
(a, b) => a.name.localeCompare(b.name),
|
|
73
|
+
)) {
|
|
74
|
+
if (
|
|
75
|
+
["__pycache__", "node_modules", ".git", ".DS_Store"].includes(
|
|
76
|
+
entry.name,
|
|
77
|
+
) ||
|
|
78
|
+
/\.py[co]$/.test(entry.name)
|
|
79
|
+
)
|
|
80
|
+
continue;
|
|
81
|
+
const from = path.join(source, entry.name);
|
|
82
|
+
const to = path.join(target, entry.name);
|
|
83
|
+
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
|
84
|
+
throw new GeneratorError(
|
|
85
|
+
"invalid_template",
|
|
86
|
+
`Template contains an unsafe member: ${entry.name}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (entry.isDirectory()) {
|
|
90
|
+
await mkdir(to);
|
|
91
|
+
filesCopied += await copyTree(from, to);
|
|
92
|
+
} else {
|
|
93
|
+
await copyFile(from, to, constants.COPYFILE_EXCL);
|
|
94
|
+
filesCopied += 1;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return filesCopied;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function createModule({ templateId, instanceRoot, root }) {
|
|
101
|
+
const template = await templateInfo(root, templateId);
|
|
102
|
+
if (!template)
|
|
103
|
+
throw new GeneratorError(
|
|
104
|
+
"unknown_template",
|
|
105
|
+
`Unknown template: ${templateId}`,
|
|
106
|
+
);
|
|
107
|
+
const instance = path.resolve(instanceRoot);
|
|
108
|
+
if (
|
|
109
|
+
!(await realDirectory(instance)) ||
|
|
110
|
+
!(await realDirectory(path.join(instance, "Config")))
|
|
111
|
+
) {
|
|
112
|
+
throw new GeneratorError(
|
|
113
|
+
"invalid_instance",
|
|
114
|
+
"Instance root must contain a real Config directory",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const modules = path.join(instance, "PyAIAUI");
|
|
118
|
+
if (!(await realDirectory(modules))) {
|
|
119
|
+
try {
|
|
120
|
+
await mkdir(modules);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (error.code === "EEXIST") {
|
|
123
|
+
throw new GeneratorError(
|
|
124
|
+
"invalid_instance",
|
|
125
|
+
"PyAIAUI must be a real directory",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
throw new GeneratorError(
|
|
129
|
+
"copy_failed",
|
|
130
|
+
`Could not create PyAIAUI: ${error.message}`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await access(modules, constants.W_OK);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new GeneratorError("invalid_instance", "PyAIAUI is not writable");
|
|
138
|
+
}
|
|
139
|
+
const target = path.join(modules, templateId);
|
|
140
|
+
try {
|
|
141
|
+
await mkdir(target);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (error.code === "EEXIST")
|
|
144
|
+
throw new GeneratorError("module_exists", `${target} already exists`);
|
|
145
|
+
throw new GeneratorError(
|
|
146
|
+
"copy_failed",
|
|
147
|
+
`Could not create module directory: ${error.message}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const bootstrap = path.join(modules, "__init__.py");
|
|
151
|
+
let createdBootstrap = false;
|
|
152
|
+
let createdRuntime = false;
|
|
153
|
+
try {
|
|
154
|
+
const filesCopied = await copyTree(template.directory, target);
|
|
155
|
+
const runtimeSource = path.join(root, "runtime", "_runtime");
|
|
156
|
+
if (await realDirectory(runtimeSource)) {
|
|
157
|
+
const runtimeTarget = path.join(modules, "_runtime");
|
|
158
|
+
if (!(await realDirectory(runtimeTarget))) {
|
|
159
|
+
await mkdir(runtimeTarget);
|
|
160
|
+
await copyTree(runtimeSource, runtimeTarget);
|
|
161
|
+
createdRuntime = true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const bootstrapSource = path.join(root, "runtime", "__init__.py");
|
|
165
|
+
try {
|
|
166
|
+
await copyFile(bootstrapSource, bootstrap, constants.COPYFILE_EXCL);
|
|
167
|
+
createdBootstrap = true;
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (error.code !== "EEXIST") throw error;
|
|
170
|
+
const existing = await lstat(bootstrap);
|
|
171
|
+
if (!existing.isFile() || existing.isSymbolicLink()) {
|
|
172
|
+
throw new GeneratorError(
|
|
173
|
+
"invalid_instance",
|
|
174
|
+
"PyAIAUI/__init__.py must be a real file",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const registration = await registerModule(instance, templateId);
|
|
179
|
+
return { moduleId: templateId, path: target, filesCopied, ...registration };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
await rm(target, { recursive: true, force: true });
|
|
182
|
+
if (createdBootstrap) await rm(bootstrap, { force: true });
|
|
183
|
+
if (createdRuntime)
|
|
184
|
+
await rm(path.join(modules, "_runtime"), {
|
|
185
|
+
recursive: true,
|
|
186
|
+
force: true,
|
|
187
|
+
});
|
|
188
|
+
if (error instanceof GeneratorError) throw error;
|
|
189
|
+
throw new GeneratorError(
|
|
190
|
+
"copy_failed",
|
|
191
|
+
`Could not copy template: ${error.message}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|