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.
Files changed (43) hide show
  1. package/README.md +34 -2
  2. package/bin/create-aiaui-module.js +73 -0
  3. package/package.json +13 -4
  4. package/runtime/__init__.py +39 -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 +5 -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 +113 -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,35 @@
1
- # Temporary Holding Version
1
+ # AIAUI module templates
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
+ 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
@@ -0,0 +1,6 @@
1
+ export class GeneratorError extends Error {
2
+ constructor(code, message) {
3
+ super(message);
4
+ this.code = code;
5
+ }
6
+ }
@@ -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
+ }