@tpsdev-ai/flair 0.6.1 → 0.7.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 +7 -5
- package/dist/bridges/runtime/allow-list.js +198 -0
- package/dist/bridges/runtime/import-runner.js +7 -3
- package/dist/bridges/runtime/load-bridge.js +126 -0
- package/dist/bridges/runtime/load-plugin.js +127 -0
- package/dist/cli.js +1895 -227
- package/dist/install/clients.js +225 -0
- package/dist/resources/Federation.js +21 -15
- package/dist/resources/auth-middleware.js +44 -11
- package/dist/resources/health.js +1 -1
- package/package.json +3 -1
- package/ui/observation-center.html +356 -62
- package/dist/bridges/runtime/load-descriptor.js +0 -46
package/README.md
CHANGED
|
@@ -162,17 +162,17 @@ openclaw plugins install @tpsdev-ai/openclaw-flair
|
|
|
162
162
|
|
|
163
163
|
The plugin auto-detects your agent identity, provides `memory_store`/`memory_recall`/`memory_get` tools, and injects relevant memories at session start. See the [plugin README](plugins/openclaw-flair/README.md) for details.
|
|
164
164
|
|
|
165
|
-
### Claude Code / Codex / Cursor (MCP)
|
|
165
|
+
### Claude Code / Gemini CLI / OpenAI Codex CLI / Cursor (MCP)
|
|
166
166
|
|
|
167
|
-
Install the MCP server for native tool integration:
|
|
167
|
+
One MCP server, many CLIs. Install the MCP server for native tool integration in any MCP-capable client:
|
|
168
168
|
|
|
169
169
|
```json
|
|
170
|
-
// .mcp.json in your project root
|
|
170
|
+
// .mcp.json in your project root (Claude Code / Cursor format)
|
|
171
171
|
{
|
|
172
172
|
"mcpServers": {
|
|
173
173
|
"flair": {
|
|
174
174
|
"command": "npx",
|
|
175
|
-
"args": ["@tpsdev-ai/flair-mcp"],
|
|
175
|
+
"args": ["-y", "@tpsdev-ai/flair-mcp"],
|
|
176
176
|
"env": { "FLAIR_AGENT_ID": "mybot" }
|
|
177
177
|
}
|
|
178
178
|
}
|
|
@@ -183,7 +183,9 @@ Add to your `CLAUDE.md`:
|
|
|
183
183
|
|
|
184
184
|
At the start of every session, run mcp__flair__bootstrap before responding.
|
|
185
185
|
|
|
186
|
-
Claude Code
|
|
186
|
+
Your agent's memory **follows it across CLIs** — same Flair instance, same agent identity, switch from Claude Code to Gemini CLI to Codex CLI without losing state. The MCP server exposes `memory_store`, `memory_search`, `memory_get`, `memory_delete`, `bootstrap`, `soul_set`, `soul_get`.
|
|
187
|
+
|
|
188
|
+
For per-CLI config snippets (Gemini CLI's `~/.gemini/settings.json`, Codex CLI's `~/.codex/config.toml`, etc.), see **[docs/mcp-clients.md](docs/mcp-clients.md)**. For a deeper Claude Code walk-through with `CLAUDE.md` patterns, see [docs/claude-code.md](docs/claude-code.md).
|
|
187
189
|
|
|
188
190
|
### JavaScript / TypeScript (Client Library)
|
|
189
191
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trust allow-list for npm code-plugin bridges.
|
|
3
|
+
*
|
|
4
|
+
* A Shape B bridge is arbitrary JavaScript fetched from npm. The spec §7
|
|
5
|
+
* requires explicit operator opt-in on first use: `flair bridge allow <name>`
|
|
6
|
+
* records the approval; subsequent invocations see the bridge on the
|
|
7
|
+
* allow-list and skip the prompt. `flair bridge revoke <name>` removes it.
|
|
8
|
+
*
|
|
9
|
+
* Approvals are pinned to a concrete package location and its package.json
|
|
10
|
+
* content digest. A name-only approval is not enough: a malicious package
|
|
11
|
+
* squatting on an allowed short-name in a different `node_modules` tree
|
|
12
|
+
* (e.g. the user approved `mem0` in ProjectA, then cd'd into ProjectB which
|
|
13
|
+
* ships a planted `node_modules/flair-bridge-mem0`) would otherwise load
|
|
14
|
+
* under the existing trust record. Each entry therefore captures:
|
|
15
|
+
*
|
|
16
|
+
* - packageDir: canonical (realpath) directory where the approved
|
|
17
|
+
* package lives on disk at allow-time.
|
|
18
|
+
* - packageJsonSha256: hex sha256 of the package's package.json content.
|
|
19
|
+
* - version: the package's self-reported version (display only;
|
|
20
|
+
* the sha is what enforces identity).
|
|
21
|
+
*
|
|
22
|
+
* At load-time, `verifyAllow(discovered)` re-checks all three. A change in
|
|
23
|
+
* any triggers a BridgeRuntimeError pointing the operator back at
|
|
24
|
+
* `flair bridge allow` — either the move is intentional (re-approve) or
|
|
25
|
+
* it is a squat (refuse).
|
|
26
|
+
*
|
|
27
|
+
* Design notes:
|
|
28
|
+
* - YAML (Shape A) + built-in bridges skip this gate entirely. They don't
|
|
29
|
+
* execute arbitrary JS.
|
|
30
|
+
* - The store is a simple JSON file at ~/.flair/bridges-allowed.json.
|
|
31
|
+
* No separate DB, no Harper dependency — the trust decision should
|
|
32
|
+
* still work when Flair's own service is down.
|
|
33
|
+
* - Migration: older entries from 0.6.0/0.6.1 carry only {name, allowedAt}.
|
|
34
|
+
* `verifyAllow` treats those as invalid and forces re-approval on next
|
|
35
|
+
* load. Acceptable cost — no known external consumers pre-1.0.
|
|
36
|
+
*/
|
|
37
|
+
import { promises as fsp } from "node:fs";
|
|
38
|
+
import { existsSync } from "node:fs";
|
|
39
|
+
import { join, dirname } from "node:path";
|
|
40
|
+
import { homedir } from "node:os";
|
|
41
|
+
import { createHash } from "node:crypto";
|
|
42
|
+
function resolvePath(opts) {
|
|
43
|
+
return opts?.path ?? join(homedir(), ".flair", "bridges-allowed.json");
|
|
44
|
+
}
|
|
45
|
+
function hasEntryFields(e) {
|
|
46
|
+
return (typeof e?.name === "string" &&
|
|
47
|
+
typeof e?.allowedAt === "string" &&
|
|
48
|
+
typeof e?.packageDir === "string" &&
|
|
49
|
+
typeof e?.packageJsonSha256 === "string");
|
|
50
|
+
}
|
|
51
|
+
async function read(path) {
|
|
52
|
+
if (!existsSync(path))
|
|
53
|
+
return { allowed: [] };
|
|
54
|
+
try {
|
|
55
|
+
const raw = await fsp.readFile(path, "utf-8");
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (!parsed || !Array.isArray(parsed.allowed))
|
|
58
|
+
return { allowed: [] };
|
|
59
|
+
// Keep structurally valid entries only. Legacy name-only rows are
|
|
60
|
+
// dropped here so `verifyAllow` reports not-allowed (forcing re-approve).
|
|
61
|
+
return {
|
|
62
|
+
allowed: parsed.allowed.filter(hasEntryFields).map((e) => ({
|
|
63
|
+
name: e.name,
|
|
64
|
+
allowedAt: e.allowedAt,
|
|
65
|
+
packageDir: e.packageDir,
|
|
66
|
+
packageJsonSha256: e.packageJsonSha256,
|
|
67
|
+
...(typeof e.version === "string" ? { version: e.version } : {}),
|
|
68
|
+
})),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
return { allowed: [] };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function write(path, data) {
|
|
76
|
+
await fsp.mkdir(dirname(path), { recursive: true });
|
|
77
|
+
const tmp = `${path}.tmp`;
|
|
78
|
+
await fsp.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
|
|
79
|
+
await fsp.rename(tmp, path);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Canonicalize a filesystem path. Resolves symlinks so node_modules hoisting
|
|
83
|
+
* and pnpm-style content-addressed stores still hash-match reliably.
|
|
84
|
+
*/
|
|
85
|
+
async function canonical(path) {
|
|
86
|
+
try {
|
|
87
|
+
return await fsp.realpath(path);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return path;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Hash a package's package.json file. The sha changes whenever the package
|
|
95
|
+
* contents "change enough to matter" — version bumps, name changes, and
|
|
96
|
+
* realistically most substantive updates touch package.json. It is NOT a
|
|
97
|
+
* hash of every file in the package; that would be slower and more fragile
|
|
98
|
+
* without closing the meaningful attack paths (since a squatter needs the
|
|
99
|
+
* right package.json anyway to survive discovery).
|
|
100
|
+
*/
|
|
101
|
+
export async function digestPackage(packageDir) {
|
|
102
|
+
const canonicalDir = await canonical(packageDir);
|
|
103
|
+
const pkgJsonPath = join(canonicalDir, "package.json");
|
|
104
|
+
const raw = await fsp.readFile(pkgJsonPath, "utf-8");
|
|
105
|
+
const sha = createHash("sha256").update(raw).digest("hex");
|
|
106
|
+
let version;
|
|
107
|
+
try {
|
|
108
|
+
const parsed = JSON.parse(raw);
|
|
109
|
+
if (typeof parsed?.version === "string")
|
|
110
|
+
version = parsed.version;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// A malformed package.json is itself a refusal signal — but leave that
|
|
114
|
+
// to the import path; here we just decline to record a version.
|
|
115
|
+
}
|
|
116
|
+
return { canonicalDir, sha256: sha, version };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Check if a name appears in the allow-list. Useful for UI listings; NOT a
|
|
120
|
+
* trust decision — use `verifyAllow` for load-time security checks.
|
|
121
|
+
*/
|
|
122
|
+
export async function isAllowed(name, opts) {
|
|
123
|
+
const data = await read(resolvePath(opts));
|
|
124
|
+
return data.allowed.some((e) => e.name === name);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Load-time trust check. Verifies the discovered package still matches the
|
|
128
|
+
* approval record by canonical path AND package.json sha. Anything else is
|
|
129
|
+
* a refusal — the operator must re-run `flair bridge allow <name>`.
|
|
130
|
+
*/
|
|
131
|
+
export async function verifyAllow(discovered, opts) {
|
|
132
|
+
const data = await read(resolvePath(opts));
|
|
133
|
+
const entry = data.allowed.find((e) => e.name === discovered.name);
|
|
134
|
+
if (!entry)
|
|
135
|
+
return { ok: false, reason: "not-allowed" };
|
|
136
|
+
// Guard: if we ever loaded a partial record, refuse. Belt-and-braces;
|
|
137
|
+
// read() already filters these, but if someone hand-edits the file we
|
|
138
|
+
// don't want to silently bypass the check.
|
|
139
|
+
if (!entry.packageDir || !entry.packageJsonSha256) {
|
|
140
|
+
return { ok: false, reason: "entry-incomplete", entry };
|
|
141
|
+
}
|
|
142
|
+
let observed;
|
|
143
|
+
try {
|
|
144
|
+
observed = await digestPackage(discovered.path);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return { ok: false, reason: "package-missing", entry };
|
|
148
|
+
}
|
|
149
|
+
if (observed.canonicalDir !== entry.packageDir) {
|
|
150
|
+
return { ok: false, reason: "path-mismatch", entry, observedPath: observed.canonicalDir };
|
|
151
|
+
}
|
|
152
|
+
if (observed.sha256 !== entry.packageJsonSha256) {
|
|
153
|
+
return { ok: false, reason: "digest-mismatch", entry, observedDigest: observed.sha256 };
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, entry };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Approve a package for code-plugin execution. `packageDir` must be the
|
|
159
|
+
* directory holding the package.json — typically discovered via
|
|
160
|
+
* `flair bridge list` and passed through by the CLI layer.
|
|
161
|
+
*/
|
|
162
|
+
export async function allow(name, packageDir, opts) {
|
|
163
|
+
const listPath = resolvePath(opts);
|
|
164
|
+
const data = await read(listPath);
|
|
165
|
+
const { canonicalDir, sha256, version } = await digestPackage(packageDir);
|
|
166
|
+
const now = new Date().toISOString();
|
|
167
|
+
const existing = data.allowed.find((e) => e.name === name);
|
|
168
|
+
if (existing && existing.packageDir === canonicalDir && existing.packageJsonSha256 === sha256) {
|
|
169
|
+
return { alreadyAllowed: true, updated: false, entry: existing };
|
|
170
|
+
}
|
|
171
|
+
const entry = {
|
|
172
|
+
name,
|
|
173
|
+
allowedAt: now,
|
|
174
|
+
packageDir: canonicalDir,
|
|
175
|
+
packageJsonSha256: sha256,
|
|
176
|
+
...(version ? { version } : {}),
|
|
177
|
+
};
|
|
178
|
+
data.allowed = data.allowed.filter((e) => e.name !== name);
|
|
179
|
+
data.allowed.push(entry);
|
|
180
|
+
data.allowed.sort((a, b) => a.name.localeCompare(b.name));
|
|
181
|
+
await write(listPath, data);
|
|
182
|
+
return { alreadyAllowed: false, updated: !!existing, entry };
|
|
183
|
+
}
|
|
184
|
+
export async function revoke(name, opts) {
|
|
185
|
+
const path = resolvePath(opts);
|
|
186
|
+
const data = await read(path);
|
|
187
|
+
const before = data.allowed.length;
|
|
188
|
+
data.allowed = data.allowed.filter((e) => e.name !== name);
|
|
189
|
+
if (data.allowed.length === before) {
|
|
190
|
+
return { wasAllowed: false };
|
|
191
|
+
}
|
|
192
|
+
await write(path, data);
|
|
193
|
+
return { wasAllowed: true };
|
|
194
|
+
}
|
|
195
|
+
export async function list(opts) {
|
|
196
|
+
const data = await read(resolvePath(opts));
|
|
197
|
+
return data.allowed;
|
|
198
|
+
}
|
|
@@ -26,7 +26,11 @@ export async function runImport(opts) {
|
|
|
26
26
|
let total = 0;
|
|
27
27
|
let imported = 0;
|
|
28
28
|
let skipped = 0;
|
|
29
|
-
|
|
29
|
+
const iterable = opts.source
|
|
30
|
+
?? (opts.descriptor
|
|
31
|
+
? importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })
|
|
32
|
+
: (() => { throw new BridgeRuntimeError({ bridge: opts.bridgeName, op: "import", field: "(source)", expected: "descriptor or source", got: "neither", hint: "runImport requires exactly one of opts.descriptor (YAML) or opts.source (AsyncIterable)" }); })());
|
|
33
|
+
for await (const m of iterable) {
|
|
30
34
|
total++;
|
|
31
35
|
const resolvedAgent = m.agentId ?? opts.agentId;
|
|
32
36
|
if (!resolvedAgent) {
|
|
@@ -35,7 +39,7 @@ export async function runImport(opts) {
|
|
|
35
39
|
// the operator should know whether this is a descriptor bug or a
|
|
36
40
|
// missing flag.
|
|
37
41
|
throw new BridgeRuntimeError({
|
|
38
|
-
bridge: opts.
|
|
42
|
+
bridge: opts.bridgeName,
|
|
39
43
|
op: "import",
|
|
40
44
|
field: "agentId",
|
|
41
45
|
expected: "set on record OR provided via --agent",
|
|
@@ -86,7 +90,7 @@ export async function runImport(opts) {
|
|
|
86
90
|
catch (err) {
|
|
87
91
|
// Wrap PUT errors so they read consistently with other BridgeRuntimeErrors.
|
|
88
92
|
throw new BridgeRuntimeError({
|
|
89
|
-
bridge: opts.
|
|
93
|
+
bridge: opts.bridgeName,
|
|
90
94
|
op: "import",
|
|
91
95
|
record: total,
|
|
92
96
|
field: "(write)",
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified loader for all bridge sources (replaces load-descriptor.ts).
|
|
3
|
+
*
|
|
4
|
+
* Returns a `LoadedBridge` discriminated union. Runners dispatch on
|
|
5
|
+
* `.kind`:
|
|
6
|
+
* - "yaml": use the existing YAML runtime (applyMap, parseRecords, etc.)
|
|
7
|
+
* - "code": call the plugin's bridge.import() / bridge.export() directly
|
|
8
|
+
*
|
|
9
|
+
* For code-plugin sources (npm packages), the allow-list gate runs first:
|
|
10
|
+
* if not allowed, throw a BridgeRuntimeError pointing the operator at
|
|
11
|
+
* `flair bridge allow <name>`.
|
|
12
|
+
*/
|
|
13
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
14
|
+
import { loadYamlDescriptor } from "./yaml-loader.js";
|
|
15
|
+
import { BUILTIN_BY_NAME } from "../builtins/index.js";
|
|
16
|
+
import { loadCodePlugin } from "./load-plugin.js";
|
|
17
|
+
import { verifyAllow } from "./allow-list.js";
|
|
18
|
+
export async function loadBridge(discovered, opts = {}) {
|
|
19
|
+
switch (discovered.source) {
|
|
20
|
+
case "builtin": {
|
|
21
|
+
const builtin = BUILTIN_BY_NAME.get(discovered.name);
|
|
22
|
+
if (!builtin) {
|
|
23
|
+
throw new BridgeRuntimeError({
|
|
24
|
+
bridge: discovered.name,
|
|
25
|
+
op: "import",
|
|
26
|
+
field: "(builtin)",
|
|
27
|
+
expected: "registered built-in name",
|
|
28
|
+
got: discovered.name,
|
|
29
|
+
hint: `built-in "${discovered.name}" not in the registry — likely code drift between discover.ts and builtins/index.ts`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return { kind: "yaml", descriptor: builtin.descriptor, source: discovered };
|
|
33
|
+
}
|
|
34
|
+
case "project-yaml":
|
|
35
|
+
case "user-yaml": {
|
|
36
|
+
const descriptor = await loadYamlDescriptor(discovered.path);
|
|
37
|
+
return { kind: "yaml", descriptor, source: discovered };
|
|
38
|
+
}
|
|
39
|
+
case "npm-package": {
|
|
40
|
+
if (!opts.skipAllowCheck) {
|
|
41
|
+
const verdict = await verifyAllow(discovered, { path: opts.allowListPath });
|
|
42
|
+
if (!verdict.ok) {
|
|
43
|
+
throw new BridgeRuntimeError({
|
|
44
|
+
bridge: discovered.name,
|
|
45
|
+
op: "import",
|
|
46
|
+
path: discovered.path,
|
|
47
|
+
field: "(trust)",
|
|
48
|
+
expected: verdict.reason === "not-allowed" ? "allow-listed code plugin" : "approved package at recorded location/digest",
|
|
49
|
+
got: verdict.reason,
|
|
50
|
+
hint: trustHint(discovered.name, verdict),
|
|
51
|
+
context: trustContext(discovered, verdict),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const plugin = await loadCodePlugin(discovered, { importer: opts.importer });
|
|
56
|
+
return { kind: "code", plugin, source: discovered };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function trustContext(discovered, verdict) {
|
|
61
|
+
switch (verdict.reason) {
|
|
62
|
+
case "not-allowed":
|
|
63
|
+
return { name: discovered.name };
|
|
64
|
+
case "path-mismatch":
|
|
65
|
+
return {
|
|
66
|
+
name: discovered.name,
|
|
67
|
+
approvedPath: verdict.entry.packageDir,
|
|
68
|
+
observedPath: verdict.observedPath,
|
|
69
|
+
approvedVersion: verdict.entry.version ?? "(unknown)",
|
|
70
|
+
approvedAt: verdict.entry.allowedAt,
|
|
71
|
+
};
|
|
72
|
+
case "digest-mismatch":
|
|
73
|
+
return {
|
|
74
|
+
name: discovered.name,
|
|
75
|
+
packagePath: verdict.entry.packageDir,
|
|
76
|
+
approvedVersion: verdict.entry.version ?? "(unknown)",
|
|
77
|
+
approvedDigest: verdict.entry.packageJsonSha256,
|
|
78
|
+
observedDigest: verdict.observedDigest,
|
|
79
|
+
approvedAt: verdict.entry.allowedAt,
|
|
80
|
+
};
|
|
81
|
+
case "entry-incomplete":
|
|
82
|
+
return { name: discovered.name };
|
|
83
|
+
case "package-missing":
|
|
84
|
+
return {
|
|
85
|
+
name: discovered.name,
|
|
86
|
+
approvedPath: verdict.entry?.packageDir ?? "(unknown)",
|
|
87
|
+
discoveredPath: discovered.path,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function trustHint(name, verdict) {
|
|
92
|
+
const reapprove = `flair bridge allow ${name}`;
|
|
93
|
+
switch (verdict.reason) {
|
|
94
|
+
case "not-allowed":
|
|
95
|
+
return `npm code plugins run arbitrary JavaScript. First-use approval required. Run: ${reapprove}`;
|
|
96
|
+
case "path-mismatch":
|
|
97
|
+
return `approved package lives at ${verdict.entry.packageDir}, but a different package with the same name was discovered at ${verdict.observedPath}. This is how local squatting attacks present. If the new location is intentional, re-run: ${reapprove}`;
|
|
98
|
+
case "digest-mismatch":
|
|
99
|
+
return `package.json contents changed since approval (recorded sha ${verdict.entry.packageJsonSha256.slice(0, 12)}…, observed ${verdict.observedDigest.slice(0, 12)}…). Re-run if the update is intentional: ${reapprove}`;
|
|
100
|
+
case "entry-incomplete":
|
|
101
|
+
return `allow-list entry for "${name}" is missing a location/digest (likely from a pre-fix Flair version). Re-run: ${reapprove}`;
|
|
102
|
+
case "package-missing":
|
|
103
|
+
return `package at ${verdict.entry?.packageDir ?? "(unknown)"} could not be read; allow-list verification cannot proceed`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Kept as a shim for code paths that still call the old name. Remove once
|
|
108
|
+
* all call sites move to loadBridge directly.
|
|
109
|
+
*
|
|
110
|
+
* Returns the YAML descriptor when the source produces one; throws for
|
|
111
|
+
* code plugins (they don't have a YamlBridgeDescriptor representation).
|
|
112
|
+
*/
|
|
113
|
+
export async function loadDescriptor(discovered) {
|
|
114
|
+
const loaded = await loadBridge(discovered);
|
|
115
|
+
if (loaded.kind === "yaml")
|
|
116
|
+
return loaded.descriptor;
|
|
117
|
+
throw new BridgeRuntimeError({
|
|
118
|
+
bridge: discovered.name,
|
|
119
|
+
op: "import",
|
|
120
|
+
path: discovered.path,
|
|
121
|
+
field: "(kind)",
|
|
122
|
+
expected: "yaml descriptor",
|
|
123
|
+
got: "code plugin",
|
|
124
|
+
hint: "this code path expected a YAML descriptor; the bridge is a code plugin — caller should dispatch on loadBridge().kind instead",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dynamic loader for Shape B code-plugin bridges.
|
|
3
|
+
*
|
|
4
|
+
* Given a discovered `npm-package` source, dynamic-import the package,
|
|
5
|
+
* validate that it exports a `MemoryBridge`, and return the loaded module.
|
|
6
|
+
* The import is done via `createRequire` relative to the package root so
|
|
7
|
+
* scoped packages and deep nested deps resolve correctly.
|
|
8
|
+
*
|
|
9
|
+
* Validation is deliberately minimal: `name`, `version`, `kind`, and at
|
|
10
|
+
* least one of `import` / `export` must be present and look right. The
|
|
11
|
+
* spec §6 contract is "duck-type at runtime, strict error messages."
|
|
12
|
+
*/
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { BridgeRuntimeError } from "../types.js";
|
|
15
|
+
const DEFAULT_IMPORTER = (spec) => import(spec);
|
|
16
|
+
export async function loadCodePlugin(discovered, opts = {}) {
|
|
17
|
+
if (discovered.source !== "npm-package") {
|
|
18
|
+
throw new BridgeRuntimeError({
|
|
19
|
+
bridge: discovered.name,
|
|
20
|
+
op: "import",
|
|
21
|
+
field: "source",
|
|
22
|
+
expected: "npm-package",
|
|
23
|
+
got: discovered.source,
|
|
24
|
+
hint: "loadCodePlugin only handles Shape B (npm code plugin) bridges; YAML descriptors go through the YAML loader",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const importer = opts.importer ?? DEFAULT_IMPORTER;
|
|
28
|
+
// Package root path — the entry point is resolved by Node's package.json
|
|
29
|
+
// "main"/"exports" fields. Use a file:// URL so Node resolves it as a
|
|
30
|
+
// filesystem path rather than a bare specifier.
|
|
31
|
+
const spec = pathToFileURL(discovered.path + "/").href;
|
|
32
|
+
let mod;
|
|
33
|
+
try {
|
|
34
|
+
mod = await importer(spec);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new BridgeRuntimeError({
|
|
38
|
+
bridge: discovered.name,
|
|
39
|
+
op: "import",
|
|
40
|
+
path: discovered.path,
|
|
41
|
+
field: "(import)",
|
|
42
|
+
expected: "importable npm package",
|
|
43
|
+
got: err?.code ?? "import error",
|
|
44
|
+
hint: `could not dynamic-import ${spec}: ${err?.message ?? err}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const candidate = pickBridgeExport(mod);
|
|
48
|
+
if (!candidate) {
|
|
49
|
+
throw new BridgeRuntimeError({
|
|
50
|
+
bridge: discovered.name,
|
|
51
|
+
op: "import",
|
|
52
|
+
path: discovered.path,
|
|
53
|
+
field: "exports",
|
|
54
|
+
expected: "named `bridge` export or default export implementing MemoryBridge",
|
|
55
|
+
got: typeof mod === "object" && mod !== null ? `exports=${Object.keys(mod).join(",") || "(empty)"}` : typeof mod,
|
|
56
|
+
hint: `flair-bridge-<name> packages must export \`bridge\` (or default-export) a MemoryBridge. See specs/FLAIR-BRIDGES.md §6`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
// Validate the shape. Keep this minimal — we trust the plugin author
|
|
60
|
+
// to build a working MemoryBridge; we just need enough to route calls.
|
|
61
|
+
validateBridge(discovered, candidate);
|
|
62
|
+
return candidate;
|
|
63
|
+
}
|
|
64
|
+
function pickBridgeExport(mod) {
|
|
65
|
+
if (!mod || typeof mod !== "object")
|
|
66
|
+
return null;
|
|
67
|
+
const m = mod;
|
|
68
|
+
if (isBridgeLike(m.bridge))
|
|
69
|
+
return m.bridge;
|
|
70
|
+
if (isBridgeLike(m.default))
|
|
71
|
+
return m.default;
|
|
72
|
+
if (isBridgeLike(m))
|
|
73
|
+
return m;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
function isBridgeLike(x) {
|
|
77
|
+
if (!x || typeof x !== "object")
|
|
78
|
+
return false;
|
|
79
|
+
const o = x;
|
|
80
|
+
return typeof o.name === "string" && (typeof o.import === "function" || typeof o.export === "function");
|
|
81
|
+
}
|
|
82
|
+
function validateBridge(discovered, b) {
|
|
83
|
+
if (typeof b.name !== "string" || !b.name) {
|
|
84
|
+
throw new BridgeRuntimeError({
|
|
85
|
+
bridge: discovered.name,
|
|
86
|
+
op: "import",
|
|
87
|
+
path: discovered.path,
|
|
88
|
+
field: "name",
|
|
89
|
+
expected: "non-empty string",
|
|
90
|
+
got: JSON.stringify(b.name),
|
|
91
|
+
hint: "MemoryBridge.name must be a non-empty string matching the npm package's flair-bridge-<name>",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (b.name !== discovered.name) {
|
|
95
|
+
throw new BridgeRuntimeError({
|
|
96
|
+
bridge: discovered.name,
|
|
97
|
+
op: "import",
|
|
98
|
+
path: discovered.path,
|
|
99
|
+
field: "name",
|
|
100
|
+
expected: `"${discovered.name}" (from package name flair-bridge-${discovered.name})`,
|
|
101
|
+
got: `"${b.name}"`,
|
|
102
|
+
hint: "MemoryBridge.name must match the npm package's public name suffix — mismatch would surprise discovery and allow-list lookups",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
if (b.kind !== "file" && b.kind !== "api") {
|
|
106
|
+
throw new BridgeRuntimeError({
|
|
107
|
+
bridge: discovered.name,
|
|
108
|
+
op: "import",
|
|
109
|
+
path: discovered.path,
|
|
110
|
+
field: "kind",
|
|
111
|
+
expected: `"file" | "api"`,
|
|
112
|
+
got: JSON.stringify(b.kind),
|
|
113
|
+
hint: "MemoryBridge.kind must be 'file' or 'api'",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (typeof b.import !== "function" && typeof b.export !== "function") {
|
|
117
|
+
throw new BridgeRuntimeError({
|
|
118
|
+
bridge: discovered.name,
|
|
119
|
+
op: "import",
|
|
120
|
+
path: discovered.path,
|
|
121
|
+
field: "(methods)",
|
|
122
|
+
expected: "at least one of `import` or `export`",
|
|
123
|
+
got: "neither",
|
|
124
|
+
hint: "MemoryBridge needs at least one of import/export implemented. See spec §6",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|