@uibro/cli 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.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runNativeHost } from "../src/host-main.js";
4
+
5
+ try {
6
+ await runNativeHost({ workspace: process.env.UIBRO_PROJECT_WORKSPACE });
7
+ } catch (error) {
8
+ process.stderr.write(`[uibro] project host failed: ${error instanceof Error ? error.message : String(error)}\n`);
9
+ process.exitCode = 1;
10
+ }
package/bin/uibro.js ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { initializeProject } from "../src/config.js";
4
+ import { writeProjectLauncher } from "../src/launcher.js";
5
+ import { registerNativeManifest } from "../src/native-manifest.js";
6
+ import { ensureProjectHostBinary } from "../src/project-install.js";
7
+
8
+ function usage() {
9
+ return "Usage: uibro init --origin <http(s)://origin>";
10
+ }
11
+
12
+ function parse(args) {
13
+ if (args[0] === "init" && args.length === 3 && args[1] === "--origin") {
14
+ return { origin: args[2] };
15
+ }
16
+ throw new Error(usage());
17
+ }
18
+
19
+ try {
20
+ const options = parse(process.argv.slice(2));
21
+ const initialized = await initializeProject(process.cwd(), options);
22
+ const hostBin = await ensureProjectHostBinary(initialized.workspace);
23
+ const launcherPath = await writeProjectLauncher(initialized.workspace, hostBin);
24
+ const registration = await registerNativeManifest({
25
+ projectId: initialized.config.projectId,
26
+ launcherPath,
27
+ });
28
+ const meta = `<meta name="uibro-project" content="${initialized.config.projectId}">`;
29
+ process.stdout.write(`UIBro initialized for ${initialized.config.projectName}.\n${meta}\nNative host: ${registration.name}\n`);
30
+ } catch (error) {
31
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
32
+ process.exitCode = 1;
33
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@uibro/cli",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "uibro": "bin/uibro.js",
7
+ "uibro-project-host": "bin/uibro-project-host.js"
8
+ },
9
+ "files": ["bin", "src"],
10
+ "dependencies": {
11
+ "@uibro/bridge": "0.1.0"
12
+ },
13
+ "engines": {
14
+ "node": ">=22"
15
+ },
16
+ "scripts": {
17
+ "build": "node --check bin/uibro.js && node --check bin/uibro-project-host.js",
18
+ "check": "node --test test/*.test.js",
19
+ "test": "node --test test/*.test.js"
20
+ }
21
+ }
package/src/config.js ADDED
@@ -0,0 +1,62 @@
1
+ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { assertProjectId, createProjectId, normalizeOrigin } from "./identity.js";
4
+
5
+ export const CONFIG_RELATIVE_PATH = join(".uibro", "project.json");
6
+
7
+ async function inferredName(workspace) {
8
+ try {
9
+ const pkg = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
10
+ if (typeof pkg.name === "string" && pkg.name.trim()) return pkg.name.trim().slice(0, 128);
11
+ } catch {}
12
+ return basename(workspace);
13
+ }
14
+
15
+ export async function readProjectConfig(workspace) {
16
+ const path = join(resolve(workspace), CONFIG_RELATIVE_PATH);
17
+ const value = JSON.parse(await readFile(path, "utf8"));
18
+ assertProjectId(value.projectId);
19
+ if (value.protocolVersion !== 1) throw new Error("Unsupported UIBro protocolVersion.");
20
+ if (typeof value.projectName !== "string" || !value.projectName.trim()) {
21
+ throw new Error("Invalid UIBro projectName.");
22
+ }
23
+ const allowedOrigins = value.allowedOrigins?.map(normalizeOrigin);
24
+ return { ...value, ...(allowedOrigins ? { allowedOrigins } : {}) };
25
+ }
26
+
27
+ export async function initializeProject(workspace, { origin } = {}) {
28
+ const absolute = await realpath(resolve(workspace));
29
+ const path = join(absolute, CONFIG_RELATIVE_PATH);
30
+ let existing;
31
+ try {
32
+ existing = await readProjectConfig(absolute);
33
+ } catch (error) {
34
+ if (error?.code !== "ENOENT") throw error;
35
+ }
36
+ const origins = new Set(existing?.allowedOrigins || []);
37
+ if (origin) origins.add(normalizeOrigin(origin));
38
+ const config = {
39
+ projectId: existing?.projectId || createProjectId(),
40
+ protocolVersion: 1,
41
+ projectName: existing?.projectName || await inferredName(absolute),
42
+ ...(origins.size ? { allowedOrigins: [...origins].sort() } : {}),
43
+ };
44
+ await mkdir(dirname(path), { recursive: true });
45
+ await writeFile(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
46
+ await ensureLocalRuntimeIgnored(dirname(path));
47
+ return { workspace: absolute, path, config };
48
+ }
49
+
50
+ async function ensureLocalRuntimeIgnored(directory) {
51
+ const path = join(directory, ".gitignore");
52
+ let lines = [];
53
+ try {
54
+ lines = (await readFile(path, "utf8")).split(/\r?\n/).filter(Boolean);
55
+ } catch (error) {
56
+ if (error?.code !== "ENOENT") throw error;
57
+ }
58
+ for (const entry of ["/native-host.mjs", "/project.json"]) {
59
+ if (!lines.includes(entry)) lines.push(entry);
60
+ }
61
+ await writeFile(path, `${lines.join("\n")}\n`, { mode: 0o600 });
62
+ }
@@ -0,0 +1,86 @@
1
+ import { projectIdentity } from "./identity.js";
2
+
3
+ async function loadBridgeRuntime() {
4
+ try {
5
+ return (await import("@uibro/bridge/src/bridge-runtime.js")).createBridgeRuntime;
6
+ } catch (error) {
7
+ if (error?.code !== "ERR_MODULE_NOT_FOUND") throw error;
8
+ return (await import("../../../apps/bridge/src/bridge-runtime.js")).createBridgeRuntime;
9
+ }
10
+ }
11
+
12
+ export class HostController {
13
+ #runtime;
14
+ #bridgeOrigin;
15
+ #state = "disconnected";
16
+ constructor({ workspace, config, runtimeFactory } = {}) {
17
+ this.workspace = workspace;
18
+ this.config = config;
19
+ this.runtimeFactory = runtimeFactory;
20
+ this.project = projectIdentity(workspace, config.projectId, config.projectName);
21
+ }
22
+
23
+ response(id, ok, error) {
24
+ return {
25
+ id,
26
+ ok,
27
+ projectId: this.project.id,
28
+ protocolVersion: 1,
29
+ state: this.#state,
30
+ project: this.project,
31
+ ...(this.#bridgeOrigin ? {
32
+ bridgeOrigin: this.#bridgeOrigin,
33
+ endpoint: this.#bridgeOrigin,
34
+ } : {}),
35
+ ...(error ? { error } : {}),
36
+ };
37
+ }
38
+
39
+ async handle(message) {
40
+ try {
41
+ if (message.type === "status") return this.response(message.id, true);
42
+ if (message.type === "disconnect") await this.stop();
43
+ if (message.type === "reconnect") {
44
+ await this.stop();
45
+ await this.start();
46
+ }
47
+ if (message.type === "connect") await this.start();
48
+ return this.response(message.id, true);
49
+ } catch (error) {
50
+ await this.stop("offline");
51
+ return this.response(
52
+ message.id,
53
+ false,
54
+ error instanceof Error ? error.message : String(error),
55
+ );
56
+ }
57
+ }
58
+
59
+ async start() {
60
+ if (this.#runtime && this.#state === "connected") return;
61
+ const factory = this.runtimeFactory || await loadBridgeRuntime();
62
+ const environment = { ...process.env, UIBRO_WORKSPACE: this.workspace };
63
+ const runtime = factory({ environment });
64
+ try {
65
+ await runtime.start({ port: 0 });
66
+ const address = runtime.httpServer.address();
67
+ if (!address || typeof address === "string" || !Number.isInteger(address.port)) {
68
+ throw new Error("UIBro Helper did not expose an isolated loopback port.");
69
+ }
70
+ this.#runtime = runtime;
71
+ this.#bridgeOrigin = `http://127.0.0.1:${address.port}`;
72
+ this.#state = "connected";
73
+ } catch (error) {
74
+ await runtime.close().catch(() => {});
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ async stop(state = "disconnected") {
80
+ const runtime = this.#runtime;
81
+ this.#runtime = undefined;
82
+ this.#bridgeOrigin = undefined;
83
+ if (runtime) await runtime.close();
84
+ this.#state = state;
85
+ }
86
+ }
@@ -0,0 +1,38 @@
1
+ import { readProjectConfig } from "./config.js";
2
+ import { HostController } from "./host-controller.js";
3
+ import { createNativeDecoder, encodeNativeMessage } from "./native-io.js";
4
+ import { validateHostMessage } from "./message.js";
5
+
6
+ export async function runNativeHost({ workspace, input = process.stdin, output = process.stdout } = {}) {
7
+ if (typeof workspace !== "string" || !workspace) throw new Error("UIBRO_PROJECT_WORKSPACE is required.");
8
+ const config = await readProjectConfig(workspace);
9
+ const controller = new HostController({ workspace, config });
10
+ let chain = Promise.resolve();
11
+ const send = (value) => output.write(encodeNativeMessage(value));
12
+ const errorResponse = (id, error) => ({
13
+ id,
14
+ ok: false,
15
+ projectId: config.projectId,
16
+ protocolVersion: 1,
17
+ state: "offline",
18
+ project: controller.project,
19
+ error: error.message,
20
+ });
21
+ const onError = (error) => send(errorResponse("invalid", error));
22
+ const decode = createNativeDecoder((raw) => {
23
+ chain = chain.then(async () => {
24
+ let message;
25
+ try {
26
+ message = validateHostMessage(raw, config);
27
+ } catch (error) {
28
+ send(errorResponse(typeof raw?.id === "string" ? raw.id : "invalid", error));
29
+ return;
30
+ }
31
+ send(await controller.handle(message));
32
+ });
33
+ }, onError);
34
+ input.on("data", decode);
35
+ input.on("end", () => { chain = chain.then(() => controller.stop()); });
36
+ input.on("error", () => { chain = chain.then(() => controller.stop()); });
37
+ return { controller, closed: chain };
38
+ }
@@ -0,0 +1,45 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { basename, resolve } from "node:path";
3
+
4
+ export const EXTENSION_ID = "imoooahdjjjgpnffkalfaglbpdhlglnb";
5
+ export const PROJECT_ID_PATTERN = /^uibro-([0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$/;
6
+
7
+ export function createProjectId(uuid = randomUUID()) {
8
+ const projectId = `uibro-${uuid.toLowerCase()}`;
9
+ assertProjectId(projectId);
10
+ return projectId;
11
+ }
12
+
13
+ export function assertProjectId(value) {
14
+ if (typeof value !== "string" || !PROJECT_ID_PATTERN.test(value)) {
15
+ throw new Error("Invalid UIBro projectId.");
16
+ }
17
+ return value;
18
+ }
19
+
20
+ export function nativeHostName(projectId) {
21
+ const uuid = assertProjectId(projectId).slice("uibro-".length);
22
+ return `com.uibro.project_${uuid.replaceAll("-", "_")}`;
23
+ }
24
+
25
+ export function projectIdentity(workspace, projectId, projectName = basename(workspace)) {
26
+ const absolute = resolve(workspace);
27
+ const fingerprint = createHash("sha256").update(absolute).digest("hex").slice(0, 12);
28
+ if (typeof projectName !== "string" || !projectName.trim() || projectName.length > 128) {
29
+ throw new Error("Invalid UIBro projectName.");
30
+ }
31
+ return { id: assertProjectId(projectId), name: projectName.trim(), fingerprint };
32
+ }
33
+
34
+ export function normalizeOrigin(value) {
35
+ let url;
36
+ try {
37
+ url = new URL(value);
38
+ } catch {
39
+ throw new Error("pageOrigin must be a valid HTTP(S) origin.");
40
+ }
41
+ if (!new Set(["http:", "https:"]).has(url.protocol) || url.origin !== value) {
42
+ throw new Error("pageOrigin must be an exact HTTP(S) origin.");
43
+ }
44
+ return url.origin;
45
+ }
@@ -0,0 +1,16 @@
1
+ import { chmod, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ export async function writeProjectLauncher(workspace, hostBin = fileURLToPath(
6
+ new URL("../bin/uibro-project-host.js", import.meta.url),
7
+ ), nodePath = process.execPath) {
8
+ if (typeof nodePath !== "string" || !nodePath.startsWith("/") || /[\r\n]/.test(nodePath)) {
9
+ throw new Error("UIBro needs an absolute Node.js executable path.");
10
+ }
11
+ const path = join(workspace, ".uibro", "native-host.mjs");
12
+ const source = `#!${nodePath}\nprocess.env.UIBRO_PROJECT_WORKSPACE = ${JSON.stringify(workspace)};\nawait import(${JSON.stringify(pathToFileURL(hostBin).href)});\n`;
13
+ await writeFile(path, source, { mode: 0o700 });
14
+ await chmod(path, 0o700);
15
+ return path;
16
+ }
package/src/message.js ADDED
@@ -0,0 +1,25 @@
1
+ import { assertProjectId, normalizeOrigin } from "./identity.js";
2
+
3
+ const TYPES = new Set(["status", "connect", "reconnect", "disconnect"]);
4
+
5
+ export function validateHostMessage(value, config) {
6
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Message must be an object.");
7
+ if (typeof value.id !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(value.id)) {
8
+ throw new Error("Message id is invalid.");
9
+ }
10
+ if (!TYPES.has(value.type)) throw new Error("Message type is invalid.");
11
+ if (value.protocolVersion !== 1) throw new Error("Message protocolVersion is unsupported.");
12
+ assertProjectId(value.projectId);
13
+ if (value.projectId !== config.projectId) throw new Error("Message targets a different UIBro project.");
14
+ const pageOrigin = normalizeOrigin(value.pageOrigin);
15
+ if (config.allowedOrigins?.length && !config.allowedOrigins.includes(pageOrigin)) {
16
+ throw new Error("This page origin is not allowed for the UIBro project.");
17
+ }
18
+ return {
19
+ id: value.id,
20
+ type: value.type,
21
+ projectId: value.projectId,
22
+ protocolVersion: 1,
23
+ pageOrigin,
24
+ };
25
+ }
@@ -0,0 +1,28 @@
1
+ const MAX_MESSAGE_BYTES = 1024 * 1024;
2
+
3
+ export function encodeNativeMessage(value) {
4
+ const body = Buffer.from(JSON.stringify(value));
5
+ if (body.length > MAX_MESSAGE_BYTES) throw new Error("Native message is too large.");
6
+ const header = Buffer.alloc(4);
7
+ header.writeUInt32LE(body.length);
8
+ return Buffer.concat([header, body]);
9
+ }
10
+
11
+ export function createNativeDecoder(onMessage, onError) {
12
+ let buffer = Buffer.alloc(0);
13
+ return (chunk) => {
14
+ buffer = Buffer.concat([buffer, chunk]);
15
+ while (buffer.length >= 4) {
16
+ const length = buffer.readUInt32LE(0);
17
+ if (length > MAX_MESSAGE_BYTES) return onError(new Error("Native message exceeds 1 MiB."));
18
+ if (buffer.length < length + 4) return;
19
+ const body = buffer.subarray(4, length + 4);
20
+ buffer = buffer.subarray(length + 4);
21
+ try {
22
+ onMessage(JSON.parse(body.toString("utf8")));
23
+ } catch {
24
+ onError(new Error("Native message contains invalid JSON."));
25
+ }
26
+ }
27
+ };
28
+ }
@@ -0,0 +1,43 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+ import { EXTENSION_ID, nativeHostName } from "./identity.js";
6
+
7
+ export function chromeManifestDirectory({ platform = process.platform, env = process.env } = {}) {
8
+ if (env.UIBRO_NATIVE_MANIFEST_DIR) return resolve(env.UIBRO_NATIVE_MANIFEST_DIR);
9
+ const home = env.HOME || env.USERPROFILE || homedir();
10
+ if (platform === "darwin") {
11
+ return join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
12
+ }
13
+ if (platform === "linux") return join(home, ".config", "google-chrome", "NativeMessagingHosts");
14
+ if (platform === "win32") return join(env.LOCALAPPDATA || home, "UIBro", "NativeMessagingHosts");
15
+ throw new Error(`Unsupported platform for Chrome Native Messaging: ${platform}`);
16
+ }
17
+
18
+ function run(command, args) {
19
+ return new Promise((accept, reject) => {
20
+ const child = spawn(command, args, { shell: false, stdio: "ignore" });
21
+ child.once("error", reject);
22
+ child.once("exit", (code) => code === 0 ? accept() : reject(new Error(`${command} exited with ${code}`)));
23
+ });
24
+ }
25
+
26
+ export async function registerNativeManifest({ projectId, launcherPath, platform = process.platform, env = process.env }) {
27
+ const name = nativeHostName(projectId);
28
+ const path = join(chromeManifestDirectory({ platform, env }), `${name}.json`);
29
+ const manifest = {
30
+ name,
31
+ description: "UIBro project-local host",
32
+ path: resolve(launcherPath),
33
+ type: "stdio",
34
+ allowed_origins: [`chrome-extension://${EXTENSION_ID}/`],
35
+ };
36
+ await mkdir(dirname(path), { recursive: true });
37
+ await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
38
+ if (platform === "win32" && !env.UIBRO_NATIVE_MANIFEST_DIR) {
39
+ const key = `HKCU\\Software\\Google\\Chrome\\NativeMessagingHosts\\${name}`;
40
+ await run("reg.exe", ["add", key, "/ve", "/t", "REG_SZ", "/d", path, "/f"]);
41
+ }
42
+ return { name, path, manifest };
43
+ }
@@ -0,0 +1,56 @@
1
+ import { spawn } from "node:child_process";
2
+ import { access, readFile, realpath } from "node:fs/promises";
3
+ import { dirname, join, relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const sourcePackageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
+
8
+ function runNpmInstall(workspace, spec) {
9
+ const command = process.platform === "win32" ? "npm.cmd" : "npm";
10
+ return new Promise((accept, reject) => {
11
+ const child = spawn(command, ["install", "--save-dev", "--ignore-scripts", spec], {
12
+ cwd: workspace,
13
+ shell: false,
14
+ stdio: "inherit",
15
+ });
16
+ child.once("error", reject);
17
+ child.once("exit", (code) => code === 0
18
+ ? accept()
19
+ : reject(new Error(`npm install for ${spec} exited with ${code}.`)));
20
+ });
21
+ }
22
+
23
+ async function packageVersion() {
24
+ const pkg = JSON.parse(await readFile(join(sourcePackageRoot, "package.json"), "utf8"));
25
+ if (typeof pkg.version !== "string" || !/^\d+\.\d+\.\d+/.test(pkg.version)) {
26
+ throw new Error("@uibro/cli has an invalid package version.");
27
+ }
28
+ return pkg.version;
29
+ }
30
+
31
+ function isInside(workspace, path) {
32
+ const child = relative(workspace, path);
33
+ return child === "" || (!child.startsWith("..") && !child.startsWith("/"));
34
+ }
35
+
36
+ export async function ensureProjectHostBinary(workspace, { install = runNpmInstall } = {}) {
37
+ const target = await realpath(workspace);
38
+ const installed = join(target, "node_modules", "@uibro", "cli", "bin", "uibro-project-host.js");
39
+ try {
40
+ await access(installed);
41
+ const path = await realpath(installed);
42
+ if (isInside(target, path)) return path;
43
+ } catch {}
44
+ if (isInside(target, sourcePackageRoot)) {
45
+ return realpath(join(sourcePackageRoot, "bin", "uibro-project-host.js"));
46
+ }
47
+ const spec = `@uibro/cli@${await packageVersion()}`;
48
+ await install(target, spec);
49
+ try {
50
+ const path = await realpath(installed);
51
+ if (!isInside(target, path)) throw new Error("Installed project host escaped the workspace.");
52
+ return path;
53
+ } catch (error) {
54
+ throw new Error("@uibro/cli was not installed inside the target project.", { cause: error });
55
+ }
56
+ }