@tpsdev-ai/flair 0.20.0 → 0.21.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 +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +482 -32
- package/dist/deploy.js +280 -18
- package/dist/doctor-client.js +312 -0
- package/dist/install/clients.js +18 -0
- package/dist/rem/restore.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/Federation.js +98 -2
- package/dist/resources/IngestEvents.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/Memory.js +123 -17
- package/dist/resources/MemoryBootstrap.js +46 -36
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/OAuth.js +61 -4
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +55 -3
- package/dist/resources/Relationship.js +10 -1
- package/dist/resources/SemanticSearch.js +14 -14
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +24 -5
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +1 -1
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-provider.js +1 -1
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/version-check.js +167 -0
- package/package.json +2 -2
- package/schemas/agent.graphql +7 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +16 -0
package/dist/deploy.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
// Files that must be present in a Flair package for deployment.
|
|
@@ -11,6 +11,23 @@ export const REQUIRED_PACKAGE_FILES = [
|
|
|
11
11
|
"ui",
|
|
12
12
|
"config.yaml",
|
|
13
13
|
];
|
|
14
|
+
// harper's own deploy CLI defaults to a 120s peer-replication timeout that's
|
|
15
|
+
// too short for Fabric — the CLI aborts mid-replicate with no override,
|
|
16
|
+
// which is exactly the incident this module now guards against. 10 minutes
|
|
17
|
+
// gives cluster-wide replication + install room to actually finish.
|
|
18
|
+
export const DEFAULT_DEPLOYMENT_TIMEOUT_MS = 600_000;
|
|
19
|
+
export const DEFAULT_INSTALL_TIMEOUT_MS = 600_000;
|
|
20
|
+
// Post-deploy verification: how long we'll wait for the served API to come
|
|
21
|
+
// back up after harper's restart, how often we poll while waiting, and how
|
|
22
|
+
// many consecutive reachable responses count as "settled" (a single
|
|
23
|
+
// reachable probe right after restart can be a fluke mid-flap).
|
|
24
|
+
export const DEFAULT_VERIFY_TIMEOUT_MS = 300_000;
|
|
25
|
+
export const VERIFY_POLL_INTERVAL_MS = 15_000;
|
|
26
|
+
export const VERIFY_SETTLE_STREAK = 3;
|
|
27
|
+
// Fallback when dist/resources can't be scanned (e.g. an unusual package
|
|
28
|
+
// layout via --package-root). Memory is Flair's original, always-present
|
|
29
|
+
// resource — a reasonable single thing to check when derivation fails.
|
|
30
|
+
export const FALLBACK_VERIFY_RESOURCE = "Memory";
|
|
14
31
|
export function validateOptions(opts) {
|
|
15
32
|
const errors = [];
|
|
16
33
|
if (!opts.target) {
|
|
@@ -81,6 +98,65 @@ export function validatePackageLayout(packageRoot) {
|
|
|
81
98
|
missing.join(", "));
|
|
82
99
|
}
|
|
83
100
|
}
|
|
101
|
+
// Derive the list of served, table-backed REST resources from the compiled
|
|
102
|
+
// package — no hardcoded resource list. Flair's jsResource files (dist/resources/*.js)
|
|
103
|
+
// contain both real Resource classes (routable, GET-able) and plain helper
|
|
104
|
+
// modules (embeddings, auth, scoring, etc). We only ship dist/ in the
|
|
105
|
+
// published package (resources/*.ts source is not in package.json's `files`),
|
|
106
|
+
// so this scans the COMPILED output, matching the convention every current
|
|
107
|
+
// table-backed resource follows:
|
|
108
|
+
//
|
|
109
|
+
// export class <Name> extends databases.<db>.<Name> { ... }
|
|
110
|
+
//
|
|
111
|
+
// i.e. the exported class name equals the filename equals the underlying
|
|
112
|
+
// table name (Memory.js -> `export class Memory extends databases.flair.Memory`,
|
|
113
|
+
// same for Agent, Soul, MemoryGrant, Credential, OrgEvent, etc). Helper
|
|
114
|
+
// modules are lowercase-first (agent-auth.js, embeddings-provider.js, ...)
|
|
115
|
+
// and never match, so they're skipped without needing a denylist. Files
|
|
116
|
+
// that export a resource extending a *generic* `Resource` base (AgentCard,
|
|
117
|
+
// WorkspaceLatest, action-style endpoints) are deliberately excluded here —
|
|
118
|
+
// they're action/command endpoints, not GET-able collections, and asserting
|
|
119
|
+
// non-404 on them would be the wrong check.
|
|
120
|
+
// Literal regex (no interpolation — satisfies semgrep detect-non-literal-regexp):
|
|
121
|
+
// matches `export class <Name> extends databases.<db>.<Name>` where the `\1`
|
|
122
|
+
// backreference forces the class name and the table name to be identical.
|
|
123
|
+
// Capture group 1 is the resource name; the caller matches it against the filename.
|
|
124
|
+
const EXPORTED_TABLE_CLASS_RE = /export class (\w+) extends databases\.[A-Za-z_$][\w$]*\.\1\b/g;
|
|
125
|
+
export function deriveVerifyResources(packageRoot) {
|
|
126
|
+
const resourcesDir = join(packageRoot, "dist", "resources");
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = readdirSync(resourcesDir);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return [FALLBACK_VERIFY_RESOURCE];
|
|
133
|
+
}
|
|
134
|
+
const names = [];
|
|
135
|
+
for (const entry of entries) {
|
|
136
|
+
if (!entry.endsWith(".js"))
|
|
137
|
+
continue;
|
|
138
|
+
const base = entry.slice(0, -3);
|
|
139
|
+
if (!/^[A-Z]/.test(base))
|
|
140
|
+
continue; // helper modules are camelCase/lowercase
|
|
141
|
+
let src;
|
|
142
|
+
try {
|
|
143
|
+
src = readFileSync(join(resourcesDir, entry), "utf8");
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// The file serves a table resource iff it exports a class whose name matches
|
|
149
|
+
// its filename (base) and extends databases.<db>.<sameName>.
|
|
150
|
+
for (const m of src.matchAll(EXPORTED_TABLE_CLASS_RE)) {
|
|
151
|
+
if (m[1] === base) {
|
|
152
|
+
names.push(base);
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
names.sort();
|
|
158
|
+
return names.length ? names : [FALLBACK_VERIFY_RESOURCE];
|
|
159
|
+
}
|
|
84
160
|
function resolveHarperBin(packageRoot) {
|
|
85
161
|
const local = join(packageRoot, "node_modules/@harperfast/harper/dist/bin/harper.js");
|
|
86
162
|
if (existsSync(local))
|
|
@@ -105,22 +181,199 @@ function resolveHarperBin(packageRoot) {
|
|
|
105
181
|
throw new Error("Could not locate Harper CLI binary (@harperfast/harper). " +
|
|
106
182
|
"Flair deploy requires Harper to be installed alongside Flair.");
|
|
107
183
|
}
|
|
108
|
-
|
|
184
|
+
// Pure arg-array builder — separated from spawnHarper so the timeout
|
|
185
|
+
// passthrough (and the rest of the arg shape) is unit-testable without
|
|
186
|
+
// mocking child_process / actually spawning harper.
|
|
187
|
+
export function buildHarperDeployArgs(opts, url, project) {
|
|
188
|
+
const deploymentTimeoutMs = opts.deploymentTimeoutMs ?? DEFAULT_DEPLOYMENT_TIMEOUT_MS;
|
|
189
|
+
const installTimeoutMs = opts.installTimeoutMs ?? DEFAULT_INSTALL_TIMEOUT_MS;
|
|
190
|
+
const args = [
|
|
191
|
+
"deploy",
|
|
192
|
+
`target=${url}`,
|
|
193
|
+
`project=${project}`,
|
|
194
|
+
`restart=${opts.restart !== false}`,
|
|
195
|
+
`replicated=${opts.replicated !== false}`,
|
|
196
|
+
`deployment_timeout=${deploymentTimeoutMs}`,
|
|
197
|
+
`install_timeout=${installTimeoutMs}`,
|
|
198
|
+
];
|
|
199
|
+
// --ignore-replication-errors escape hatch. Only appended when
|
|
200
|
+
// set — omitted entirely otherwise, so this is a no-op for every existing
|
|
201
|
+
// caller/test that doesn't pass it.
|
|
202
|
+
if (opts.ignoreReplicationErrors) {
|
|
203
|
+
args.push("ignore_replication_errors=true");
|
|
204
|
+
}
|
|
205
|
+
return args;
|
|
206
|
+
}
|
|
207
|
+
// Flaky-peer-replication resilience defaults. See DeployOptions
|
|
208
|
+
// for the full incident writeup this closes.
|
|
209
|
+
export const DEFAULT_DEPLOY_RETRIES = 2;
|
|
210
|
+
export const DEPLOY_RETRY_BACKOFF_MS = [5_000, 10_000];
|
|
211
|
+
// The signature of a Fabric PEER-REPLICATION failure specifically — harper
|
|
212
|
+
// deploys fine to the origin node, but pushing the component to a peer
|
|
213
|
+
// fails, e.g.:
|
|
214
|
+
// "Component 'flair' was deployed on the origin node but failed to
|
|
215
|
+
// replicate to 1 of 1 peer node(s): ... (Error: Connection closed 1006)"
|
|
216
|
+
// This is the CORRECTNESS-CRITICAL part of the fix: matching this pattern
|
|
217
|
+
// (and ONLY this pattern) is what lets us retry a known flake while still
|
|
218
|
+
// failing fast on a real deploy failure (bad package, auth, missing files —
|
|
219
|
+
// none of which mention peer replication and must never be retried).
|
|
220
|
+
// Literal regex, no interpolation.
|
|
221
|
+
export const REPLICATION_FAILURE_RE = /failed to replicate to \d+ (of \d+ )?peer|connection closed\s+1006|ignore_replication_errors/i;
|
|
222
|
+
// Tee-style capture: streams harper's stdout/stderr to the user in real time
|
|
223
|
+
// (unchanged UX — a multi-minute deploy needs live progress, not a black
|
|
224
|
+
// box) while ALSO buffering the combined text so the caller can
|
|
225
|
+
// pattern-match REPLICATION_FAILURE_RE against it after exit. This replaces
|
|
226
|
+
// the previous stdio:"inherit" passthrough, which gave no way to inspect
|
|
227
|
+
// harper's output. stdin stays "inherit" — harper's deploy never reads
|
|
228
|
+
// from it, so there's nothing to tee there.
|
|
229
|
+
function spawnHarperCaptured(bin, args, cwd, env) {
|
|
109
230
|
return new Promise((resolveP, rejectP) => {
|
|
110
231
|
const p = spawn(process.execPath, [bin, ...args], {
|
|
111
232
|
cwd,
|
|
112
|
-
stdio: "inherit",
|
|
233
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
113
234
|
env,
|
|
114
235
|
});
|
|
115
|
-
|
|
116
|
-
p.on("
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
else
|
|
120
|
-
rejectP(new Error(`harper deploy exited with code ${code}`));
|
|
236
|
+
const chunks = [];
|
|
237
|
+
p.stdout?.on("data", (d) => {
|
|
238
|
+
process.stdout.write(d);
|
|
239
|
+
chunks.push(d.toString("utf8"));
|
|
121
240
|
});
|
|
241
|
+
p.stderr?.on("data", (d) => {
|
|
242
|
+
process.stderr.write(d);
|
|
243
|
+
chunks.push(d.toString("utf8"));
|
|
244
|
+
});
|
|
245
|
+
p.on("error", rejectP);
|
|
246
|
+
p.on("exit", (code) => resolveP({ code, output: chunks.join("") }));
|
|
122
247
|
});
|
|
123
248
|
}
|
|
249
|
+
function sleep(ms) {
|
|
250
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
251
|
+
}
|
|
252
|
+
// Runs `harper deploy`, retrying ONLY on the flaky peer-replication failure
|
|
253
|
+
// signature (REPLICATION_FAILURE_RE) — the real incident this closes: the
|
|
254
|
+
// origin deployed fine, replication to a peer failed with a transient
|
|
255
|
+
// "Connection closed 1006", and a bare manual retry cleared it with no
|
|
256
|
+
// other change. This re-runs the FULL harper deploy (prepare/install/
|
|
257
|
+
// replicate), not just a peer-only re-push — wasteful, but it's exactly
|
|
258
|
+
// what worked by hand, and harper's CLI has no "retry replication only"
|
|
259
|
+
// entry point. Any other failure (bad package, auth, missing files, ...)
|
|
260
|
+
// is never retried — it fails fast, same as before this change.
|
|
261
|
+
async function runHarperDeploy(bin, args, cwd, env, opts) {
|
|
262
|
+
const maxRetries = opts.deployRetries ?? DEFAULT_DEPLOY_RETRIES;
|
|
263
|
+
const backoff = opts.deployRetryBackoffMs ?? DEPLOY_RETRY_BACKOFF_MS;
|
|
264
|
+
const totalAttempts = Math.max(1, maxRetries + 1);
|
|
265
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt++) {
|
|
266
|
+
const { code, output } = await spawnHarperCaptured(bin, args, cwd, env);
|
|
267
|
+
if (code === 0)
|
|
268
|
+
return { replicationWarning: false };
|
|
269
|
+
const isReplicationFailure = REPLICATION_FAILURE_RE.test(output);
|
|
270
|
+
const isLastAttempt = attempt === totalAttempts;
|
|
271
|
+
if (isReplicationFailure && !isLastAttempt) {
|
|
272
|
+
const waitMs = backoff[Math.min(attempt - 1, backoff.length - 1)];
|
|
273
|
+
// Self-healing must be visible, never silent — console.warn directly
|
|
274
|
+
// (not gated behind onProgress, which some callers like
|
|
275
|
+
// fabric-upgrade.ts don't wire up) so this is loud regardless of caller.
|
|
276
|
+
console.warn(`⚠ flair deploy: replication flake on attempt ${attempt}/${totalAttempts} ` +
|
|
277
|
+
`(harper deploy exited ${code}, peer-replication signature matched) — ` +
|
|
278
|
+
`retrying in ${Math.round(waitMs / 1000)}s...`);
|
|
279
|
+
opts.onProgress?.(`replication flake on attempt ${attempt}/${totalAttempts} — retrying in ${Math.round(waitMs / 1000)}s...`);
|
|
280
|
+
await sleep(waitMs);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (isReplicationFailure && opts.ignoreReplicationErrors) {
|
|
284
|
+
console.warn(`⚠ flair deploy: peer replication still failing after ${attempt} attempt(s), ` +
|
|
285
|
+
`but --ignore-replication-errors is set — treating this as a WARNED SUCCESS ` +
|
|
286
|
+
`(deployed to the origin node only; the peer will need to catch up via normal ` +
|
|
287
|
+
`federation sync or a later deploy).`);
|
|
288
|
+
opts.onProgress?.(`WARNING: proceeding origin-only — peer replication did not complete after ${attempt} attempt(s)`);
|
|
289
|
+
return { replicationWarning: true };
|
|
290
|
+
}
|
|
291
|
+
if (isReplicationFailure) {
|
|
292
|
+
throw new Error(`harper deploy exited with code ${code}: peer replication failed after ${attempt} attempt(s) ` +
|
|
293
|
+
`(retries exhausted). Pass --ignore-replication-errors to accept an origin-only deploy, ` +
|
|
294
|
+
`or re-run once the peer link recovers.`);
|
|
295
|
+
}
|
|
296
|
+
throw new Error(`harper deploy exited with code ${code}`);
|
|
297
|
+
}
|
|
298
|
+
// Unreachable — the loop always returns or throws — but keeps TS happy.
|
|
299
|
+
throw new Error("harper deploy: exhausted retry loop without resolving");
|
|
300
|
+
}
|
|
301
|
+
// A single reachability probe against the served (REST) base URL. Harper
|
|
302
|
+
// restarts the process after every deploy, so the endpoint FLAPS for a bit —
|
|
303
|
+
// connection refused / reset / DNS blips are all EXPECTED right after
|
|
304
|
+
// restart, not a failure signal by themselves. Any HTTP response at all
|
|
305
|
+
// (regardless of status code — a 404 on `/` is normal) proves the process
|
|
306
|
+
// is back up and terminating TLS/HTTP again; that's all "reachable" means
|
|
307
|
+
// here. Resource-level 404s are a separate, later check.
|
|
308
|
+
async function probeReachable(baseUrl, fetchImpl) {
|
|
309
|
+
try {
|
|
310
|
+
await fetchImpl(baseUrl, { method: "GET", signal: AbortSignal.timeout(10_000) });
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
async function pollUntilSettled(baseUrl, timeoutMs, pollIntervalMs, settleStreak, fetchImpl, onProgress) {
|
|
318
|
+
const deadline = Date.now() + timeoutMs;
|
|
319
|
+
let streak = 0;
|
|
320
|
+
let attempt = 0;
|
|
321
|
+
for (;;) {
|
|
322
|
+
attempt++;
|
|
323
|
+
const reachable = await probeReachable(baseUrl, fetchImpl);
|
|
324
|
+
streak = reachable ? streak + 1 : 0;
|
|
325
|
+
if (!reachable) {
|
|
326
|
+
onProgress?.(`waiting for ${baseUrl} to come back up after restart (attempt ${attempt})...`);
|
|
327
|
+
}
|
|
328
|
+
if (streak >= settleStreak)
|
|
329
|
+
return;
|
|
330
|
+
if (Date.now() >= deadline) {
|
|
331
|
+
throw new Error(`deploy verification: ${baseUrl} did not settle within ${timeoutMs}ms after restart ` +
|
|
332
|
+
`(Harper never came back up, or is unusually slow to restart post-deploy)`);
|
|
333
|
+
}
|
|
334
|
+
await sleep(pollIntervalMs);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function verifyResourcesServing(baseUrl, resources, fetchImpl) {
|
|
338
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
339
|
+
const notServing = [];
|
|
340
|
+
for (const name of resources) {
|
|
341
|
+
const path = `${base}/${name}`;
|
|
342
|
+
let status;
|
|
343
|
+
try {
|
|
344
|
+
const res = await fetchImpl(path, { method: "GET", signal: AbortSignal.timeout(10_000) });
|
|
345
|
+
status = res.status;
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
throw new Error(`deploy verification: request to ${path} failed even after the endpoint settled: ${err?.message ?? err}`);
|
|
349
|
+
}
|
|
350
|
+
// 404 = the resource genuinely isn't being served (this is the incident:
|
|
351
|
+
// harper reported "Successfully deployed" while the component was empty).
|
|
352
|
+
// 401 = auth-gated, which means the resource IS being served correctly.
|
|
353
|
+
// 200 = served + accessible. Both count as pass.
|
|
354
|
+
if (status === 404)
|
|
355
|
+
notServing.push(name);
|
|
356
|
+
}
|
|
357
|
+
if (notServing.length) {
|
|
358
|
+
const list = notServing.map((n) => `/${n}`).join(", ");
|
|
359
|
+
throw new Error(`deploy reported success but ${list} return${notServing.length === 1 ? "s" : ""} 404 — ` +
|
|
360
|
+
`component is not serving; likely deployed the wrong package root`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// The tool must not be able to lie. harper's deploy CLI can print
|
|
364
|
+
// "Successfully deployed" for an empty component — the only way to know the
|
|
365
|
+
// deploy actually worked is to curl the served API and check it isn't 404.
|
|
366
|
+
// This polls the served base URL (443, NOT the :9925 ops API deploy talks
|
|
367
|
+
// to) until it settles after harper's post-deploy restart, then asserts the
|
|
368
|
+
// derived resource(s) respond non-404.
|
|
369
|
+
export async function verifyDeployServing(o) {
|
|
370
|
+
const { baseUrl, resources, timeoutMs = DEFAULT_VERIFY_TIMEOUT_MS, pollIntervalMs = VERIFY_POLL_INTERVAL_MS, settleStreak = VERIFY_SETTLE_STREAK, fetchImpl = fetch, onProgress, } = o;
|
|
371
|
+
onProgress?.(`verifying ${baseUrl} is actually serving (not just reported deployed)...`);
|
|
372
|
+
await pollUntilSettled(baseUrl, timeoutMs, pollIntervalMs, settleStreak, fetchImpl, onProgress);
|
|
373
|
+
onProgress?.(`settled — checking ${resources.map((r) => `/${r}`).join(", ")}...`);
|
|
374
|
+
await verifyResourcesServing(baseUrl, resources, fetchImpl);
|
|
375
|
+
onProgress?.(`served API verified non-404 for ${resources.length} resource(s)`);
|
|
376
|
+
}
|
|
124
377
|
export async function deploy(opts) {
|
|
125
378
|
const errors = validateOptions(opts);
|
|
126
379
|
if (errors.length) {
|
|
@@ -141,13 +394,7 @@ export async function deploy(opts) {
|
|
|
141
394
|
"Pass --fabric-user + --fabric-password instead.");
|
|
142
395
|
}
|
|
143
396
|
const harperBin = resolveHarperBin(packageRoot);
|
|
144
|
-
const args =
|
|
145
|
-
"deploy",
|
|
146
|
-
`target=${url}`,
|
|
147
|
-
`project=${project}`,
|
|
148
|
-
`restart=${opts.restart !== false}`,
|
|
149
|
-
`replicated=${opts.replicated !== false}`,
|
|
150
|
-
];
|
|
397
|
+
const args = buildHarperDeployArgs(opts, url, project);
|
|
151
398
|
// Credentials go via env, not argv, so they don't appear in `ps` output
|
|
152
399
|
// for the lifetime of the Harper child process. Harper's cliOperations
|
|
153
400
|
// reads CLI_TARGET_USERNAME / CLI_TARGET_PASSWORD as env fallbacks.
|
|
@@ -156,6 +403,21 @@ export async function deploy(opts) {
|
|
|
156
403
|
CLI_TARGET_USERNAME: opts.fabricUser,
|
|
157
404
|
CLI_TARGET_PASSWORD: opts.fabricPassword,
|
|
158
405
|
};
|
|
159
|
-
await
|
|
160
|
-
|
|
406
|
+
const { replicationWarning } = await runHarperDeploy(harperBin, args, packageRoot, childEnv, opts);
|
|
407
|
+
// harper can print "Successfully deployed" for a component that isn't
|
|
408
|
+
// actually serving anything (the incident this closes: an empty deploy,
|
|
409
|
+
// reported success, /Memory 404ing in prod). Verify by curling the served
|
|
410
|
+
// API — on by default, escape hatch via --no-verify.
|
|
411
|
+
if (opts.verify !== false) {
|
|
412
|
+
const resources = opts.verifyResources?.length
|
|
413
|
+
? opts.verifyResources
|
|
414
|
+
: deriveVerifyResources(packageRoot);
|
|
415
|
+
await verifyDeployServing({
|
|
416
|
+
baseUrl: url,
|
|
417
|
+
resources,
|
|
418
|
+
timeoutMs: opts.verifyTimeoutMs ?? DEFAULT_VERIFY_TIMEOUT_MS,
|
|
419
|
+
onProgress: opts.onProgress,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return { url, project, version, packageRoot, dryRun: false, replicationWarning };
|
|
161
423
|
}
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
// ─── Doctor: client integration checks (flair#588) ──────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// `flair doctor` diagnosed the SERVER side only (Harper port, keys, config,
|
|
4
|
+
// embeddings, data dir). It had zero visibility into whether the CLIENT
|
|
5
|
+
// integration — the MCP wiring an agent like Claude Code actually uses — is
|
|
6
|
+
// working. A real incident found users with partial setups (MCP block wired
|
|
7
|
+
// but no CLAUDE.md line; or no SessionStart hook) that silently no-op, with
|
|
8
|
+
// no way to tell "is Flair working for my agent?" short of an incident.
|
|
9
|
+
//
|
|
10
|
+
// This module is pure filesystem logic (no network, no crypto) so it's fast
|
|
11
|
+
// and fully unit-testable in isolation — mirrors test/unit/client-wiring.test.ts's
|
|
12
|
+
// technique of overriding process.env.HOME to a temp dir. The two
|
|
13
|
+
// network-dependent checks (reachability + agent registration) live in
|
|
14
|
+
// src/cli.ts alongside authFetch/resolveKeyPath, which they reuse.
|
|
15
|
+
//
|
|
16
|
+
// Every read here is try/catch-wrapped: a missing or malformed config file is
|
|
17
|
+
// "not present", never a thrown error — doctor must never crash or hang on a
|
|
18
|
+
// broken client config.
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { dirname, join } from "node:path";
|
|
21
|
+
import { clientConfigPath } from "./install/clients.js";
|
|
22
|
+
// The exact substring `flair init` writes into CLAUDE.md (src/cli.ts, the
|
|
23
|
+
// `init` action) and that the doctor check + fix both key off of.
|
|
24
|
+
export const CLAUDE_MD_BOOTSTRAP_MARKER = "mcp__flair__bootstrap";
|
|
25
|
+
const CLAUDE_MD_BOOTSTRAP_LINE = "At the start of every session, run mcp__flair__bootstrap before responding.";
|
|
26
|
+
// The exact substring identifying a Flair SessionStart hook command (see
|
|
27
|
+
// docs/mcp-clients.md "Auto-recall on session start").
|
|
28
|
+
export const SESSION_START_HOOK_MARKER = "flair-session-start";
|
|
29
|
+
// ── shared helpers ──────────────────────────────────────────────────────────
|
|
30
|
+
/**
|
|
31
|
+
* Run `fn` with process.env.HOME temporarily pointed at `homeDir`, then
|
|
32
|
+
* restore it. clientConfigPath() (src/install/clients.ts) resolves the home
|
|
33
|
+
* dir via HOME/USERPROFILE at call time (not cached), so this lets us reuse
|
|
34
|
+
* that single source of truth for per-client config paths while keeping
|
|
35
|
+
* doctor-client's own functions parameterized by an explicit homeDir for
|
|
36
|
+
* tests — no test ever touches the real ~/.claude.json etc. The override is
|
|
37
|
+
* synchronous and restored before this function returns, so it's safe even
|
|
38
|
+
* though process.env is process-global.
|
|
39
|
+
*/
|
|
40
|
+
function withHome(homeDir, fn) {
|
|
41
|
+
const prev = process.env.HOME;
|
|
42
|
+
process.env.HOME = homeDir;
|
|
43
|
+
try {
|
|
44
|
+
return fn();
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (prev === undefined)
|
|
48
|
+
delete process.env.HOME;
|
|
49
|
+
else
|
|
50
|
+
process.env.HOME = prev;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function readTextFile(path) {
|
|
54
|
+
try {
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
return null;
|
|
57
|
+
return readFileSync(path, "utf-8");
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read the Flair MCP server block from `clientId`'s config file. `present`
|
|
65
|
+
* is true only when the block exists AND both FLAIR_AGENT_ID and FLAIR_URL
|
|
66
|
+
* are set (non-empty) — a half-wired block (e.g. block present, env missing)
|
|
67
|
+
* counts as absent for the pass/fail check, but agentId/flairUrl are still
|
|
68
|
+
* returned when partially found so callers can use whatever is known.
|
|
69
|
+
*/
|
|
70
|
+
export function readClientMcpBlock(clientId, homeDir) {
|
|
71
|
+
const configPath = withHome(homeDir, () => clientConfigPath(clientId));
|
|
72
|
+
return clientId === "codex" ? readCodexFlairBlock(configPath) : readJsonFlairBlock(configPath);
|
|
73
|
+
}
|
|
74
|
+
function readJsonFlairBlock(configPath) {
|
|
75
|
+
const raw = readTextFile(configPath);
|
|
76
|
+
if (!raw || !raw.trim())
|
|
77
|
+
return { present: false, configPath };
|
|
78
|
+
try {
|
|
79
|
+
const config = JSON.parse(raw);
|
|
80
|
+
const flair = config?.mcpServers?.flair;
|
|
81
|
+
if (!flair || typeof flair !== "object")
|
|
82
|
+
return { present: false, configPath };
|
|
83
|
+
const agentId = typeof flair.env?.FLAIR_AGENT_ID === "string" && flair.env.FLAIR_AGENT_ID ? flair.env.FLAIR_AGENT_ID : undefined;
|
|
84
|
+
const flairUrl = typeof flair.env?.FLAIR_URL === "string" && flair.env.FLAIR_URL ? flair.env.FLAIR_URL : undefined;
|
|
85
|
+
return { present: !!agentId && !!flairUrl, configPath, agentId, flairUrl };
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Malformed JSON — treat as "not present", never throw.
|
|
89
|
+
return { present: false, configPath };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Codex's config is TOML, and this repo carries no TOML parser (see the
|
|
94
|
+
* comment on _wireCodex in src/install/clients.ts) — so this is a lightweight
|
|
95
|
+
* string scan, matching the exact shape _wireCodex/tomlSnippet() produce:
|
|
96
|
+
*
|
|
97
|
+
* [mcp_servers.flair]
|
|
98
|
+
* command = "npx"
|
|
99
|
+
* args = ["-y", "@tpsdev-ai/flair-mcp"]
|
|
100
|
+
*
|
|
101
|
+
* [mcp_servers.flair.env]
|
|
102
|
+
* FLAIR_AGENT_ID = "..."
|
|
103
|
+
* FLAIR_URL = "..."
|
|
104
|
+
*
|
|
105
|
+
* We locate the `[mcp_servers.flair]` header, then collect lines until a
|
|
106
|
+
* header that is NOT part of this table (i.e. doesn't start with
|
|
107
|
+
* "[mcp_servers.flair") — deliberately does NOT stop at the nested
|
|
108
|
+
* `[mcp_servers.flair.env]` sub-table, since that's where the two env keys
|
|
109
|
+
* actually live.
|
|
110
|
+
*/
|
|
111
|
+
function readCodexFlairBlock(configPath) {
|
|
112
|
+
const raw = readTextFile(configPath);
|
|
113
|
+
if (!raw)
|
|
114
|
+
return { present: false, configPath };
|
|
115
|
+
const scanned = scanCodexFlairBlock(raw);
|
|
116
|
+
return { present: scanned.present, configPath, agentId: scanned.agentId, flairUrl: scanned.flairUrl };
|
|
117
|
+
}
|
|
118
|
+
function scanCodexFlairBlock(raw) {
|
|
119
|
+
const startMatch = raw.match(/^\[mcp_servers\.flair\]\s*$/m);
|
|
120
|
+
if (!startMatch || startMatch.index === undefined)
|
|
121
|
+
return { present: false };
|
|
122
|
+
const rest = raw.slice(startMatch.index);
|
|
123
|
+
const lines = rest.split("\n");
|
|
124
|
+
const blockLines = [lines[0]];
|
|
125
|
+
for (let i = 1; i < lines.length; i++) {
|
|
126
|
+
const trimmed = lines[i].trim();
|
|
127
|
+
if (trimmed.startsWith("[") && !trimmed.startsWith("[mcp_servers.flair"))
|
|
128
|
+
break;
|
|
129
|
+
blockLines.push(lines[i]);
|
|
130
|
+
}
|
|
131
|
+
const block = blockLines.join("\n");
|
|
132
|
+
const agentMatch = block.match(/^\s*FLAIR_AGENT_ID\s*=\s*"([^"]*)"/m);
|
|
133
|
+
const urlMatch = block.match(/^\s*FLAIR_URL\s*=\s*"([^"]*)"/m);
|
|
134
|
+
const agentId = agentMatch?.[1] || undefined;
|
|
135
|
+
const flairUrl = urlMatch?.[1] || undefined;
|
|
136
|
+
return { present: !!agentId && !!flairUrl, agentId, flairUrl };
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Pass when EITHER the project-scoped `${cwd}/CLAUDE.md` or the user-level
|
|
140
|
+
* `~/.claude/CLAUDE.md` contains the bootstrap marker — Claude Code loads
|
|
141
|
+
* both. Checks cwd first (the convention docs/claude-code.md documents and
|
|
142
|
+
* what `flair init` tells users to edit).
|
|
143
|
+
*/
|
|
144
|
+
export function checkClaudeMdBootstrap(cwd, homeDir) {
|
|
145
|
+
const cwdPath = join(cwd, "CLAUDE.md");
|
|
146
|
+
const cwdContent = readTextFile(cwdPath);
|
|
147
|
+
if (cwdContent && cwdContent.includes(CLAUDE_MD_BOOTSTRAP_MARKER)) {
|
|
148
|
+
return { present: true, path: cwdPath };
|
|
149
|
+
}
|
|
150
|
+
const homePath = join(homeDir, ".claude", "CLAUDE.md");
|
|
151
|
+
const homeContent = readTextFile(homePath);
|
|
152
|
+
if (homeContent && homeContent.includes(CLAUDE_MD_BOOTSTRAP_MARKER)) {
|
|
153
|
+
return { present: true, path: homePath };
|
|
154
|
+
}
|
|
155
|
+
return { present: false, path: null };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Append the bootstrap instruction to `${cwd}/CLAUDE.md` (creating it if
|
|
159
|
+
* absent). Idempotent — safe to call twice; a second call is a no-op that
|
|
160
|
+
* still reports ok:true.
|
|
161
|
+
*/
|
|
162
|
+
export function fixClaudeMdBootstrap(cwd) {
|
|
163
|
+
const path = join(cwd, "CLAUDE.md");
|
|
164
|
+
try {
|
|
165
|
+
const existing = readTextFile(path) ?? "";
|
|
166
|
+
if (existing.includes(CLAUDE_MD_BOOTSTRAP_MARKER)) {
|
|
167
|
+
return { ok: true, path, message: `already present in ${path}` };
|
|
168
|
+
}
|
|
169
|
+
const separator = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
170
|
+
const block = `${separator}## Flair memory\n\n${CLAUDE_MD_BOOTSTRAP_LINE}\n`;
|
|
171
|
+
writeFileSync(path, existing + block);
|
|
172
|
+
return { ok: true, path, message: `added bootstrap instruction to ${path}` };
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
176
|
+
return { ok: false, path, message: `could not write ${path}: ${reason}` };
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Pass when ~/.claude/settings.json exists, parses as JSON, and ANY hook
|
|
181
|
+
* command anywhere under hooks.SessionStart[*].hooks[*].command contains the
|
|
182
|
+
* flair-session-start marker (see docs/mcp-clients.md for the exact shape).
|
|
183
|
+
*/
|
|
184
|
+
export function checkSessionStartHook(homeDir) {
|
|
185
|
+
const path = join(homeDir, ".claude", "settings.json");
|
|
186
|
+
const raw = readTextFile(path);
|
|
187
|
+
if (!raw || !raw.trim())
|
|
188
|
+
return { present: false, path };
|
|
189
|
+
try {
|
|
190
|
+
const config = JSON.parse(raw);
|
|
191
|
+
const groups = config?.hooks?.SessionStart;
|
|
192
|
+
if (!Array.isArray(groups))
|
|
193
|
+
return { present: false, path };
|
|
194
|
+
for (const group of groups) {
|
|
195
|
+
const hooks = group?.hooks;
|
|
196
|
+
if (!Array.isArray(hooks))
|
|
197
|
+
continue;
|
|
198
|
+
for (const hook of hooks) {
|
|
199
|
+
if (typeof hook?.command === "string" && hook.command.includes(SESSION_START_HOOK_MARKER)) {
|
|
200
|
+
return { present: true, path };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { present: false, path };
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return { present: false, path };
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Merge-safe insert of a Flair SessionStart hook group into
|
|
212
|
+
* ~/.claude/settings.json — creates the file/array if absent, preserves any
|
|
213
|
+
* other existing hooks/keys (read-parse-merge-write, mirroring wireJsonMcp's
|
|
214
|
+
* merge safety in src/install/clients.ts; never a blind overwrite). Dedupes:
|
|
215
|
+
* a no-op (ok:true) if a matching hook is already present, so it's safe to
|
|
216
|
+
* call twice.
|
|
217
|
+
*/
|
|
218
|
+
export function fixSessionStartHook(homeDir, agentId) {
|
|
219
|
+
const path = join(homeDir, ".claude", "settings.json");
|
|
220
|
+
if (!agentId) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
path,
|
|
224
|
+
message: "no agent id known — pass --agent <id> (or set FLAIR_AGENT_ID) so doctor knows which agent to wire the hook to",
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
let config = {};
|
|
229
|
+
const raw = readTextFile(path);
|
|
230
|
+
if (raw && raw.trim())
|
|
231
|
+
config = JSON.parse(raw);
|
|
232
|
+
config.hooks = config.hooks && typeof config.hooks === "object" ? config.hooks : {};
|
|
233
|
+
config.hooks.SessionStart = Array.isArray(config.hooks.SessionStart) ? config.hooks.SessionStart : [];
|
|
234
|
+
const alreadyPresent = config.hooks.SessionStart.some((group) => Array.isArray(group?.hooks) &&
|
|
235
|
+
group.hooks.some((h) => typeof h?.command === "string" && h.command.includes(SESSION_START_HOOK_MARKER)));
|
|
236
|
+
if (alreadyPresent) {
|
|
237
|
+
return { ok: true, path, message: `already present in ${path}` };
|
|
238
|
+
}
|
|
239
|
+
config.hooks.SessionStart.push({
|
|
240
|
+
hooks: [
|
|
241
|
+
{
|
|
242
|
+
type: "command",
|
|
243
|
+
command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
});
|
|
247
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
248
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
249
|
+
return { ok: true, path, message: `added SessionStart hook to ${path} (agent '${agentId}')` };
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
253
|
+
return { ok: false, path, message: `could not write ${path}: ${reason}` };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function indentLines(s) {
|
|
257
|
+
return s
|
|
258
|
+
.split("\n")
|
|
259
|
+
.map((l) => ` ${l}`)
|
|
260
|
+
.join("\n");
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Apply-or-report for the CLAUDE.md bootstrap leg. Idempotent: a second call
|
|
264
|
+
* after the line is present (whether from a prior call or already there)
|
|
265
|
+
* reports ok:true, applied:false — safe to call on every `flair init`.
|
|
266
|
+
*/
|
|
267
|
+
export function applyOrReportClaudeMdBootstrap(cwd, homeDir, skip) {
|
|
268
|
+
const existing = checkClaudeMdBootstrap(cwd, homeDir);
|
|
269
|
+
if (existing.present) {
|
|
270
|
+
return { applied: false, ok: true, message: `CLAUDE.md already has the bootstrap instruction (${existing.path})` };
|
|
271
|
+
}
|
|
272
|
+
const hint = `Add to your CLAUDE.md:\n${indentLines(CLAUDE_MD_BOOTSTRAP_LINE)}`;
|
|
273
|
+
if (skip) {
|
|
274
|
+
return { applied: false, ok: false, message: "CLAUDE.md bootstrap instruction skipped (--skip-claude-md)", hint };
|
|
275
|
+
}
|
|
276
|
+
const fix = fixClaudeMdBootstrap(cwd);
|
|
277
|
+
return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
|
|
278
|
+
}
|
|
279
|
+
function sessionStartHookHint(agentId, path) {
|
|
280
|
+
const snippet = {
|
|
281
|
+
hooks: {
|
|
282
|
+
SessionStart: [
|
|
283
|
+
{
|
|
284
|
+
hooks: [
|
|
285
|
+
{
|
|
286
|
+
type: "command",
|
|
287
|
+
command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
return `Add this to ${path}:\n${indentLines(JSON.stringify(snippet, null, 2))}`;
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Apply-or-report for the SessionStart hook leg. Idempotent: a second call
|
|
298
|
+
* after the hook is present (whether from a prior call or already there)
|
|
299
|
+
* reports ok:true, applied:false — safe to call on every `flair init`.
|
|
300
|
+
*/
|
|
301
|
+
export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
|
|
302
|
+
const existing = checkSessionStartHook(homeDir);
|
|
303
|
+
if (existing.present) {
|
|
304
|
+
return { applied: false, ok: true, message: `SessionStart hook already wired in ${existing.path}` };
|
|
305
|
+
}
|
|
306
|
+
const hint = sessionStartHookHint(agentId, existing.path);
|
|
307
|
+
if (skip) {
|
|
308
|
+
return { applied: false, ok: false, message: "SessionStart hook skipped (--skip-hook)", hint };
|
|
309
|
+
}
|
|
310
|
+
const fix = fixSessionStartHook(homeDir, agentId);
|
|
311
|
+
return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
|
|
312
|
+
}
|
package/dist/install/clients.js
CHANGED
|
@@ -179,6 +179,24 @@ function geminiConfigPath() {
|
|
|
179
179
|
function codexConfigPath() {
|
|
180
180
|
return join(resolveHome(), ".codex", "config.toml");
|
|
181
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Single dispatcher for "where does this client's MCP config live" — used by
|
|
184
|
+
* `flair doctor`'s client-integration checks (flair#588) to read the config
|
|
185
|
+
* without duplicating the per-client path logic that already lives here.
|
|
186
|
+
* Additive only: does not change existing wire/detect behavior.
|
|
187
|
+
*/
|
|
188
|
+
export function clientConfigPath(id) {
|
|
189
|
+
switch (id) {
|
|
190
|
+
case "claude-code":
|
|
191
|
+
return join(resolveHome(), ".claude.json");
|
|
192
|
+
case "codex":
|
|
193
|
+
return codexConfigPath();
|
|
194
|
+
case "gemini":
|
|
195
|
+
return geminiConfigPath();
|
|
196
|
+
case "cursor":
|
|
197
|
+
return cursorConfigPath();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
182
200
|
// ---- Internal wiring functions --------------------------------------------------
|
|
183
201
|
//
|
|
184
202
|
// Claude Code wiring lives inline in src/cli.ts (it writes ~/.claude.json, the
|