@tpsdev-ai/flair 0.14.0 → 0.16.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 +66 -9
- package/dist/cli-shim.cjs +66 -0
- package/dist/cli.js +669 -417
- package/dist/fabric-upgrade.js +367 -0
- package/dist/install/clients.js +132 -81
- package/dist/resources/A2AAdapter.js +31 -5
- package/dist/resources/OrgEvent.js +12 -2
- package/dist/resources/Presence.js +3 -9
- package/dist/resources/SemanticSearch.js +124 -2
- package/dist/resources/WorkspaceState.js +12 -2
- package/dist/resources/agent-auth.js +3 -10
- package/dist/resources/auth-middleware.js +3 -10
- package/dist/resources/b64.js +40 -0
- package/dist/resources/bm25-filter.js +84 -0
- package/dist/resources/bm25.js +130 -0
- package/dist/resources/embeddings-provider.js +41 -6
- package/package.json +10 -10
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fabric-upgrade.ts — `flair upgrade --target <fabric-url>`
|
|
3
|
+
*
|
|
4
|
+
* One-command upgrade of a Flair instance already DEPLOYED to a Harper Fabric
|
|
5
|
+
* cluster (as opposed to a local `flair upgrade`, which upgrades the globally
|
|
6
|
+
* installed npm packages on the current host).
|
|
7
|
+
*
|
|
8
|
+
* Why this exists — the manual dance it replaces:
|
|
9
|
+
* Upgrading a deployed Fabric Flair used to require, by hand:
|
|
10
|
+
* 1. mkdir a fresh temp dir
|
|
11
|
+
* 2. write a package.json that depends on @tpsdev-ai/flair@<version> AND
|
|
12
|
+
* carries an `overrides` block pinning @harperfast/harper to a fixed
|
|
13
|
+
* version — because the PUBLISHED flair declares an OLD Harper
|
|
14
|
+
* (@harperfast/harper@5.0.21 as of flair@0.14.0) whose component
|
|
15
|
+
* packager (`packageComponent`) emits an EMPTY tarball when the package
|
|
16
|
+
* root lives under node_modules (the real npm-install scenario).
|
|
17
|
+
* See flair#513 — fixed in Harper >= 5.1.13.
|
|
18
|
+
* 3. npm install
|
|
19
|
+
* 4. run `flair deploy --target <url>` from that temp dir
|
|
20
|
+
* This module bakes the Harper pin in so the whole thing is one command.
|
|
21
|
+
*
|
|
22
|
+
* `deploy()` (src/deploy.ts) is REUSED verbatim for the packaging + spawn of
|
|
23
|
+
* the bundled Harper's `harper deploy` — this module never reimplements deploy.
|
|
24
|
+
* It only: resolves the target version, prepares a clean temp deployable with
|
|
25
|
+
* the Harper override baked in, confirms the resolved Harper bin is the fix
|
|
26
|
+
* version, then hands the temp package root to deploy() via `packageRoot`.
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from "node:child_process";
|
|
29
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import { tmpdir } from "node:os";
|
|
32
|
+
import { createRequire } from "node:module";
|
|
33
|
+
/**
|
|
34
|
+
* Minimum @harperfast/harper version whose component packager works when the
|
|
35
|
+
* package root is under node_modules (flair#513 — the empty-tarball fix landed
|
|
36
|
+
* in 5.1.13). Anything below this MUST be overridden before a Fabric deploy.
|
|
37
|
+
*/
|
|
38
|
+
export const MIN_HARPER_VERSION = "5.1.13";
|
|
39
|
+
/**
|
|
40
|
+
* Version we pin Harper to when an override is needed and `--harper-version`
|
|
41
|
+
* isn't given and we can't resolve the registry's latest. Known-good, ships the
|
|
42
|
+
* packageComponent fix. 5.1.14 is the latest published as of this writing.
|
|
43
|
+
*/
|
|
44
|
+
export const DEFAULT_HARPER_PIN = "5.1.14";
|
|
45
|
+
const FLAIR_PKG = "@tpsdev-ai/flair";
|
|
46
|
+
const HARPER_PKG = "@harperfast/harper";
|
|
47
|
+
/**
|
|
48
|
+
* Parse "5.1.14" / "5.1.14-rc.1" / "v0.14.0" into [major, minor, patch],
|
|
49
|
+
* ignoring any pre-release / build suffix for comparison. Returns null when the
|
|
50
|
+
* core can't be parsed as three numeric segments.
|
|
51
|
+
*/
|
|
52
|
+
export function parseSemverCore(v) {
|
|
53
|
+
if (!v)
|
|
54
|
+
return null;
|
|
55
|
+
const core = v.trim().replace(/^v/, "").split("-")[0].split("+")[0];
|
|
56
|
+
const parts = core.split(".");
|
|
57
|
+
if (parts.length < 3)
|
|
58
|
+
return null;
|
|
59
|
+
const nums = parts.slice(0, 3).map((p) => Number(p));
|
|
60
|
+
if (nums.some((n) => !Number.isFinite(n)))
|
|
61
|
+
return null;
|
|
62
|
+
return [nums[0], nums[1], nums[2]];
|
|
63
|
+
}
|
|
64
|
+
/** True when `a` >= `b` by numeric major.minor.patch (pre-release ignored). */
|
|
65
|
+
export function semverGte(a, b) {
|
|
66
|
+
const pa = parseSemverCore(a);
|
|
67
|
+
const pb = parseSemverCore(b);
|
|
68
|
+
if (!pa || !pb)
|
|
69
|
+
return false;
|
|
70
|
+
for (let i = 0; i < 3; i++) {
|
|
71
|
+
if (pa[i] > pb[i])
|
|
72
|
+
return true;
|
|
73
|
+
if (pa[i] < pb[i])
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
return true; // equal
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Decide what (if any) @harperfast/harper override to bake into the temp
|
|
80
|
+
* deployable's package.json.
|
|
81
|
+
*
|
|
82
|
+
* - If flair already declares Harper >= MIN_HARPER_VERSION → no override
|
|
83
|
+
* needed; keep what it ships (pin = declared).
|
|
84
|
+
* - Otherwise (declared is older, absent, or unparseable) → override to
|
|
85
|
+
* `preferredPin` (caller passes --harper-version, else registry latest,
|
|
86
|
+
* else DEFAULT_HARPER_PIN). We still require the chosen pin to satisfy
|
|
87
|
+
* MIN_HARPER_VERSION, falling back to DEFAULT_HARPER_PIN if the caller
|
|
88
|
+
* passed something too old.
|
|
89
|
+
*
|
|
90
|
+
* Pure — no I/O. The LOAD-BEARING bit is `overridden`: when true, the temp
|
|
91
|
+
* package.json gets `overrides: { "@harperfast/harper": pin }`.
|
|
92
|
+
*/
|
|
93
|
+
export function resolveHarperPin(declared, preferredPin) {
|
|
94
|
+
if (declared && semverGte(declared, MIN_HARPER_VERSION)) {
|
|
95
|
+
return {
|
|
96
|
+
pin: declared,
|
|
97
|
+
overridden: false,
|
|
98
|
+
declared,
|
|
99
|
+
reason: `flair declares ${HARPER_PKG}@${declared} (>= ${MIN_HARPER_VERSION}); no override needed`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
// Need an override. Choose the pin: prefer the caller's, but never below the
|
|
103
|
+
// fix floor — a stale --harper-version would silently reintroduce flair#513.
|
|
104
|
+
let pin = preferredPin && semverGte(preferredPin, MIN_HARPER_VERSION)
|
|
105
|
+
? preferredPin
|
|
106
|
+
: DEFAULT_HARPER_PIN;
|
|
107
|
+
const declaredLabel = declared ? `${declared}` : "none";
|
|
108
|
+
const tooOldHint = preferredPin && !semverGte(preferredPin, MIN_HARPER_VERSION)
|
|
109
|
+
? ` (requested ${preferredPin} is below the ${MIN_HARPER_VERSION} fix floor — using ${pin} instead)`
|
|
110
|
+
: "";
|
|
111
|
+
return {
|
|
112
|
+
pin,
|
|
113
|
+
overridden: true,
|
|
114
|
+
declared,
|
|
115
|
+
reason: `flair declares ${HARPER_PKG}@${declaredLabel} (< ${MIN_HARPER_VERSION}, flair#513 empty-tarball) — pinning to ${pin}${tooOldHint}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Build the package.json contents for the temp deployable. Depends on
|
|
120
|
+
* @tpsdev-ai/flair@<version> and, when the Harper pin is an override, carries
|
|
121
|
+
* the `overrides` block that forces npm to install the fix Harper under the
|
|
122
|
+
* flair package's node_modules.
|
|
123
|
+
*/
|
|
124
|
+
export function buildDeployablePackageJson(flairVersion, pin) {
|
|
125
|
+
const pkg = {
|
|
126
|
+
name: "flair-fabric-upgrade-stage",
|
|
127
|
+
version: "0.0.0",
|
|
128
|
+
private: true,
|
|
129
|
+
dependencies: {
|
|
130
|
+
[FLAIR_PKG]: flairVersion,
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
if (pin.overridden) {
|
|
134
|
+
pkg.overrides = { [HARPER_PKG]: pin.pin };
|
|
135
|
+
}
|
|
136
|
+
return pkg;
|
|
137
|
+
}
|
|
138
|
+
// ─── Default (real) dependency implementations ──────────────────────────────
|
|
139
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
140
|
+
async function defaultFetchLatestFlairVersion() {
|
|
141
|
+
const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/latest`, {
|
|
142
|
+
signal: AbortSignal.timeout(10_000),
|
|
143
|
+
});
|
|
144
|
+
if (!res.ok) {
|
|
145
|
+
throw new Error(`npm registry returned ${res.status} for ${FLAIR_PKG}/latest`);
|
|
146
|
+
}
|
|
147
|
+
const data = (await res.json());
|
|
148
|
+
if (!data.version)
|
|
149
|
+
throw new Error(`No version in registry response for ${FLAIR_PKG}`);
|
|
150
|
+
return data.version;
|
|
151
|
+
}
|
|
152
|
+
async function defaultFetchDeclaredHarperVersion(flairVersion) {
|
|
153
|
+
const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/${flairVersion}`, {
|
|
154
|
+
signal: AbortSignal.timeout(10_000),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok)
|
|
157
|
+
return null;
|
|
158
|
+
const data = (await res.json());
|
|
159
|
+
return data.dependencies?.[HARPER_PKG] ?? null;
|
|
160
|
+
}
|
|
161
|
+
function defaultNpmInstall(dir) {
|
|
162
|
+
// No package spec on argv — npm reads dependencies + overrides from the
|
|
163
|
+
// package.json we wrote into `dir`. --omit=dev keeps the stage lean;
|
|
164
|
+
// --no-audit/--no-fund cut noise. Output streamed for operator visibility.
|
|
165
|
+
const r = spawnSync("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], { cwd: dir, stdio: "inherit" });
|
|
166
|
+
if (r.error)
|
|
167
|
+
throw r.error;
|
|
168
|
+
if (r.status !== 0) {
|
|
169
|
+
throw new Error(`npm install failed in staging dir (exit ${r.status})`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Query the Fabric for the deployed Flair component version. Best-effort: the
|
|
174
|
+
* public REST surface exposes `/Health`, which echoes the running Flair version
|
|
175
|
+
* in newer builds; if the shape isn't recognized we return null (verification
|
|
176
|
+
* downgrades to a soft notice, never a hard failure).
|
|
177
|
+
*/
|
|
178
|
+
async function defaultFetchDeployedVersion(opts) {
|
|
179
|
+
const base = opts.url.replace(/\/$/, "");
|
|
180
|
+
const headers = {};
|
|
181
|
+
if (opts.fabricUser && opts.fabricPassword) {
|
|
182
|
+
const auth = Buffer.from(`${opts.fabricUser}:${opts.fabricPassword}`).toString("base64");
|
|
183
|
+
headers.Authorization = `Basic ${auth}`;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const res = await fetch(`${base}/Health`, {
|
|
187
|
+
headers,
|
|
188
|
+
signal: AbortSignal.timeout(10_000),
|
|
189
|
+
});
|
|
190
|
+
if (!res.ok)
|
|
191
|
+
return null;
|
|
192
|
+
const text = await res.text();
|
|
193
|
+
let body;
|
|
194
|
+
try {
|
|
195
|
+
body = JSON.parse(text);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
// Tolerate a few shapes: { version }, { flair: { version } }, { flairVersion }.
|
|
201
|
+
const v = body?.version ?? body?.flairVersion ?? body?.flair?.version ?? null;
|
|
202
|
+
return typeof v === "string" ? v : null;
|
|
203
|
+
}
|
|
204
|
+
catch {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/** Resolve the @harperfast/harper bin that npm installed under the staged flair. */
|
|
209
|
+
export function resolveStagedHarperVersion(stagingDir) {
|
|
210
|
+
const harperPkgJson = join(stagingDir, "node_modules", FLAIR_PKG, "node_modules", HARPER_PKG, "package.json");
|
|
211
|
+
// npm dedupes: with an override the fixed Harper may hoist to the staging
|
|
212
|
+
// root rather than nest under flair. Check both.
|
|
213
|
+
const hoisted = join(stagingDir, "node_modules", HARPER_PKG, "package.json");
|
|
214
|
+
for (const candidate of [harperPkgJson, hoisted]) {
|
|
215
|
+
if (existsSync(candidate)) {
|
|
216
|
+
try {
|
|
217
|
+
const pkg = JSON.parse(readFileSync(candidate, "utf8"));
|
|
218
|
+
if (typeof pkg.version === "string")
|
|
219
|
+
return pkg.version;
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
/* try next */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Last resort: resolve through the staged flair's module graph.
|
|
227
|
+
try {
|
|
228
|
+
const flairPkg = join(stagingDir, "node_modules", FLAIR_PKG, "package.json");
|
|
229
|
+
const req = createRequire(flairPkg);
|
|
230
|
+
const resolved = req.resolve(`${HARPER_PKG}/package.json`);
|
|
231
|
+
const pkg = JSON.parse(readFileSync(resolved, "utf8"));
|
|
232
|
+
return typeof pkg.version === "string" ? pkg.version : null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Locate the staged @tpsdev-ai/flair package root (the deploy() packageRoot). */
|
|
239
|
+
export function resolveStagedFlairRoot(stagingDir) {
|
|
240
|
+
const root = join(stagingDir, "node_modules", FLAIR_PKG);
|
|
241
|
+
if (!existsSync(join(root, "package.json"))) {
|
|
242
|
+
throw new Error(`Staged flair not found at ${root} — npm install may have failed`);
|
|
243
|
+
}
|
|
244
|
+
return root;
|
|
245
|
+
}
|
|
246
|
+
function defaultDeps() {
|
|
247
|
+
// deploy is imported lazily so unit tests can fully mock without pulling the
|
|
248
|
+
// real Harper-spawning module into their graph.
|
|
249
|
+
return {
|
|
250
|
+
fetchLatestFlairVersion: defaultFetchLatestFlairVersion,
|
|
251
|
+
fetchDeclaredHarperVersion: defaultFetchDeclaredHarperVersion,
|
|
252
|
+
npmInstall: defaultNpmInstall,
|
|
253
|
+
fetchDeployedVersion: defaultFetchDeployedVersion,
|
|
254
|
+
deploy: async (opts) => {
|
|
255
|
+
const { deploy } = await import("./deploy.js");
|
|
256
|
+
return deploy(opts);
|
|
257
|
+
},
|
|
258
|
+
log: (m) => console.log(m),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
// ─── Orchestrator ───────────────────────────────────────────────────────────
|
|
262
|
+
/**
|
|
263
|
+
* Build the upgrade plan (steps 1 + version diff). Pure-ish: only the two
|
|
264
|
+
* read-only registry lookups, no install, no deploy. `--check` stops here.
|
|
265
|
+
*/
|
|
266
|
+
export async function planFabricUpgrade(opts, deps) {
|
|
267
|
+
const project = opts.project ?? "flair";
|
|
268
|
+
const targetVersion = opts.version ?? (await deps.fetchLatestFlairVersion());
|
|
269
|
+
const declaredHarper = await deps.fetchDeclaredHarperVersion(targetVersion);
|
|
270
|
+
const harper = resolveHarperPin(declaredHarper, opts.harperVersion);
|
|
271
|
+
const currentVersion = await deps.fetchDeployedVersion({
|
|
272
|
+
url: opts.target,
|
|
273
|
+
project,
|
|
274
|
+
fabricUser: opts.fabricUser,
|
|
275
|
+
fabricPassword: opts.fabricPassword,
|
|
276
|
+
});
|
|
277
|
+
const upToDate = currentVersion != null &&
|
|
278
|
+
parseSemverCore(currentVersion) != null &&
|
|
279
|
+
parseSemverCore(targetVersion) != null &&
|
|
280
|
+
semverGte(currentVersion, targetVersion) &&
|
|
281
|
+
semverGte(targetVersion, currentVersion);
|
|
282
|
+
return { target: opts.target, project, targetVersion, currentVersion, harper, upToDate };
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Full Fabric upgrade. Reuses deploy() for the packaging/deploy — this function
|
|
286
|
+
* only prepares the clean temp deployable (with the Harper override baked in)
|
|
287
|
+
* and verifies afterward.
|
|
288
|
+
*
|
|
289
|
+
* SAFETY: never logs creds; always cleans up the staging dir (finally); never
|
|
290
|
+
* touches the running local Flair — all work happens in an isolated temp dir
|
|
291
|
+
* and the deploy targets the remote Fabric URL only.
|
|
292
|
+
*/
|
|
293
|
+
export async function fabricUpgrade(opts, injected) {
|
|
294
|
+
const deps = { ...defaultDeps(), ...injected };
|
|
295
|
+
const log = deps.log ?? (() => { });
|
|
296
|
+
// Step 1 + diff.
|
|
297
|
+
const plan = await planFabricUpgrade(opts, deps);
|
|
298
|
+
log(`Fabric: ${plan.target}`);
|
|
299
|
+
log(`Project: ${plan.project}`);
|
|
300
|
+
log(`Version: ${plan.currentVersion ?? "(unknown)"} → ${plan.targetVersion}` +
|
|
301
|
+
(plan.upToDate ? " (already up to date)" : ""));
|
|
302
|
+
log(`Harper: ${plan.harper.reason}`);
|
|
303
|
+
if (opts.check) {
|
|
304
|
+
log("");
|
|
305
|
+
log("--check: plan only — not deploying.");
|
|
306
|
+
return { plan, deployed: false, verifiedVersion: null, stagingDir: "" };
|
|
307
|
+
}
|
|
308
|
+
// Step 2: prepare a clean deployable in an isolated temp dir.
|
|
309
|
+
const stagingDir = mkdtempSync(join(tmpdir(), "flair-fabric-upgrade-"));
|
|
310
|
+
try {
|
|
311
|
+
const pkgJson = buildDeployablePackageJson(plan.targetVersion, plan.harper);
|
|
312
|
+
writeFileSync(join(stagingDir, "package.json"), JSON.stringify(pkgJson, null, 2));
|
|
313
|
+
log(`\nStaging ${FLAIR_PKG}@${plan.targetVersion} in ${stagingDir} ...`);
|
|
314
|
+
deps.npmInstall(stagingDir);
|
|
315
|
+
// CONFIRM the resolved Harper bin is the fix version before deploying.
|
|
316
|
+
const stagedHarper = resolveStagedHarperVersion(stagingDir);
|
|
317
|
+
if (!stagedHarper) {
|
|
318
|
+
throw new Error(`Could not resolve the staged ${HARPER_PKG} version — refusing to deploy ` +
|
|
319
|
+
`(packageComponent may emit an empty tarball, flair#513).`);
|
|
320
|
+
}
|
|
321
|
+
if (!semverGte(stagedHarper, MIN_HARPER_VERSION)) {
|
|
322
|
+
throw new Error(`Staged ${HARPER_PKG}@${stagedHarper} is below the ${MIN_HARPER_VERSION} ` +
|
|
323
|
+
`fix floor — the override did not take. Refusing to deploy (flair#513).`);
|
|
324
|
+
}
|
|
325
|
+
log(`✓ Staged ${HARPER_PKG}@${stagedHarper} (>= ${MIN_HARPER_VERSION})`);
|
|
326
|
+
const packageRoot = resolveStagedFlairRoot(stagingDir);
|
|
327
|
+
// Step 3: REUSE deploy() — do not reimplement packaging/spawn.
|
|
328
|
+
log(`Deploying ${plan.project} ${plan.targetVersion} to ${plan.target} ...`);
|
|
329
|
+
const result = await deps.deploy({
|
|
330
|
+
target: opts.target,
|
|
331
|
+
project: plan.project,
|
|
332
|
+
version: plan.targetVersion,
|
|
333
|
+
fabricUser: opts.fabricUser,
|
|
334
|
+
fabricPassword: opts.fabricPassword,
|
|
335
|
+
restart: opts.restart,
|
|
336
|
+
replicated: opts.replicated,
|
|
337
|
+
packageRoot,
|
|
338
|
+
});
|
|
339
|
+
// Step 4: verify the deployed version (best-effort).
|
|
340
|
+
let verifiedVersion = null;
|
|
341
|
+
try {
|
|
342
|
+
verifiedVersion = await deps.fetchDeployedVersion({
|
|
343
|
+
url: opts.target,
|
|
344
|
+
project: plan.project,
|
|
345
|
+
fabricUser: opts.fabricUser,
|
|
346
|
+
fabricPassword: opts.fabricPassword,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
verifiedVersion = null;
|
|
351
|
+
}
|
|
352
|
+
if (verifiedVersion) {
|
|
353
|
+
const ok = semverGte(verifiedVersion, plan.targetVersion);
|
|
354
|
+
log(ok
|
|
355
|
+
? `✓ Fabric now reports Flair ${verifiedVersion}`
|
|
356
|
+
: `⚠ Fabric reports ${verifiedVersion} (expected ${plan.targetVersion}) — may still be restarting`);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
log(`✓ Deployed ${result.version} (Fabric did not report a version to verify against)`);
|
|
360
|
+
}
|
|
361
|
+
return { plan, deployed: true, verifiedVersion, stagingDir };
|
|
362
|
+
}
|
|
363
|
+
finally {
|
|
364
|
+
// SAFETY: always clean up the temp dir.
|
|
365
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
366
|
+
}
|
|
367
|
+
}
|
package/dist/install/clients.js
CHANGED
|
@@ -5,9 +5,28 @@
|
|
|
5
5
|
// - detect(): boolean - returns true if client is installed
|
|
6
6
|
// - wire(options: { agentId: string; flairUrl: string }): { ok: boolean; message: string }
|
|
7
7
|
//
|
|
8
|
+
// Wiring contract (FIX 4 — onboarding dogfood round 1):
|
|
9
|
+
// "wired" MUST mean a config file was actually written. A wire function returns
|
|
10
|
+
// { ok: true } ONLY when it merged the Flair MCP server into the client's real
|
|
11
|
+
// config file. If it cannot (unknown path, write error), it returns
|
|
12
|
+
// { ok: false } with the correct per-OS snippet to paste — never a vague
|
|
13
|
+
// "manual wiring required" while elsewhere the run claims the client is wired.
|
|
14
|
+
// All paths are resolved cross-platform (Linux included) via standard
|
|
15
|
+
// per-client locations under $HOME / $XDG_CONFIG_HOME.
|
|
8
16
|
// ---- Detection helpers ----------------------------------------------------------
|
|
9
17
|
import { spawnSync } from "node:child_process";
|
|
10
|
-
import { accessSync, constants } from "node:fs";
|
|
18
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the user's home dir. Prefer the live HOME/USERPROFILE env over
|
|
23
|
+
* os.homedir(), which caches the value at process start and so ignores a
|
|
24
|
+
* runtime HOME override — same convention as src/cli.ts ("so tests can
|
|
25
|
+
* override"). Production behavior is unchanged (HOME is set on every OS).
|
|
26
|
+
*/
|
|
27
|
+
function resolveHome() {
|
|
28
|
+
return process.env.HOME || process.env.USERPROFILE || homedir();
|
|
29
|
+
}
|
|
11
30
|
/**
|
|
12
31
|
* Check if a command exists in PATH (cross-platform alternative to `which`).
|
|
13
32
|
* Does not spawn a child process — pure filesystem check.
|
|
@@ -85,94 +104,126 @@ function cursorDetect() {
|
|
|
85
104
|
return false;
|
|
86
105
|
}
|
|
87
106
|
}
|
|
88
|
-
// ----
|
|
89
|
-
|
|
107
|
+
// ---- Shared config shapes -------------------------------------------------------
|
|
108
|
+
/** The standard MCP stdio server entry every client (except Codex TOML) uses. */
|
|
109
|
+
function flairMcpEntry(env) {
|
|
90
110
|
return {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
`2. Add or update the "mcpServers" section:\n` +
|
|
95
|
-
` {\n` +
|
|
96
|
-
` "mcpServers": {\n` +
|
|
97
|
-
` "flair": {\n` +
|
|
98
|
-
` "command": "npx",\n` +
|
|
99
|
-
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
100
|
-
` "env": {\n` +
|
|
101
|
-
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
102
|
-
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
103
|
-
` }\n` +
|
|
104
|
-
` }\n` +
|
|
105
|
-
` }\n` +
|
|
106
|
-
` }\n` +
|
|
107
|
-
`3. Restart Claude Code\n` +
|
|
108
|
-
`Note: This is a manual step - the Flair CLI cannot automatically modify Claude Code's settings due to security restrictions.`,
|
|
111
|
+
command: "npx",
|
|
112
|
+
args: ["-y", "@tpsdev-ai/flair-mcp"],
|
|
113
|
+
env: { FLAIR_AGENT_ID: env.FLAIR_AGENT_ID, FLAIR_URL: env.FLAIR_URL },
|
|
109
114
|
};
|
|
110
115
|
}
|
|
116
|
+
/** Pretty-printed JSON `mcpServers.flair` snippet for copy-paste fallbacks. */
|
|
117
|
+
function jsonSnippet(env) {
|
|
118
|
+
return JSON.stringify({ mcpServers: { flair: flairMcpEntry(env) } }, null, 2);
|
|
119
|
+
}
|
|
120
|
+
/** TOML `[mcp_servers.flair]` snippet (Codex format). */
|
|
121
|
+
function tomlSnippet(env) {
|
|
122
|
+
return [
|
|
123
|
+
`[mcp_servers.flair]`,
|
|
124
|
+
`command = "npx"`,
|
|
125
|
+
`args = ["-y", "@tpsdev-ai/flair-mcp"]`,
|
|
126
|
+
``,
|
|
127
|
+
`[mcp_servers.flair.env]`,
|
|
128
|
+
`FLAIR_AGENT_ID = "${env.FLAIR_AGENT_ID}"`,
|
|
129
|
+
`FLAIR_URL = "${env.FLAIR_URL}"`,
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Merge the Flair MCP server into a JSON config file with an `mcpServers` map.
|
|
134
|
+
* Creates the file (and parent dir) if absent; preserves existing servers and
|
|
135
|
+
* any other top-level keys. Returns ok:true only when the file was written.
|
|
136
|
+
*/
|
|
137
|
+
function wireJsonMcp(configPath, label, env) {
|
|
138
|
+
const home = resolveHome();
|
|
139
|
+
const display = configPath.startsWith(home) ? "~" + configPath.slice(home.length) : configPath;
|
|
140
|
+
try {
|
|
141
|
+
let config = {};
|
|
142
|
+
if (existsSync(configPath)) {
|
|
143
|
+
const raw = readFileSync(configPath, "utf-8").trim();
|
|
144
|
+
if (raw)
|
|
145
|
+
config = JSON.parse(raw);
|
|
146
|
+
}
|
|
147
|
+
config.mcpServers = config.mcpServers || {};
|
|
148
|
+
const existing = config.mcpServers.flair;
|
|
149
|
+
if (existing && existing.env?.FLAIR_URL === env.FLAIR_URL && existing.env?.FLAIR_AGENT_ID === env.FLAIR_AGENT_ID) {
|
|
150
|
+
return { ok: true, message: `${label}: already wired in ${display}` };
|
|
151
|
+
}
|
|
152
|
+
config.mcpServers.flair = flairMcpEntry(env);
|
|
153
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
154
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
155
|
+
return { ok: true, message: `${label}: wired ${display} (restart ${label} to pick it up)` };
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
message: `${label}: manual wiring needed (could not write ${display}: ${reason}).\n` +
|
|
162
|
+
` Add this to ${display}:\n${indent(jsonSnippet(env))}`,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function indent(s) {
|
|
167
|
+
return s.split("\n").map((l) => ` ${l}`).join("\n");
|
|
168
|
+
}
|
|
169
|
+
// ---- Per-client config paths (cross-platform, Linux included) --------------------
|
|
170
|
+
/** Cursor: ~/.cursor/mcp.json on every OS. */
|
|
171
|
+
function cursorConfigPath() {
|
|
172
|
+
return join(resolveHome(), ".cursor", "mcp.json");
|
|
173
|
+
}
|
|
174
|
+
/** Gemini CLI: ~/.gemini/settings.json on every OS. */
|
|
175
|
+
function geminiConfigPath() {
|
|
176
|
+
return join(resolveHome(), ".gemini", "settings.json");
|
|
177
|
+
}
|
|
178
|
+
/** Codex CLI: ~/.codex/config.toml on every OS. */
|
|
179
|
+
function codexConfigPath() {
|
|
180
|
+
return join(resolveHome(), ".codex", "config.toml");
|
|
181
|
+
}
|
|
182
|
+
// ---- Internal wiring functions --------------------------------------------------
|
|
183
|
+
//
|
|
184
|
+
// Claude Code wiring lives inline in src/cli.ts (it writes ~/.claude.json, the
|
|
185
|
+
// one client the CLI safely edits, cross-platform). _wireClaudeCode here is the
|
|
186
|
+
// fallback used when something calls the array form; it returns the snippet for
|
|
187
|
+
// ~/.claude.json so the message is unambiguous and correct on every OS.
|
|
188
|
+
function _wireClaudeCode(env) {
|
|
189
|
+
// The real auto-wire is inline in cli.ts. If reached via the array, point at
|
|
190
|
+
// the correct cross-platform path (~/.claude.json — same on macOS/Linux/Win)
|
|
191
|
+
// and give the exact snippet. Never emit macOS-only paths here.
|
|
192
|
+
return wireJsonMcp(join(resolveHome(), ".claude.json"), "Claude Code", env);
|
|
193
|
+
}
|
|
111
194
|
function _wireCodex(env) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
195
|
+
// Codex uses TOML with a [mcp_servers.flair] table. We don't carry a TOML
|
|
196
|
+
// parser, and blind text-appending risks corrupting/duplicating an existing
|
|
197
|
+
// table — so we only auto-write when the file does NOT yet exist (clean
|
|
198
|
+
// create), otherwise emit the exact TOML block to paste.
|
|
199
|
+
const path = codexConfigPath();
|
|
200
|
+
const display = "~/.codex/config.toml";
|
|
201
|
+
try {
|
|
202
|
+
if (existsSync(path)) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
message: `Codex: manual wiring needed — ${display} already exists.\n` +
|
|
206
|
+
` Add this block to ${display}:\n${indent(tomlSnippet(env))}`,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
210
|
+
writeFileSync(path, tomlSnippet(env) + "\n");
|
|
211
|
+
return { ok: true, message: `Codex: wired ${display} (restart Codex to pick it up)` };
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
message: `Codex: manual wiring needed (could not write ${display}: ${reason}).\n` +
|
|
218
|
+
` Add this block to ${display}:\n${indent(tomlSnippet(env))}`,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
132
221
|
}
|
|
133
222
|
function _wireGemini(env) {
|
|
134
|
-
return
|
|
135
|
-
ok: false,
|
|
136
|
-
message: `Manual wiring required for Gemini:\n` +
|
|
137
|
-
`1. Locate Gemini's configuration (check ~/.gemini/config or similar)\n` +
|
|
138
|
-
`2. Add the Flair MCP server configuration:\n` +
|
|
139
|
-
` {\n` +
|
|
140
|
-
` "mcpServers": {\n` +
|
|
141
|
-
` "flair": {\n` +
|
|
142
|
-
` "command": "npx",\n` +
|
|
143
|
-
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
144
|
-
` "env": {\n` +
|
|
145
|
-
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
146
|
-
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
147
|
-
` }\n` +
|
|
148
|
-
` }\n` +
|
|
149
|
-
` }\n` +
|
|
150
|
-
` }\n` +
|
|
151
|
-
`3. Restart Gemini\n` +
|
|
152
|
-
`Note: This is a manual step - the Flair CLI cannot automatically modify Gemini's configuration due to security restrictions.`,
|
|
153
|
-
};
|
|
223
|
+
return wireJsonMcp(geminiConfigPath(), "Gemini", env);
|
|
154
224
|
}
|
|
155
225
|
function _wireCursor(env) {
|
|
156
|
-
return
|
|
157
|
-
ok: false,
|
|
158
|
-
message: `Manual wiring required for Cursor:\n` +
|
|
159
|
-
`1. Locate Cursor's settings file (usually ~/.cursor/settings.json)\n` +
|
|
160
|
-
`2. Add or update the "mcpServers" section:\n` +
|
|
161
|
-
` {\n` +
|
|
162
|
-
` "mcpServers": {\n` +
|
|
163
|
-
` "flair": {\n` +
|
|
164
|
-
` "command": "npx",\n` +
|
|
165
|
-
` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
|
|
166
|
-
` "env": {\n` +
|
|
167
|
-
` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
|
|
168
|
-
` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
|
|
169
|
-
` }\n` +
|
|
170
|
-
` }\n` +
|
|
171
|
-
` }\n` +
|
|
172
|
-
` }\n` +
|
|
173
|
-
`3. Restart Cursor\n` +
|
|
174
|
-
`Note: This is a manual step - the Flair CLI cannot automatically modify Cursor's settings due to security restrictions.`,
|
|
175
|
-
};
|
|
226
|
+
return wireJsonMcp(cursorConfigPath(), "Cursor", env);
|
|
176
227
|
}
|
|
177
228
|
// ---- Exported detection & wiring array ------------------------------------------
|
|
178
229
|
export const ALL_CLIENTS = [
|
|
@@ -274,7 +274,10 @@ export class A2AAdapter extends Resource {
|
|
|
274
274
|
// (GET /a2a passes through; POST /a2a must carry TPS-Ed25519 or admin
|
|
275
275
|
// Basic). The post() handler below additionally enforces sender ==
|
|
276
276
|
// params.agentId for message/send so an authenticated caller can only
|
|
277
|
-
// act AS themselves, not impersonate another agent.
|
|
277
|
+
// act AS themselves, not impersonate another agent. message/send routes
|
|
278
|
+
// a DIRECTED handoff to params.toAgentId (the recipient) by setting the
|
|
279
|
+
// OrgEvent's targetIds = [toAgentId]; OrgEventCatchup filters on that, so
|
|
280
|
+
// the recipient — not the sender — receives the message.
|
|
278
281
|
allowRead() { return true; }
|
|
279
282
|
allowCreate() { return true; }
|
|
280
283
|
async get() {
|
|
@@ -439,22 +442,45 @@ export class A2AAdapter extends Resource {
|
|
|
439
442
|
// Without this check, an authenticated agent could forge OrgEvents
|
|
440
443
|
// attributed to any other agent — defeats the whole signed-envelopes
|
|
441
444
|
// model that delegationChain enforces for TPS mail.
|
|
445
|
+
// NOTE: agentId is the SENDER. The recipient is params.toAgentId.
|
|
442
446
|
if (!callerIsAdmin && callerAgent !== agentId) {
|
|
443
447
|
return rpcError(id, -32001, "Forbidden", {
|
|
444
448
|
detail: `caller ${callerAgent ?? "(anon)"} cannot send as ${agentId}`,
|
|
445
449
|
});
|
|
446
450
|
}
|
|
447
|
-
const
|
|
448
|
-
if (!
|
|
451
|
+
const sender = await databases.flair.Agent.get(agentId).catch(() => null);
|
|
452
|
+
if (!sender) {
|
|
449
453
|
return rpcError(id, -32004, "Agent not found", { agentId });
|
|
450
454
|
}
|
|
455
|
+
// Directed handoff: params.toAgentId (the recipient) controls
|
|
456
|
+
// OrgEvent.targetIds, which is what OrgEventCatchup filters on
|
|
457
|
+
// (`targets.includes(participantId)`). The recipient must exist.
|
|
458
|
+
// Back-compat: if toAgentId is omitted, fall back to the legacy
|
|
459
|
+
// self-scoped broadcast (targetIds = [sender]) so existing callers
|
|
460
|
+
// that only set agentId keep working. The no-spoof guard above is
|
|
461
|
+
// unchanged — toAgentId only affects WHO receives the message, never
|
|
462
|
+
// who it is sent AS, so a sender can hand off to a peer without being
|
|
463
|
+
// able to impersonate anyone.
|
|
464
|
+
const toAgentId = cleanText(params.toAgentId);
|
|
465
|
+
let targetIds;
|
|
466
|
+
if (toAgentId) {
|
|
467
|
+
const recipient = await databases.flair.Agent.get(toAgentId).catch(() => null);
|
|
468
|
+
if (!recipient) {
|
|
469
|
+
return rpcError(id, -32004, "Recipient agent not found", { toAgentId });
|
|
470
|
+
}
|
|
471
|
+
targetIds = [toAgentId];
|
|
472
|
+
}
|
|
473
|
+
else {
|
|
474
|
+
targetIds = [agentId];
|
|
475
|
+
}
|
|
451
476
|
const summary = truncate(firstTextPart(message) || "A2A message received", 200);
|
|
452
477
|
await publishOrgEvent({
|
|
478
|
+
authorId: agentId,
|
|
453
479
|
kind: "a2a.message",
|
|
454
480
|
scope: agentId,
|
|
455
481
|
summary,
|
|
456
|
-
detail: JSON.stringify({ message }),
|
|
457
|
-
targetIds
|
|
482
|
+
detail: JSON.stringify({ message, toAgentId: toAgentId || null }),
|
|
483
|
+
targetIds,
|
|
458
484
|
});
|
|
459
485
|
return rpcResult(id, {
|
|
460
486
|
type: "message",
|