pllla-connect 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/dist/account.js +24 -0
- package/dist/catalog.js +58 -0
- package/dist/detect.js +176 -0
- package/dist/exec.js +161 -0
- package/dist/main.js +397 -0
- package/dist/nodeProvision.js +269 -0
- package/dist/nodeResolve.js +84 -0
- package/dist/openclaw.js +338 -0
- package/dist/progress.js +74 -0
- package/dist/version.js +105 -0
- package/package.json +22 -0
package/dist/account.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridge account id (docs/agent/EXTERNAL_RUNTIME.md §6.3) — one gateway can
|
|
3
|
+
* host N PLLLA agents, each as its own `channels.pllla.accounts.<id>` lane.
|
|
4
|
+
* The default id is derived from the pairing token so re-running the same
|
|
5
|
+
* connect command targets the same account instead of minting a new lane.
|
|
6
|
+
*
|
|
7
|
+
* Lives outside main.ts so tests can import it without executing the CLI.
|
|
8
|
+
*/
|
|
9
|
+
export const PAIRING_TOKEN_PREFIX = "pair_live_";
|
|
10
|
+
const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
11
|
+
export function isValidAccountId(value) {
|
|
12
|
+
return ACCOUNT_ID_PATTERN.test(value);
|
|
13
|
+
}
|
|
14
|
+
/** `agent-` + the first 8 hex characters of the token body. */
|
|
15
|
+
export function deriveAccountId(pairingToken) {
|
|
16
|
+
const body = pairingToken.startsWith(PAIRING_TOKEN_PREFIX)
|
|
17
|
+
? pairingToken.slice(PAIRING_TOKEN_PREFIX.length)
|
|
18
|
+
: pairingToken;
|
|
19
|
+
const head = body.slice(0, 8).toLowerCase();
|
|
20
|
+
if (!/^[0-9a-f]{8}$/.test(head)) {
|
|
21
|
+
throw new Error("The pairing token looks malformed — copy the full command from the connect card in PLLLA.");
|
|
22
|
+
}
|
|
23
|
+
return `agent-${head}`;
|
|
24
|
+
}
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-driven runtime catalog — the connector never hardcodes runtime
|
|
3
|
+
* knowledge beyond the per-runtime bridge adapters. The list of runtimes,
|
|
4
|
+
* how to detect them, how to install them, and which Node they need all come
|
|
5
|
+
* from the server (docs/agent/EXTERNAL_RUNTIME.md), so a runtime raising its
|
|
6
|
+
* floor reaches every installed connector without a release.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors `ExternalRuntimeDescriptor` in
|
|
9
|
+
* `src/data/constants/externalRuntimeCatalog.ts` — keep the two in step.
|
|
10
|
+
*/
|
|
11
|
+
function isRecord(value) {
|
|
12
|
+
return typeof value === "object" && value !== null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Wire-shape guard. `runtime` arrived in the same contract generation as an
|
|
16
|
+
* additive field; a server without it cannot drive provisioning, so the
|
|
17
|
+
* connector refuses loudly instead of guessing a Node version.
|
|
18
|
+
*/
|
|
19
|
+
export function isCatalogRuntime(value) {
|
|
20
|
+
if (!isRecord(value))
|
|
21
|
+
return false;
|
|
22
|
+
const { detect, install, bridge, runtime } = value;
|
|
23
|
+
return (typeof value.id === "string" &&
|
|
24
|
+
typeof value.label === "string" &&
|
|
25
|
+
isRecord(detect) &&
|
|
26
|
+
Array.isArray(detect.commands) &&
|
|
27
|
+
Array.isArray(detect.gatewayPorts) &&
|
|
28
|
+
isRecord(install) &&
|
|
29
|
+
typeof install.command === "string" &&
|
|
30
|
+
isRecord(bridge) &&
|
|
31
|
+
typeof bridge.package === "string" &&
|
|
32
|
+
isRecord(runtime) &&
|
|
33
|
+
typeof runtime.nodeRange === "string" &&
|
|
34
|
+
typeof runtime.provisionNodeMajor === "number" &&
|
|
35
|
+
typeof runtime.minVersion === "string");
|
|
36
|
+
}
|
|
37
|
+
export async function fetchRuntimeCatalog(serverOrigin) {
|
|
38
|
+
const response = await fetch(`${serverOrigin}/api/user/ai-agent/external-runtimes`);
|
|
39
|
+
const data = await response.json().catch(() => null);
|
|
40
|
+
if (!response.ok ||
|
|
41
|
+
!isRecord(data) ||
|
|
42
|
+
data.success !== true ||
|
|
43
|
+
!Array.isArray(data.runtimes)) {
|
|
44
|
+
throw new Error(`Could not load the runtime catalog from ${serverOrigin} (HTTP ${response.status}).`);
|
|
45
|
+
}
|
|
46
|
+
const runtimes = [];
|
|
47
|
+
for (const entry of data.runtimes) {
|
|
48
|
+
if (!isCatalogRuntime(entry)) {
|
|
49
|
+
const id = isRecord(entry) && typeof entry.id === "string" ? entry.id : "?";
|
|
50
|
+
throw new Error(`Runtime catalog entry "${id}" is missing prerequisites (runtime.nodeRange/provisionNodeMajor/minVersion) — the server at ${serverOrigin} predates this connector.`);
|
|
51
|
+
}
|
|
52
|
+
runtimes.push(entry);
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
contractVersion: typeof data.contractVersion === "number" ? data.contractVersion : 0,
|
|
56
|
+
runtimes,
|
|
57
|
+
};
|
|
58
|
+
}
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime detection — never trusts PATH alone (docs/agent/EXTERNAL_RUNTIME.md
|
|
3
|
+
* §6.2). GUI apps do not inherit the shell PATH (nvm/npm-global/homebrew), so
|
|
4
|
+
* a bare `openclaw` lookup misses an nvm-installed OpenClaw and the connector
|
|
5
|
+
* would wrongly start a second install. Detection order:
|
|
6
|
+
*
|
|
7
|
+
* 1. state dir `~/.openclaw/openclaw.json` (`OPENCLAW_STATE_DIR` respected)
|
|
8
|
+
* 2. a live gateway on the runtime's known ports
|
|
9
|
+
* 3. candidate bin dirs: PATH, the PLLLA private prefix, every nvm node,
|
|
10
|
+
* homebrew, /usr/local, ~/.npm-global, ~/.local
|
|
11
|
+
*
|
|
12
|
+
* Every later CLI call uses the detected `binaryPath`; nothing here or
|
|
13
|
+
* downstream ever spawns a bare command name.
|
|
14
|
+
*/
|
|
15
|
+
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { accessSync, constants, existsSync, readdirSync, statSync, } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { prependPath } from "./exec.js";
|
|
20
|
+
import { compareVersions, parseOpenClawVersion, parseVersion, } from "./version.js";
|
|
21
|
+
export function resolveStateDir(env = process.env, home = homedir()) {
|
|
22
|
+
return env.OPENCLAW_STATE_DIR || join(home, ".openclaw");
|
|
23
|
+
}
|
|
24
|
+
/** Per-runtime config file that proves an install; null for unknown runtimes. */
|
|
25
|
+
export function resolveStateFile(runtimeId, env = process.env, home = homedir()) {
|
|
26
|
+
if (runtimeId === "openclaw") {
|
|
27
|
+
return join(resolveStateDir(env, home), "openclaw.json");
|
|
28
|
+
}
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
/** `~/.pllla/runtimes/<runtimeId>` — the `npm install -g --prefix` target. */
|
|
32
|
+
export function privateRuntimePrefix(home, runtimeId) {
|
|
33
|
+
return join(home, ".pllla", "runtimes", runtimeId);
|
|
34
|
+
}
|
|
35
|
+
/** `~/.nvm/versions/node/<v>/bin`, newest version first. */
|
|
36
|
+
export function nvmNodeBinDirs(home) {
|
|
37
|
+
const versionsDir = join(home, ".nvm", "versions", "node");
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = readdirSync(versionsDir);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
return entries
|
|
46
|
+
.map((name) => ({ name, version: parseVersion(name) }))
|
|
47
|
+
.filter((entry) => entry.version !== null)
|
|
48
|
+
.sort((a, b) => compareVersions(b.version, a.version))
|
|
49
|
+
.map((entry) => join(versionsDir, entry.name, "bin"));
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Pure candidate computation (home + PATH injected) so the order in §6.2 is
|
|
53
|
+
* testable without a real machine. Duplicates and empties are dropped.
|
|
54
|
+
*/
|
|
55
|
+
export function candidateBinDirs(params) {
|
|
56
|
+
const { home, pathEnv, runtimeId } = params;
|
|
57
|
+
const platform = params.platform ?? process.platform;
|
|
58
|
+
const pathDelimiter = platform === "win32" ? ";" : ":";
|
|
59
|
+
const ordered = [
|
|
60
|
+
...(pathEnv ?? "").split(pathDelimiter),
|
|
61
|
+
join(privateRuntimePrefix(home, runtimeId), "bin"),
|
|
62
|
+
...nvmNodeBinDirs(home),
|
|
63
|
+
"/opt/homebrew/bin",
|
|
64
|
+
"/usr/local/bin",
|
|
65
|
+
join(home, ".npm-global", "bin"),
|
|
66
|
+
join(home, ".local", "bin"),
|
|
67
|
+
];
|
|
68
|
+
const unique = [];
|
|
69
|
+
for (const dir of ordered) {
|
|
70
|
+
if (dir && !unique.includes(dir))
|
|
71
|
+
unique.push(dir);
|
|
72
|
+
}
|
|
73
|
+
return unique;
|
|
74
|
+
}
|
|
75
|
+
function isExecutableFile(path, platform) {
|
|
76
|
+
try {
|
|
77
|
+
if (!statSync(path).isFile())
|
|
78
|
+
return false;
|
|
79
|
+
if (platform !== "win32")
|
|
80
|
+
accessSync(path, constants.X_OK);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** First `<dir>/<name>` that is an executable file (`.cmd`/`.exe` on Windows). */
|
|
88
|
+
export function findExecutable(name, dirs, platform = process.platform) {
|
|
89
|
+
const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
|
|
90
|
+
for (const dir of dirs) {
|
|
91
|
+
for (const candidate of names) {
|
|
92
|
+
const path = join(dir, candidate);
|
|
93
|
+
if (isExecutableFile(path, platform))
|
|
94
|
+
return path;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* `<binaryPath> --version` → `X.Y.Z`. The env must put a usable `node` on
|
|
101
|
+
* PATH first — the runtime CLI is a `#!/usr/bin/env node` script.
|
|
102
|
+
*/
|
|
103
|
+
export function probeRuntimeVersion(binaryPath, env) {
|
|
104
|
+
try {
|
|
105
|
+
const output = execFileSync(binaryPath, ["--version"], {
|
|
106
|
+
encoding: "utf8",
|
|
107
|
+
env,
|
|
108
|
+
timeout: 20_000,
|
|
109
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
+
windowsHide: true,
|
|
111
|
+
});
|
|
112
|
+
return parseOpenClawVersion(output);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function portAnswers(port) {
|
|
119
|
+
try {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const timer = setTimeout(() => controller.abort(), 1_500);
|
|
122
|
+
const response = await fetch(`http://127.0.0.1:${port}/`, {
|
|
123
|
+
signal: controller.signal,
|
|
124
|
+
});
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
return response.status < 500;
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
export async function detectRuntime(runtime, options = {}) {
|
|
133
|
+
const home = options.home ?? homedir();
|
|
134
|
+
const env = options.env ?? process.env;
|
|
135
|
+
const platform = options.platform ?? process.platform;
|
|
136
|
+
const stateFile = resolveStateFile(runtime.id, env, home);
|
|
137
|
+
const stateDirFound = stateFile !== null && existsSync(stateFile);
|
|
138
|
+
let gatewayLive = false;
|
|
139
|
+
for (const port of runtime.detect.gatewayPorts) {
|
|
140
|
+
if (await portAnswers(port)) {
|
|
141
|
+
gatewayLive = true;
|
|
142
|
+
break;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const dirs = candidateBinDirs({
|
|
146
|
+
home,
|
|
147
|
+
pathEnv: env.PATH,
|
|
148
|
+
runtimeId: runtime.id,
|
|
149
|
+
platform,
|
|
150
|
+
});
|
|
151
|
+
let binaryPath = null;
|
|
152
|
+
for (const command of runtime.detect.commands) {
|
|
153
|
+
binaryPath = findExecutable(command, dirs, platform);
|
|
154
|
+
if (binaryPath)
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
let version = null;
|
|
158
|
+
if (binaryPath) {
|
|
159
|
+
// The CLI's own dir first (an nvm install pairs with its sibling node),
|
|
160
|
+
// then whatever private/system node the caller already knows about.
|
|
161
|
+
const probeEnv = prependPath(env, [
|
|
162
|
+
dirname(binaryPath),
|
|
163
|
+
...(options.extraBinDirs ?? []),
|
|
164
|
+
...dirs,
|
|
165
|
+
]);
|
|
166
|
+
version = probeRuntimeVersion(binaryPath, probeEnv);
|
|
167
|
+
}
|
|
168
|
+
return { runtime, binaryPath, version, gatewayLive, stateDirFound };
|
|
169
|
+
}
|
|
170
|
+
export async function detectRuntimes(runtimes, options = {}) {
|
|
171
|
+
const results = [];
|
|
172
|
+
for (const runtime of runtimes) {
|
|
173
|
+
results.push(await detectRuntime(runtime, options));
|
|
174
|
+
}
|
|
175
|
+
return results;
|
|
176
|
+
}
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subprocess helpers — every external command the connector runs goes
|
|
3
|
+
* through here so timeouts, output streaming, and PATH shaping are uniform.
|
|
4
|
+
*
|
|
5
|
+
* PATH matters more than usual: GUI-launched processes (the desktop app) do
|
|
6
|
+
* not inherit nvm/npm-global/homebrew PATH entries, and the `openclaw` bin
|
|
7
|
+
* is a `#!/usr/bin/env node` script — it cannot even start unless a `node`
|
|
8
|
+
* is reachable on the PATH we hand it (§6.1 "private first + verified").
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { delimiter } from "node:path";
|
|
12
|
+
export class CommandFailedError extends Error {
|
|
13
|
+
file;
|
|
14
|
+
args;
|
|
15
|
+
exitCode;
|
|
16
|
+
stdout;
|
|
17
|
+
stderr;
|
|
18
|
+
constructor(params) {
|
|
19
|
+
super(params.reason);
|
|
20
|
+
this.name = "CommandFailedError";
|
|
21
|
+
this.file = params.file;
|
|
22
|
+
this.args = params.args;
|
|
23
|
+
this.exitCode = params.exitCode;
|
|
24
|
+
this.stdout = params.stdout;
|
|
25
|
+
this.stderr = params.stderr;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Returns `env` with `dirs` prepended to PATH (deduplicated, empties dropped). */
|
|
29
|
+
export function prependPath(env, dirs) {
|
|
30
|
+
const existing = (env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
31
|
+
const merged = [];
|
|
32
|
+
for (const dir of [...dirs, ...existing]) {
|
|
33
|
+
if (dir && !merged.includes(dir))
|
|
34
|
+
merged.push(dir);
|
|
35
|
+
}
|
|
36
|
+
return { ...env, PATH: merged.join(delimiter) };
|
|
37
|
+
}
|
|
38
|
+
export function firstLine(text) {
|
|
39
|
+
return text.split("\n").find((line) => line.trim().length > 0) ?? "";
|
|
40
|
+
}
|
|
41
|
+
/** POSIX single-quote when needed — for `manualCommand` strings a person pastes. */
|
|
42
|
+
export function shellQuote(value) {
|
|
43
|
+
return /^[A-Za-z0-9_./:=@%+,-]+$/.test(value)
|
|
44
|
+
? value
|
|
45
|
+
: `'${value.replace(/'/g, `'\\''`)}'`;
|
|
46
|
+
}
|
|
47
|
+
/** The most useful single line out of a failed command for a user message. */
|
|
48
|
+
export function describeCommandFailure(error) {
|
|
49
|
+
if (error instanceof CommandFailedError) {
|
|
50
|
+
const tail = [error.stderr, error.stdout]
|
|
51
|
+
.map((text) => text.trim().split("\n").filter(Boolean).slice(-1)[0] ?? "")
|
|
52
|
+
.find((line) => line.length > 0);
|
|
53
|
+
return tail ? `${error.message}: ${tail}` : error.message;
|
|
54
|
+
}
|
|
55
|
+
return error instanceof Error ? error.message : String(error);
|
|
56
|
+
}
|
|
57
|
+
function forwardLines(onLine) {
|
|
58
|
+
let pending = "";
|
|
59
|
+
return {
|
|
60
|
+
push: (chunk) => {
|
|
61
|
+
if (!onLine)
|
|
62
|
+
return;
|
|
63
|
+
pending += chunk.toString("utf8");
|
|
64
|
+
const lines = pending.split(/\r?\n/);
|
|
65
|
+
pending = lines.pop() ?? "";
|
|
66
|
+
for (const line of lines)
|
|
67
|
+
if (line.trim())
|
|
68
|
+
onLine(line);
|
|
69
|
+
},
|
|
70
|
+
flush: () => {
|
|
71
|
+
if (onLine && pending.trim())
|
|
72
|
+
onLine(pending);
|
|
73
|
+
pending = "";
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Runs a command to completion, streaming output. Rejects with
|
|
79
|
+
* `CommandFailedError` on a non-zero exit, a spawn error, or a timeout
|
|
80
|
+
* (SIGTERM, then SIGKILL five seconds later).
|
|
81
|
+
*/
|
|
82
|
+
export function runCommand(options) {
|
|
83
|
+
const { file, args, env, cwd, timeoutMs, onLine } = options;
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
const useShell = process.platform === "win32" && /\.(cmd|bat)$/i.test(file);
|
|
86
|
+
const child = spawn(file, args, {
|
|
87
|
+
cwd,
|
|
88
|
+
env: env ?? process.env,
|
|
89
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
90
|
+
windowsHide: true,
|
|
91
|
+
shell: useShell,
|
|
92
|
+
});
|
|
93
|
+
let stdout = "";
|
|
94
|
+
let stderr = "";
|
|
95
|
+
let settled = false;
|
|
96
|
+
let timedOut = false;
|
|
97
|
+
const out = forwardLines(onLine);
|
|
98
|
+
const err = forwardLines(onLine);
|
|
99
|
+
const timer = setTimeout(() => {
|
|
100
|
+
timedOut = true;
|
|
101
|
+
child.kill("SIGTERM");
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
if (!settled)
|
|
104
|
+
child.kill("SIGKILL");
|
|
105
|
+
}, 5_000).unref();
|
|
106
|
+
}, timeoutMs);
|
|
107
|
+
child.stdout?.on("data", (chunk) => {
|
|
108
|
+
stdout += chunk.toString("utf8");
|
|
109
|
+
out.push(chunk);
|
|
110
|
+
});
|
|
111
|
+
child.stderr?.on("data", (chunk) => {
|
|
112
|
+
stderr += chunk.toString("utf8");
|
|
113
|
+
err.push(chunk);
|
|
114
|
+
});
|
|
115
|
+
child.on("error", (error) => {
|
|
116
|
+
if (settled)
|
|
117
|
+
return;
|
|
118
|
+
settled = true;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
reject(new CommandFailedError({
|
|
121
|
+
file,
|
|
122
|
+
args,
|
|
123
|
+
exitCode: null,
|
|
124
|
+
stdout,
|
|
125
|
+
stderr,
|
|
126
|
+
reason: `Could not run ${file}: ${error.message}`,
|
|
127
|
+
}));
|
|
128
|
+
});
|
|
129
|
+
child.on("close", (code, signal) => {
|
|
130
|
+
if (settled)
|
|
131
|
+
return;
|
|
132
|
+
settled = true;
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
out.flush();
|
|
135
|
+
err.flush();
|
|
136
|
+
if (timedOut) {
|
|
137
|
+
reject(new CommandFailedError({
|
|
138
|
+
file,
|
|
139
|
+
args,
|
|
140
|
+
exitCode: code,
|
|
141
|
+
stdout,
|
|
142
|
+
stderr,
|
|
143
|
+
reason: `${file} ${args.join(" ")} timed out after ${Math.round(timeoutMs / 1000)}s`,
|
|
144
|
+
}));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (code !== 0) {
|
|
148
|
+
reject(new CommandFailedError({
|
|
149
|
+
file,
|
|
150
|
+
args,
|
|
151
|
+
exitCode: code,
|
|
152
|
+
stdout,
|
|
153
|
+
stderr,
|
|
154
|
+
reason: `${file} ${args.join(" ")} exited with ${code ?? `signal ${signal ?? "unknown"}`}`,
|
|
155
|
+
}));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
resolve({ stdout, stderr });
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|