@tpsdev-ai/flair 0.4.15 → 0.5.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 +49 -0
- package/dist/cli.js +971 -102
- package/dist/keystore.js +116 -0
- package/dist/resources/AdminConnectors.js +54 -0
- package/dist/resources/AdminDashboard.js +68 -0
- package/dist/resources/AdminIdp.js +57 -0
- package/dist/resources/AdminInstance.js +64 -0
- package/dist/resources/AdminMemory.js +92 -0
- package/dist/resources/AdminPrincipals.js +71 -0
- package/dist/resources/Agent.js +49 -2
- package/dist/resources/Credential.js +105 -0
- package/dist/resources/Federation.js +315 -0
- package/dist/resources/Memory.js +11 -0
- package/dist/resources/MemoryBootstrap.js +91 -4
- package/dist/resources/OAuth.js +381 -0
- package/dist/resources/Relationship.js +96 -0
- package/dist/resources/SemanticSearch.js +20 -4
- package/dist/resources/XAA.js +264 -0
- package/dist/resources/admin-layout.js +94 -0
- package/dist/resources/auth-middleware.js +39 -21
- package/dist/resources/embeddings-provider.js +33 -30
- package/dist/resources/federation-crypto.js +52 -0
- package/dist/resources/health.js +67 -1
- package/dist/src/keystore.js +116 -0
- package/package.json +5 -3
- package/schemas/agent.graphql +51 -2
- package/schemas/federation.graphql +51 -0
- package/schemas/memory.graphql +20 -0
- package/schemas/oauth.graphql +72 -0
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,8 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join, resolve as resolvePath } from "node:path";
|
|
7
7
|
import { spawn } from "node:child_process";
|
|
8
8
|
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
|
+
import { keystore } from "./keystore.js";
|
|
10
|
+
import { signBody } from "../resources/federation-crypto.js";
|
|
9
11
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
10
12
|
const DEFAULT_PORT = 19926;
|
|
11
13
|
const DEFAULT_OPS_PORT = 19925;
|
|
@@ -127,6 +129,11 @@ async function api(method, path, body) {
|
|
|
127
129
|
if (token) {
|
|
128
130
|
authHeader = `Bearer ${token}`;
|
|
129
131
|
}
|
|
132
|
+
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
133
|
+
// Admin Basic auth — used by federation, backup, and other admin CLI commands
|
|
134
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
135
|
+
authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
136
|
+
}
|
|
130
137
|
else {
|
|
131
138
|
// Extract agentId from body (POST/PUT) or URL query params (GET)
|
|
132
139
|
let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
|
|
@@ -357,14 +364,37 @@ program
|
|
|
357
364
|
// Only applied to install — run needs real HOME for npm/node resolution.
|
|
358
365
|
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
359
366
|
console.log("Installing Harper...");
|
|
367
|
+
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
360
368
|
await new Promise((resolve, reject) => {
|
|
361
369
|
let output = "";
|
|
370
|
+
let dotTimer = null;
|
|
362
371
|
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
372
|
+
// Print progress dots so the terminal doesn't appear frozen during model download
|
|
373
|
+
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
363
374
|
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
364
375
|
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
365
|
-
install.on("exit", (code) =>
|
|
366
|
-
|
|
367
|
-
|
|
376
|
+
install.on("exit", (code) => {
|
|
377
|
+
if (dotTimer) {
|
|
378
|
+
clearInterval(dotTimer);
|
|
379
|
+
process.stdout.write("\n");
|
|
380
|
+
}
|
|
381
|
+
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
382
|
+
});
|
|
383
|
+
install.on("error", (err) => {
|
|
384
|
+
if (dotTimer) {
|
|
385
|
+
clearInterval(dotTimer);
|
|
386
|
+
process.stdout.write("\n");
|
|
387
|
+
}
|
|
388
|
+
reject(err);
|
|
389
|
+
});
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
install.kill();
|
|
392
|
+
if (dotTimer) {
|
|
393
|
+
clearInterval(dotTimer);
|
|
394
|
+
process.stdout.write("\n");
|
|
395
|
+
}
|
|
396
|
+
reject(new Error(`Harper install timed out: ${output}`));
|
|
397
|
+
}, 60_000);
|
|
368
398
|
});
|
|
369
399
|
}
|
|
370
400
|
// Start Harper with flair loaded as a component (the "." arg).
|
|
@@ -473,8 +503,14 @@ program
|
|
|
473
503
|
console.log(` Flair URL: ${httpUrl}`);
|
|
474
504
|
console.log(` Private key: ${privPath}`);
|
|
475
505
|
if (!opts.adminPass && !alreadyRunning) {
|
|
476
|
-
console.log(`\n
|
|
477
|
-
console.log(`
|
|
506
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
507
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
508
|
+
console.log(` │ │`);
|
|
509
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
510
|
+
console.log(` │ Password: ${adminPass.padEnd(37)}│`);
|
|
511
|
+
console.log(` │ │`);
|
|
512
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
513
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
478
514
|
}
|
|
479
515
|
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
480
516
|
// ── First-run soul setup ──────────────────────────────────────────────
|
|
@@ -484,7 +520,25 @@ program
|
|
|
484
520
|
console.log("\n🎭 Set up agent personality (press Enter to skip any):\n");
|
|
485
521
|
const { createInterface } = await import("node:readline");
|
|
486
522
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
487
|
-
|
|
523
|
+
// Buffered ask: collects rapid input (pasted text) into one answer.
|
|
524
|
+
// Waits 200ms after last line before resolving, so pasted multiline
|
|
525
|
+
// blocks are captured as a single answer instead of spilling across prompts.
|
|
526
|
+
const ask = (q) => new Promise(resolve => {
|
|
527
|
+
let buffer = "";
|
|
528
|
+
let timer = null;
|
|
529
|
+
const finish = () => {
|
|
530
|
+
rl.removeListener("line", onLine);
|
|
531
|
+
resolve(buffer.trim());
|
|
532
|
+
};
|
|
533
|
+
const onLine = (line) => {
|
|
534
|
+
buffer += (buffer ? "\n" : "") + line;
|
|
535
|
+
if (timer)
|
|
536
|
+
clearTimeout(timer);
|
|
537
|
+
timer = setTimeout(finish, 200);
|
|
538
|
+
};
|
|
539
|
+
process.stdout.write(q);
|
|
540
|
+
rl.on("line", onLine);
|
|
541
|
+
});
|
|
488
542
|
const role = await ask(" What's this agent's role? (e.g., \"Senior dev, concise and direct\")\n > ");
|
|
489
543
|
const project = await ask(" What project is it working on?\n > ");
|
|
490
544
|
const standards = await ask(" Any coding standards or preferences?\n > ");
|
|
@@ -514,6 +568,25 @@ program
|
|
|
514
568
|
console.log("\n No soul entries — you can add them later with: flair soul set --agent " + agentId + " --key role --value \"...\"");
|
|
515
569
|
}
|
|
516
570
|
}
|
|
571
|
+
else {
|
|
572
|
+
// --skip-soul or non-interactive: seed sensible defaults so bootstrap returns useful context
|
|
573
|
+
const defaultSoulEntries = [
|
|
574
|
+
["role", "AI assistant [default — customize with 'flair soul set']"],
|
|
575
|
+
["personality", "Helpful, precise, and proactive [default — customize with 'flair soul set']"],
|
|
576
|
+
["constraints", "Respect user privacy. Be concise. [default — customize with 'flair soul set']"],
|
|
577
|
+
];
|
|
578
|
+
console.log("\nSeeding default soul entries...");
|
|
579
|
+
for (const [key, value] of defaultSoulEntries) {
|
|
580
|
+
try {
|
|
581
|
+
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
582
|
+
console.log(` ✓ soul:${key} set (default)`);
|
|
583
|
+
}
|
|
584
|
+
catch (err) {
|
|
585
|
+
console.warn(` ⚠ soul:${key} failed: ${err.message}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
console.log(` Customize with: flair soul set --agent ${agentId} --key role --value "..."`);
|
|
589
|
+
}
|
|
517
590
|
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
518
591
|
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
519
592
|
// Auto-wire MCP config into ~/.claude.json if Claude Code is installed
|
|
@@ -816,6 +889,373 @@ agent
|
|
|
816
889
|
}
|
|
817
890
|
console.log(`\n✅ Agent '${id}' removed successfully`);
|
|
818
891
|
});
|
|
892
|
+
// ─── flair principal ─────────────────────────────────────────────────────────
|
|
893
|
+
// 1.0 identity management. The Principal model extends Agent — this is the
|
|
894
|
+
// preferred CLI surface for managing identities going forward.
|
|
895
|
+
const principal = program.command("principal").description("Manage principals (humans and agents)");
|
|
896
|
+
principal
|
|
897
|
+
.command("add <id>")
|
|
898
|
+
.description("Create a new principal")
|
|
899
|
+
.option("--kind <kind>", "Principal kind: human or agent", "agent")
|
|
900
|
+
.option("--name <name>", "Display name (defaults to id)")
|
|
901
|
+
.option("--admin", "Grant admin privileges")
|
|
902
|
+
.option("--trust <tier>", "Default trust tier: endorsed, corroborated, or unverified")
|
|
903
|
+
.option("--runtime <runtime>", "Runtime: openclaw, claude-code, headless, external")
|
|
904
|
+
.option("--port <port>", "Harper HTTP port")
|
|
905
|
+
.option("--admin-pass <pass>", "Admin password for registration")
|
|
906
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
907
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
908
|
+
.action(async (id, opts) => {
|
|
909
|
+
const opsPort = resolveOpsPort(opts);
|
|
910
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
911
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
|
|
912
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
913
|
+
const kind = opts.kind ?? "agent";
|
|
914
|
+
const name = opts.name ?? id;
|
|
915
|
+
const isAdmin = opts.admin ?? false;
|
|
916
|
+
const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
|
|
917
|
+
const runtime = opts.runtime;
|
|
918
|
+
if (!adminPass) {
|
|
919
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
920
|
+
process.exit(1);
|
|
921
|
+
}
|
|
922
|
+
// Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
|
|
923
|
+
mkdirSync(keysDir, { recursive: true });
|
|
924
|
+
const privPath = privKeyPath(id, keysDir);
|
|
925
|
+
const pubPath = pubKeyPath(id, keysDir);
|
|
926
|
+
let pubKeyB64url;
|
|
927
|
+
if (existsSync(privPath)) {
|
|
928
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
929
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
930
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
931
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
932
|
+
}
|
|
933
|
+
else {
|
|
934
|
+
const kp = nacl.sign.keyPair();
|
|
935
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
936
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
937
|
+
chmodSync(privPath, 0o600);
|
|
938
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
939
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
940
|
+
console.log(`Keypair written: ${privPath}`);
|
|
941
|
+
}
|
|
942
|
+
// Insert via operations API with Principal fields
|
|
943
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
944
|
+
const record = {
|
|
945
|
+
id,
|
|
946
|
+
name,
|
|
947
|
+
displayName: name,
|
|
948
|
+
kind,
|
|
949
|
+
type: kind === "human" ? "human" : "agent",
|
|
950
|
+
status: "active",
|
|
951
|
+
publicKey: pubKeyB64url,
|
|
952
|
+
defaultTrustTier: trustTier,
|
|
953
|
+
admin: isAdmin,
|
|
954
|
+
runtime: runtime ?? null,
|
|
955
|
+
createdAt: new Date().toISOString(),
|
|
956
|
+
updatedAt: new Date().toISOString(),
|
|
957
|
+
};
|
|
958
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
959
|
+
method: "POST",
|
|
960
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
961
|
+
body: JSON.stringify({ operation: "upsert", database: "flair", table: "Agent", records: [record] }),
|
|
962
|
+
});
|
|
963
|
+
if (!res.ok) {
|
|
964
|
+
const text = await res.text().catch(() => "");
|
|
965
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
console.log(`✅ Principal '${id}' created`);
|
|
969
|
+
console.log(` Kind: ${kind}`);
|
|
970
|
+
console.log(` Trust: ${trustTier}`);
|
|
971
|
+
console.log(` Admin: ${isAdmin}`);
|
|
972
|
+
if (runtime)
|
|
973
|
+
console.log(` Runtime: ${runtime}`);
|
|
974
|
+
console.log(` Public key: ${pubKeyB64url}`);
|
|
975
|
+
console.log(` Private key: ${privPath}`);
|
|
976
|
+
});
|
|
977
|
+
principal
|
|
978
|
+
.command("list")
|
|
979
|
+
.description("List all principals")
|
|
980
|
+
.option("--kind <kind>", "Filter by kind: human or agent")
|
|
981
|
+
.option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
|
|
982
|
+
.option("--port <port>", "Harper HTTP port")
|
|
983
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
984
|
+
.action(async (opts) => {
|
|
985
|
+
const opsPort = resolveOpsPort(opts);
|
|
986
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
987
|
+
if (!adminPass) {
|
|
988
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
989
|
+
process.exit(1);
|
|
990
|
+
}
|
|
991
|
+
const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
|
|
992
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
993
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
994
|
+
method: "POST",
|
|
995
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
996
|
+
body: JSON.stringify({
|
|
997
|
+
operation: "sql",
|
|
998
|
+
sql: `SELECT id, name, kind, status, defaultTrustTier, admin, runtime, createdAt FROM flair.Agent${kindFilter} ORDER BY createdAt`,
|
|
999
|
+
}),
|
|
1000
|
+
});
|
|
1001
|
+
if (!res.ok) {
|
|
1002
|
+
const text = await res.text().catch(() => "");
|
|
1003
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1004
|
+
process.exit(1);
|
|
1005
|
+
}
|
|
1006
|
+
const records = await res.json();
|
|
1007
|
+
if (records.length === 0) {
|
|
1008
|
+
console.log("No principals found.");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
// Table format
|
|
1012
|
+
console.log(`${"ID".padEnd(20)} ${"Kind".padEnd(7)} ${"Trust".padEnd(14)} ${"Admin".padEnd(6)} ${"Status".padEnd(12)} ${"Runtime".padEnd(12)} Created`);
|
|
1013
|
+
console.log("─".repeat(95));
|
|
1014
|
+
for (const r of records) {
|
|
1015
|
+
const kind = r.kind ?? "agent";
|
|
1016
|
+
const trust = r.defaultTrustTier ?? "—";
|
|
1017
|
+
const admin = r.admin ? "yes" : "no";
|
|
1018
|
+
const status = r.status ?? "active";
|
|
1019
|
+
const runtime = r.runtime ?? "—";
|
|
1020
|
+
const created = r.createdAt?.slice(0, 10) ?? "—";
|
|
1021
|
+
console.log(`${String(r.id).padEnd(20)} ${kind.padEnd(7)} ${trust.padEnd(14)} ${admin.padEnd(6)} ${status.padEnd(12)} ${runtime.padEnd(12)} ${created}`);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
principal
|
|
1025
|
+
.command("show <id>")
|
|
1026
|
+
.description("Show principal details")
|
|
1027
|
+
.action(async (id) => {
|
|
1028
|
+
const result = await api("GET", `/Agent/${id}`);
|
|
1029
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1030
|
+
});
|
|
1031
|
+
principal
|
|
1032
|
+
.command("disable <id>")
|
|
1033
|
+
.description("Deactivate a principal (revokes access, preserves data)")
|
|
1034
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1035
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1036
|
+
.action(async (id, opts) => {
|
|
1037
|
+
const opsPort = resolveOpsPort(opts);
|
|
1038
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1039
|
+
if (!adminPass) {
|
|
1040
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1041
|
+
process.exit(1);
|
|
1042
|
+
}
|
|
1043
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1044
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1045
|
+
method: "POST",
|
|
1046
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1047
|
+
body: JSON.stringify({
|
|
1048
|
+
operation: "update",
|
|
1049
|
+
database: "flair",
|
|
1050
|
+
table: "Agent",
|
|
1051
|
+
records: [{ id, status: "deactivated", updatedAt: new Date().toISOString() }],
|
|
1052
|
+
}),
|
|
1053
|
+
});
|
|
1054
|
+
if (!res.ok) {
|
|
1055
|
+
const text = await res.text().catch(() => "");
|
|
1056
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
}
|
|
1059
|
+
console.log(`✅ Principal '${id}' deactivated`);
|
|
1060
|
+
});
|
|
1061
|
+
principal
|
|
1062
|
+
.command("promote <id> <tier>")
|
|
1063
|
+
.description("Change a principal's trust tier (endorsed, corroborated, unverified)")
|
|
1064
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1065
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1066
|
+
.action(async (id, tier, opts) => {
|
|
1067
|
+
const validTiers = ["endorsed", "corroborated", "unverified"];
|
|
1068
|
+
if (!validTiers.includes(tier)) {
|
|
1069
|
+
console.error(`Error: tier must be one of: ${validTiers.join(", ")}`);
|
|
1070
|
+
process.exit(1);
|
|
1071
|
+
}
|
|
1072
|
+
const opsPort = resolveOpsPort(opts);
|
|
1073
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1074
|
+
if (!adminPass) {
|
|
1075
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1076
|
+
process.exit(1);
|
|
1077
|
+
}
|
|
1078
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1079
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1080
|
+
method: "POST",
|
|
1081
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1082
|
+
body: JSON.stringify({
|
|
1083
|
+
operation: "update",
|
|
1084
|
+
database: "flair",
|
|
1085
|
+
table: "Agent",
|
|
1086
|
+
records: [{ id, defaultTrustTier: tier, updatedAt: new Date().toISOString() }],
|
|
1087
|
+
}),
|
|
1088
|
+
});
|
|
1089
|
+
if (!res.ok) {
|
|
1090
|
+
const text = await res.text().catch(() => "");
|
|
1091
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1092
|
+
process.exit(1);
|
|
1093
|
+
}
|
|
1094
|
+
console.log(`✅ Principal '${id}' trust tier set to '${tier}'`);
|
|
1095
|
+
});
|
|
1096
|
+
// ─── flair idp ───────────────────────────────────────────────────────────────
|
|
1097
|
+
// XAA Enterprise IdP configuration (per FLAIR-XAA spec § 4).
|
|
1098
|
+
const idp = program.command("idp").description("Manage enterprise IdP configurations (XAA)");
|
|
1099
|
+
idp
|
|
1100
|
+
.command("add")
|
|
1101
|
+
.description("Register a trusted enterprise IdP")
|
|
1102
|
+
.requiredOption("--name <name>", "Display name (e.g., 'Harper Corporate')")
|
|
1103
|
+
.requiredOption("--issuer <url>", "IdP issuer URL (e.g., https://accounts.google.com)")
|
|
1104
|
+
.requiredOption("--jwks-uri <url>", "JWKS endpoint URL")
|
|
1105
|
+
.requiredOption("--client-id <id>", "Flair's client_id at this IdP")
|
|
1106
|
+
.option("--required-domain <domain>", "Reject tokens without this domain (hd/tid claim)")
|
|
1107
|
+
.option("--no-jit-provision", "Disable auto-creation of principals for new IdP users")
|
|
1108
|
+
.option("--default-trust <tier>", "Trust tier for JIT principals", "unverified")
|
|
1109
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1110
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1111
|
+
.action(async (opts) => {
|
|
1112
|
+
const opsPort = resolveOpsPort(opts);
|
|
1113
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1114
|
+
if (!adminPass) {
|
|
1115
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
}
|
|
1118
|
+
const id = `idp_${randomUUID().slice(0, 8)}`;
|
|
1119
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1120
|
+
const now = new Date().toISOString();
|
|
1121
|
+
const record = {
|
|
1122
|
+
id,
|
|
1123
|
+
name: opts.name,
|
|
1124
|
+
issuer: opts.issuer,
|
|
1125
|
+
jwksUri: opts.jwksUri,
|
|
1126
|
+
clientId: opts.clientId,
|
|
1127
|
+
requiredDomain: opts.requiredDomain ?? null,
|
|
1128
|
+
jitProvision: opts.jitProvision !== false,
|
|
1129
|
+
defaultTrustTier: opts.defaultTrust ?? "unverified",
|
|
1130
|
+
enabled: true,
|
|
1131
|
+
createdAt: now,
|
|
1132
|
+
updatedAt: now,
|
|
1133
|
+
};
|
|
1134
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1135
|
+
method: "POST",
|
|
1136
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1137
|
+
body: JSON.stringify({ operation: "upsert", database: "flair", table: "IdpConfig", records: [record] }),
|
|
1138
|
+
});
|
|
1139
|
+
if (!res.ok) {
|
|
1140
|
+
const text = await res.text().catch(() => "");
|
|
1141
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1142
|
+
process.exit(1);
|
|
1143
|
+
}
|
|
1144
|
+
console.log(`✅ IdP '${opts.name}' registered (id: ${id})`);
|
|
1145
|
+
console.log(` Issuer: ${opts.issuer}`);
|
|
1146
|
+
console.log(` JWKS: ${opts.jwksUri}`);
|
|
1147
|
+
console.log(` Client: ${opts.clientId}`);
|
|
1148
|
+
if (opts.requiredDomain)
|
|
1149
|
+
console.log(` Domain: ${opts.requiredDomain}`);
|
|
1150
|
+
console.log(` JIT: ${opts.jitProvision !== false}`);
|
|
1151
|
+
});
|
|
1152
|
+
idp
|
|
1153
|
+
.command("list")
|
|
1154
|
+
.description("List configured IdPs")
|
|
1155
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1156
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1157
|
+
.action(async (opts) => {
|
|
1158
|
+
const opsPort = resolveOpsPort(opts);
|
|
1159
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1160
|
+
if (!adminPass) {
|
|
1161
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1162
|
+
process.exit(1);
|
|
1163
|
+
}
|
|
1164
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1165
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1166
|
+
method: "POST",
|
|
1167
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1168
|
+
body: JSON.stringify({
|
|
1169
|
+
operation: "sql",
|
|
1170
|
+
sql: "SELECT id, name, issuer, requiredDomain, jitProvision, enabled, createdAt FROM flair.IdpConfig ORDER BY createdAt",
|
|
1171
|
+
}),
|
|
1172
|
+
});
|
|
1173
|
+
if (!res.ok) {
|
|
1174
|
+
const text = await res.text().catch(() => "");
|
|
1175
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1176
|
+
process.exit(1);
|
|
1177
|
+
}
|
|
1178
|
+
const records = await res.json();
|
|
1179
|
+
if (records.length === 0) {
|
|
1180
|
+
console.log("No IdPs configured.");
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
for (const r of records) {
|
|
1184
|
+
const status = r.enabled ? "enabled" : "disabled";
|
|
1185
|
+
console.log(`${r.name} (${r.id}) — ${status}`);
|
|
1186
|
+
console.log(` Issuer: ${r.issuer}`);
|
|
1187
|
+
if (r.requiredDomain)
|
|
1188
|
+
console.log(` Domain: ${r.requiredDomain}`);
|
|
1189
|
+
console.log(` JIT: ${r.jitProvision ?? true}`);
|
|
1190
|
+
console.log();
|
|
1191
|
+
}
|
|
1192
|
+
});
|
|
1193
|
+
idp
|
|
1194
|
+
.command("remove <id>")
|
|
1195
|
+
.description("Remove an IdP configuration")
|
|
1196
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1197
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1198
|
+
.action(async (id, opts) => {
|
|
1199
|
+
const opsPort = resolveOpsPort(opts);
|
|
1200
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1201
|
+
if (!adminPass) {
|
|
1202
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1203
|
+
process.exit(1);
|
|
1204
|
+
}
|
|
1205
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1206
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1207
|
+
method: "POST",
|
|
1208
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1209
|
+
body: JSON.stringify({ operation: "delete", database: "flair", table: "IdpConfig", hash_values: [id] }),
|
|
1210
|
+
});
|
|
1211
|
+
if (!res.ok) {
|
|
1212
|
+
const text = await res.text().catch(() => "");
|
|
1213
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1214
|
+
process.exit(1);
|
|
1215
|
+
}
|
|
1216
|
+
console.log(`✅ IdP '${id}' removed`);
|
|
1217
|
+
});
|
|
1218
|
+
idp
|
|
1219
|
+
.command("test <id>")
|
|
1220
|
+
.description("Test IdP connectivity (fetches JWKS)")
|
|
1221
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1222
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1223
|
+
.action(async (id, opts) => {
|
|
1224
|
+
const opsPort = resolveOpsPort(opts);
|
|
1225
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1226
|
+
if (!adminPass) {
|
|
1227
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1228
|
+
process.exit(1);
|
|
1229
|
+
}
|
|
1230
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1231
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1232
|
+
method: "POST",
|
|
1233
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1234
|
+
body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.IdpConfig WHERE id = '${id}'` }),
|
|
1235
|
+
});
|
|
1236
|
+
const records = await res.json();
|
|
1237
|
+
if (records.length === 0) {
|
|
1238
|
+
console.error(`IdP '${id}' not found`);
|
|
1239
|
+
process.exit(1);
|
|
1240
|
+
}
|
|
1241
|
+
const cfg = records[0];
|
|
1242
|
+
console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
|
|
1243
|
+
console.log(` JWKS endpoint: ${cfg.jwksUri}`);
|
|
1244
|
+
try {
|
|
1245
|
+
const jwksRes = await fetch(cfg.jwksUri, { signal: AbortSignal.timeout(10_000) });
|
|
1246
|
+
if (!jwksRes.ok) {
|
|
1247
|
+
console.error(` ❌ JWKS fetch failed: HTTP ${jwksRes.status}`);
|
|
1248
|
+
process.exit(1);
|
|
1249
|
+
}
|
|
1250
|
+
const jwks = await jwksRes.json();
|
|
1251
|
+
const keyCount = jwks.keys?.length ?? 0;
|
|
1252
|
+
console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
console.error(` ❌ JWKS fetch error: ${err.message}`);
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
}
|
|
1258
|
+
});
|
|
819
1259
|
// ─── flair grant / revoke ─────────────────────────────────────────────────────
|
|
820
1260
|
program
|
|
821
1261
|
.command("grant <from-agent> <to-agent>")
|
|
@@ -907,61 +1347,367 @@ program
|
|
|
907
1347
|
console.log(`✅ Grant revoked: '${toAgent}' can no longer read '${fromAgent}'s memories`);
|
|
908
1348
|
console.log(` Removed grant ID: ${grantId}`);
|
|
909
1349
|
});
|
|
1350
|
+
// ─── Federation signing helpers ──────────────────────────────────────────────
|
|
1351
|
+
/**
|
|
1352
|
+
* Load the Ed25519 secret key for the local federation instance.
|
|
1353
|
+
* Tries keystore first, then falls back to DB-stored seed (migration path).
|
|
1354
|
+
*/
|
|
1355
|
+
async function loadInstanceSecretKey(instanceId, opts) {
|
|
1356
|
+
// Try keystore first
|
|
1357
|
+
const seed = keystore.getPrivateKeySeed(instanceId);
|
|
1358
|
+
if (seed) {
|
|
1359
|
+
return nacl.sign.keyPair.fromSeed(seed).secretKey;
|
|
1360
|
+
}
|
|
1361
|
+
// Fallback: check DB for legacy _keySeed
|
|
1362
|
+
const opsPort = resolveOpsPort(opts);
|
|
1363
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1364
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1365
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1366
|
+
method: "POST",
|
|
1367
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1368
|
+
body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.Instance WHERE id = '${instanceId}'` }),
|
|
1369
|
+
});
|
|
1370
|
+
if (res.ok) {
|
|
1371
|
+
const rows = await res.json();
|
|
1372
|
+
if (rows[0]?._keySeed) {
|
|
1373
|
+
const seedFromDb = Buffer.from(rows[0]._keySeed, "base64url");
|
|
1374
|
+
// Migrate to keystore
|
|
1375
|
+
keystore.setPrivateKeySeed(instanceId, new Uint8Array(seedFromDb));
|
|
1376
|
+
return nacl.sign.keyPair.fromSeed(new Uint8Array(seedFromDb)).secretKey;
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
throw new Error(`No private key found for instance ${instanceId}. Re-run 'flair federation status' to regenerate.`);
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* Sign a request body and return a new body with the signature field added.
|
|
1383
|
+
*/
|
|
1384
|
+
function signRequestBody(body, secretKey) {
|
|
1385
|
+
const sig = signBody(body, secretKey);
|
|
1386
|
+
return { ...body, signature: sig };
|
|
1387
|
+
}
|
|
1388
|
+
// ─── flair federation ────────────────────────────────────────────────────────
|
|
1389
|
+
const federation = program.command("federation").description("Manage federation (hub-and-spoke sync)");
|
|
1390
|
+
federation
|
|
1391
|
+
.command("status")
|
|
1392
|
+
.description("Show federation status and peer connections")
|
|
1393
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1394
|
+
.action(async (opts) => {
|
|
1395
|
+
try {
|
|
1396
|
+
const instance = await api("GET", "/FederationInstance");
|
|
1397
|
+
console.log(`Instance: ${instance.id} (${instance.role})`);
|
|
1398
|
+
console.log(`Public key: ${instance.publicKey}`);
|
|
1399
|
+
console.log(`Status: ${instance.status}`);
|
|
1400
|
+
console.log();
|
|
1401
|
+
const { peers } = await api("GET", "/FederationPeers");
|
|
1402
|
+
if (peers.length === 0) {
|
|
1403
|
+
console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
|
|
1404
|
+
}
|
|
1405
|
+
else {
|
|
1406
|
+
console.log(`${"Peer".padEnd(20)} ${"Role".padEnd(8)} ${"Status".padEnd(14)} ${"Last Sync".padEnd(22)} Relay`);
|
|
1407
|
+
console.log("─".repeat(80));
|
|
1408
|
+
for (const p of peers) {
|
|
1409
|
+
const lastSync = p.lastSyncAt?.slice(0, 19) ?? "never";
|
|
1410
|
+
console.log(`${p.id.padEnd(20)} ${(p.role ?? "—").padEnd(8)} ${(p.status ?? "—").padEnd(14)} ${lastSync.padEnd(22)} ${p.relayOnly ? "yes" : "no"}`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
catch (err) {
|
|
1415
|
+
console.error(`Error: ${err.message}`);
|
|
1416
|
+
process.exit(1);
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
federation
|
|
1420
|
+
.command("pair <hub-url>")
|
|
1421
|
+
.description("Pair this spoke with a hub instance")
|
|
1422
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1423
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1424
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1425
|
+
.option("--token <token>", "One-time pairing token from hub admin")
|
|
1426
|
+
.action(async (hubUrl, opts) => {
|
|
1427
|
+
try {
|
|
1428
|
+
const instance = await api("GET", "/FederationInstance");
|
|
1429
|
+
console.log(`Local instance: ${instance.id} (${instance.role})`);
|
|
1430
|
+
if (!opts.token) {
|
|
1431
|
+
console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
|
|
1432
|
+
process.exit(1);
|
|
1433
|
+
}
|
|
1434
|
+
// Load secret key and sign the pairing request
|
|
1435
|
+
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
1436
|
+
const pairBody = {
|
|
1437
|
+
instanceId: instance.id,
|
|
1438
|
+
publicKey: instance.publicKey,
|
|
1439
|
+
role: "spoke",
|
|
1440
|
+
pairingToken: opts.token,
|
|
1441
|
+
};
|
|
1442
|
+
const signedBody = signRequestBody(pairBody, secretKey);
|
|
1443
|
+
const res = await fetch(`${hubUrl}/FederationPair`, {
|
|
1444
|
+
method: "POST",
|
|
1445
|
+
headers: { "Content-Type": "application/json" },
|
|
1446
|
+
body: JSON.stringify(signedBody),
|
|
1447
|
+
});
|
|
1448
|
+
if (!res.ok) {
|
|
1449
|
+
const text = await res.text().catch(() => "");
|
|
1450
|
+
console.error(`Pairing failed: ${res.status} ${text}`);
|
|
1451
|
+
process.exit(1);
|
|
1452
|
+
}
|
|
1453
|
+
const result = await res.json();
|
|
1454
|
+
console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
|
|
1455
|
+
// Record the hub as our peer locally
|
|
1456
|
+
const opsPort = resolveOpsPort(opts);
|
|
1457
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1458
|
+
if (adminPass) {
|
|
1459
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1460
|
+
await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1461
|
+
method: "POST",
|
|
1462
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1463
|
+
body: JSON.stringify({
|
|
1464
|
+
operation: "upsert", database: "flair", table: "Peer",
|
|
1465
|
+
records: [{
|
|
1466
|
+
id: result.instance?.id ?? "hub",
|
|
1467
|
+
publicKey: result.instance?.publicKey ?? "",
|
|
1468
|
+
role: "hub", endpoint: hubUrl, status: "paired",
|
|
1469
|
+
pairedAt: new Date().toISOString(),
|
|
1470
|
+
createdAt: new Date().toISOString(),
|
|
1471
|
+
updatedAt: new Date().toISOString(),
|
|
1472
|
+
}],
|
|
1473
|
+
}),
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
catch (err) {
|
|
1478
|
+
console.error(`Error: ${err.message}`);
|
|
1479
|
+
process.exit(1);
|
|
1480
|
+
}
|
|
1481
|
+
});
|
|
1482
|
+
federation
|
|
1483
|
+
.command("token")
|
|
1484
|
+
.description("Generate a one-time pairing token (run on the hub)")
|
|
1485
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1486
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1487
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1488
|
+
.option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
|
|
1489
|
+
.action(async (opts) => {
|
|
1490
|
+
try {
|
|
1491
|
+
const { randomBytes } = await import("node:crypto");
|
|
1492
|
+
const token = randomBytes(24).toString("base64url");
|
|
1493
|
+
const ttlMinutes = parseInt(opts.ttl, 10) || 60;
|
|
1494
|
+
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
1495
|
+
const opsPort = resolveOpsPort(opts);
|
|
1496
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1497
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1498
|
+
await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1499
|
+
method: "POST",
|
|
1500
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1501
|
+
body: JSON.stringify({
|
|
1502
|
+
operation: "upsert", database: "flair", table: "PairingToken",
|
|
1503
|
+
records: [{
|
|
1504
|
+
id: token,
|
|
1505
|
+
createdAt: new Date().toISOString(),
|
|
1506
|
+
expiresAt,
|
|
1507
|
+
}],
|
|
1508
|
+
}),
|
|
1509
|
+
});
|
|
1510
|
+
console.log(`Pairing token (expires in ${ttlMinutes}m):`);
|
|
1511
|
+
console.log(` ${token}`);
|
|
1512
|
+
console.log(`\nGive this to the spoke admin to run:`);
|
|
1513
|
+
console.log(` flair federation pair <this-hub-url> --token ${token}`);
|
|
1514
|
+
}
|
|
1515
|
+
catch (err) {
|
|
1516
|
+
console.error(`Error: ${err.message}`);
|
|
1517
|
+
process.exit(1);
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
federation
|
|
1521
|
+
.command("sync")
|
|
1522
|
+
.description("Push local changes to the hub")
|
|
1523
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1524
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
1525
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1526
|
+
.action(async (opts) => {
|
|
1527
|
+
try {
|
|
1528
|
+
const { peers } = await api("GET", "/FederationPeers");
|
|
1529
|
+
const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
|
|
1530
|
+
if (!hub) {
|
|
1531
|
+
console.error("No hub peer configured. Use 'flair federation pair' first.");
|
|
1532
|
+
process.exit(1);
|
|
1533
|
+
}
|
|
1534
|
+
console.log(`Syncing to hub: ${hub.id}...`);
|
|
1535
|
+
const since = hub.lastSyncAt ?? new Date(0).toISOString();
|
|
1536
|
+
const opsPort = resolveOpsPort(opts);
|
|
1537
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1538
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1539
|
+
const tables = ["Memory", "Soul", "Agent", "Relationship"];
|
|
1540
|
+
const records = [];
|
|
1541
|
+
const instance = await api("GET", "/FederationInstance");
|
|
1542
|
+
for (const table of tables) {
|
|
1543
|
+
try {
|
|
1544
|
+
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1545
|
+
method: "POST",
|
|
1546
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1547
|
+
body: JSON.stringify({ operation: "sql", sql: `SELECT * FROM flair.${table} WHERE updatedAt > '${since}'` }),
|
|
1548
|
+
});
|
|
1549
|
+
if (res.ok) {
|
|
1550
|
+
for (const row of await res.json()) {
|
|
1551
|
+
records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
catch { }
|
|
1556
|
+
}
|
|
1557
|
+
if (records.length === 0) {
|
|
1558
|
+
console.log("No changes since last sync.");
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
// Sign the sync request with our instance key
|
|
1562
|
+
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
1563
|
+
const syncBody = { instanceId: instance.id, records, lamportClock: Date.now() };
|
|
1564
|
+
const signedSyncBody = signRequestBody(syncBody, secretKey);
|
|
1565
|
+
const syncRes = await fetch(`${hub.endpoint ?? hub.id}/FederationSync`, {
|
|
1566
|
+
method: "POST",
|
|
1567
|
+
headers: { "Content-Type": "application/json" },
|
|
1568
|
+
body: JSON.stringify(signedSyncBody),
|
|
1569
|
+
});
|
|
1570
|
+
if (!syncRes.ok) {
|
|
1571
|
+
console.error(`Sync failed: ${syncRes.status} ${await syncRes.text().catch(() => "")}`);
|
|
1572
|
+
process.exit(1);
|
|
1573
|
+
}
|
|
1574
|
+
const result = await syncRes.json();
|
|
1575
|
+
console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
|
|
1576
|
+
}
|
|
1577
|
+
catch (err) {
|
|
1578
|
+
console.error(`Error: ${err.message}`);
|
|
1579
|
+
process.exit(1);
|
|
1580
|
+
}
|
|
1581
|
+
});
|
|
910
1582
|
// ─── flair status ─────────────────────────────────────────────────────────────
|
|
911
1583
|
program
|
|
912
1584
|
.command("status")
|
|
913
|
-
.description("
|
|
1585
|
+
.description("Show Flair instance status, memory stats, and agent info")
|
|
914
1586
|
.option("--port <port>", "Harper HTTP port")
|
|
915
1587
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
1588
|
+
.option("--json", "Output as JSON")
|
|
1589
|
+
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
916
1590
|
.action(async (opts) => {
|
|
917
1591
|
const port = resolveHttpPort(opts);
|
|
918
1592
|
const baseUrl = opts.url ?? `http://127.0.0.1:${port}`;
|
|
919
1593
|
let healthy = false;
|
|
920
|
-
let
|
|
921
|
-
|
|
1594
|
+
let healthData = null;
|
|
1595
|
+
// 1. Basic health check (unauthenticated — just { ok: true })
|
|
922
1596
|
try {
|
|
923
|
-
const res = await fetch(`${baseUrl}/
|
|
924
|
-
healthy = res.
|
|
925
|
-
if (res.headers.get("content-type")?.includes("application/json")) {
|
|
926
|
-
const body = await res.json().catch(() => null);
|
|
927
|
-
if (body?.version)
|
|
928
|
-
version = body.version;
|
|
929
|
-
}
|
|
1597
|
+
const res = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1598
|
+
healthy = res.ok;
|
|
930
1599
|
}
|
|
931
1600
|
catch { /* unreachable */ }
|
|
1601
|
+
// 2. Try authenticated /HealthDetail for rich stats
|
|
932
1602
|
if (healthy) {
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
1603
|
+
const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
|
|
1604
|
+
if (agentId) {
|
|
1605
|
+
const keyPath = resolveKeyPath(agentId);
|
|
1606
|
+
if (keyPath) {
|
|
1607
|
+
try {
|
|
1608
|
+
const authHeader = buildEd25519Auth(agentId, "GET", "/HealthDetail", keyPath);
|
|
1609
|
+
const res = await fetch(`${baseUrl}/HealthDetail`, {
|
|
1610
|
+
headers: { Authorization: authHeader },
|
|
1611
|
+
signal: AbortSignal.timeout(5000),
|
|
1612
|
+
});
|
|
1613
|
+
if (res.ok) {
|
|
1614
|
+
healthData = await res.json().catch(() => null);
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
catch { /* fall through to basic output */ }
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
// Fallback: try admin basic auth if available
|
|
1621
|
+
if (!healthData) {
|
|
1622
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
|
|
1623
|
+
if (adminPass) {
|
|
1624
|
+
try {
|
|
1625
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1626
|
+
const res = await fetch(`${baseUrl}/HealthDetail`, {
|
|
1627
|
+
headers: { Authorization: auth },
|
|
1628
|
+
signal: AbortSignal.timeout(5000),
|
|
1629
|
+
});
|
|
1630
|
+
if (res.ok) {
|
|
1631
|
+
healthData = await res.json().catch(() => null);
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
catch { /* fall through to basic output */ }
|
|
939
1635
|
}
|
|
940
1636
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
console.log(`
|
|
951
|
-
|
|
1637
|
+
}
|
|
1638
|
+
if (opts.json) {
|
|
1639
|
+
console.log(JSON.stringify({ healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData }, null, 2));
|
|
1640
|
+
if (!healthy)
|
|
1641
|
+
process.exit(1);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
if (!healthy) {
|
|
1645
|
+
console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
|
|
1646
|
+
console.log(` URL: ${baseUrl}`);
|
|
1647
|
+
console.log(`\n Run: flair start or flair doctor`);
|
|
952
1648
|
process.exit(1);
|
|
1649
|
+
}
|
|
1650
|
+
// Format uptime
|
|
1651
|
+
const uptimeSec = healthData?.uptimeSeconds;
|
|
1652
|
+
let uptimeStr = "";
|
|
1653
|
+
if (uptimeSec != null) {
|
|
1654
|
+
const d = Math.floor(uptimeSec / 86400);
|
|
1655
|
+
const h = Math.floor((uptimeSec % 86400) / 3600);
|
|
1656
|
+
const m = Math.floor((uptimeSec % 3600) / 60);
|
|
1657
|
+
uptimeStr = d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
1658
|
+
}
|
|
1659
|
+
// Format last write as relative time
|
|
1660
|
+
let lastWriteStr = "";
|
|
1661
|
+
if (healthData?.lastWrite) {
|
|
1662
|
+
const ago = Date.now() - new Date(healthData.lastWrite).getTime();
|
|
1663
|
+
const mins = Math.floor(ago / 60000);
|
|
1664
|
+
const hrs = Math.floor(ago / 3600000);
|
|
1665
|
+
const days = Math.floor(ago / 86400000);
|
|
1666
|
+
lastWriteStr = days > 0 ? `${days}d ago` : hrs > 0 ? `${hrs}h ago` : mins > 0 ? `${mins}m ago` : "just now";
|
|
1667
|
+
}
|
|
1668
|
+
const pid = healthData?.pid ?? "";
|
|
1669
|
+
const agents = healthData?.agents;
|
|
1670
|
+
const memories = healthData?.memories;
|
|
1671
|
+
console.log(`Flair v${__pkgVersion} — 🟢 running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
|
|
1672
|
+
console.log(` URL: ${baseUrl}`);
|
|
1673
|
+
if (memories) {
|
|
1674
|
+
const embStr = memories.withEmbeddings > 0
|
|
1675
|
+
? `${memories.withEmbeddings} with embeddings`
|
|
1676
|
+
: "";
|
|
1677
|
+
const hashStr = memories.hashFallback > 0
|
|
1678
|
+
? `${memories.hashFallback} hash-fallback`
|
|
1679
|
+
: "";
|
|
1680
|
+
const detail = [embStr, hashStr].filter(Boolean).join(", ");
|
|
1681
|
+
console.log(` Memories: ${memories.total}${detail ? ` (${detail})` : ""}`);
|
|
1682
|
+
}
|
|
1683
|
+
if (agents) {
|
|
1684
|
+
const nameStr = agents.names?.length > 0 ? ` (${agents.names.join(", ")})` : "";
|
|
1685
|
+
console.log(` Agents: ${agents.count}${nameStr}`);
|
|
1686
|
+
}
|
|
1687
|
+
if (healthData?.soulEntries != null) {
|
|
1688
|
+
console.log(` Soul: ${healthData.soulEntries} entries`);
|
|
1689
|
+
}
|
|
1690
|
+
if (lastWriteStr) {
|
|
1691
|
+
console.log(` Last write: ${lastWriteStr}`);
|
|
1692
|
+
}
|
|
1693
|
+
console.log(` Health: ✅ all checks passing`);
|
|
953
1694
|
});
|
|
954
1695
|
// ─── flair upgrade ────────────────────────────────────────────────────────────
|
|
955
1696
|
program
|
|
956
1697
|
.command("upgrade")
|
|
957
1698
|
.description("Upgrade Flair and related packages to latest versions")
|
|
958
|
-
.
|
|
1699
|
+
.option("--check", "Only check for updates, don't install")
|
|
1700
|
+
.option("--restart", "Restart Flair after upgrade")
|
|
1701
|
+
.action(async (opts) => {
|
|
1702
|
+
const { execSync } = await import("node:child_process");
|
|
1703
|
+
const checkOnly = opts.check ?? false;
|
|
959
1704
|
console.log("Checking for updates...\n");
|
|
960
1705
|
const packages = [
|
|
961
1706
|
"@tpsdev-ai/flair",
|
|
962
1707
|
"@tpsdev-ai/flair-client",
|
|
963
1708
|
"@tpsdev-ai/flair-mcp",
|
|
964
1709
|
];
|
|
1710
|
+
const upgrades = [];
|
|
965
1711
|
for (const pkg of packages) {
|
|
966
1712
|
try {
|
|
967
1713
|
const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
|
|
@@ -969,22 +1715,67 @@ program
|
|
|
969
1715
|
continue;
|
|
970
1716
|
const data = await res.json();
|
|
971
1717
|
const latest = data.version ?? "unknown";
|
|
972
|
-
// Check installed version
|
|
973
1718
|
let installed = "not installed";
|
|
974
1719
|
try {
|
|
975
|
-
const
|
|
976
|
-
|
|
977
|
-
const match = installed.match(/@(\d+\.\d+\.\d+)/);
|
|
1720
|
+
const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null || true`, { encoding: "utf-8" }).trim();
|
|
1721
|
+
const match = out.match(/@(\d+\.\d+[\d.a-z-]*)/);
|
|
978
1722
|
installed = match ? match[1] : "not installed";
|
|
979
1723
|
}
|
|
980
1724
|
catch { /* best effort */ }
|
|
981
1725
|
const upToDate = installed === latest;
|
|
982
1726
|
const icon = upToDate ? "✅" : "⬆️";
|
|
983
1727
|
console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
|
|
1728
|
+
if (!upToDate && installed !== "not installed") {
|
|
1729
|
+
upgrades.push({ pkg, installed, latest });
|
|
1730
|
+
}
|
|
984
1731
|
}
|
|
985
1732
|
catch { /* skip unavailable packages */ }
|
|
986
1733
|
}
|
|
987
|
-
|
|
1734
|
+
if (upgrades.length === 0) {
|
|
1735
|
+
console.log("\n✅ Everything is up to date.");
|
|
1736
|
+
return;
|
|
1737
|
+
}
|
|
1738
|
+
if (checkOnly) {
|
|
1739
|
+
console.log(`\n${upgrades.length} update${upgrades.length > 1 ? "s" : ""} available. Run: flair upgrade`);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
// Perform upgrade
|
|
1743
|
+
console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
|
|
1744
|
+
for (const { pkg, latest } of upgrades) {
|
|
1745
|
+
try {
|
|
1746
|
+
console.log(` Installing ${pkg}@${latest}...`);
|
|
1747
|
+
execSync(`npm install -g ${pkg}@${latest}`, { stdio: "pipe" });
|
|
1748
|
+
console.log(` ✅ ${pkg}@${latest} installed`);
|
|
1749
|
+
}
|
|
1750
|
+
catch (err) {
|
|
1751
|
+
console.error(` ❌ ${pkg} upgrade failed: ${err.message}`);
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
if (opts.restart) {
|
|
1755
|
+
console.log("\nRestarting Flair...");
|
|
1756
|
+
try {
|
|
1757
|
+
const port = resolveHttpPort({});
|
|
1758
|
+
const label = "ai.tpsdev.flair";
|
|
1759
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
1760
|
+
if (process.platform === "darwin" && existsSync(plistPath)) {
|
|
1761
|
+
try {
|
|
1762
|
+
execSync(`launchctl stop ${label}`, { stdio: "pipe" });
|
|
1763
|
+
}
|
|
1764
|
+
catch { }
|
|
1765
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
1766
|
+
console.log("✅ Flair restarted with new version");
|
|
1767
|
+
}
|
|
1768
|
+
else {
|
|
1769
|
+
console.log("Run: flair restart");
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
catch {
|
|
1773
|
+
console.log("Run: flair restart");
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
else {
|
|
1777
|
+
console.log("\nRun: flair restart to use the new version");
|
|
1778
|
+
}
|
|
988
1779
|
});
|
|
989
1780
|
// ─── flair stop ───────────────────────────────────────────────────────────────
|
|
990
1781
|
program
|
|
@@ -1029,6 +1820,83 @@ program
|
|
|
1029
1820
|
console.log("Flair is not running (nothing found on port " + port + ").");
|
|
1030
1821
|
}
|
|
1031
1822
|
});
|
|
1823
|
+
// ─── flair start ──────────────────────────────────────────────────────────────
|
|
1824
|
+
program
|
|
1825
|
+
.command("start")
|
|
1826
|
+
.description("Start Flair (Harper) — requires a prior 'flair init'")
|
|
1827
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1828
|
+
.action(async (opts) => {
|
|
1829
|
+
const port = resolveHttpPort(opts);
|
|
1830
|
+
// Check if already running
|
|
1831
|
+
try {
|
|
1832
|
+
const res = await fetch(`http://127.0.0.1:${port}/Health`, { signal: AbortSignal.timeout(2000) });
|
|
1833
|
+
if (res.status > 0) {
|
|
1834
|
+
console.log(`Flair is already running on port ${port}.`);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
catch { /* not running — good */ }
|
|
1839
|
+
const dataDir = defaultDataDir();
|
|
1840
|
+
if (!existsSync(dataDir)) {
|
|
1841
|
+
console.error("❌ No Flair data directory found. Run 'flair init' first.");
|
|
1842
|
+
process.exit(1);
|
|
1843
|
+
}
|
|
1844
|
+
const platform = process.platform;
|
|
1845
|
+
if (platform === "darwin") {
|
|
1846
|
+
const label = "ai.tpsdev.flair";
|
|
1847
|
+
const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
|
|
1848
|
+
if (existsSync(plistPath)) {
|
|
1849
|
+
try {
|
|
1850
|
+
const { execSync } = await import("node:child_process");
|
|
1851
|
+
try {
|
|
1852
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
1853
|
+
}
|
|
1854
|
+
catch { }
|
|
1855
|
+
execSync(`launchctl start ${label}`, { stdio: "pipe" });
|
|
1856
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
1857
|
+
console.log("✅ Flair started (launchd)");
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
catch (err) {
|
|
1861
|
+
console.error(`launchd start failed, falling back to direct start: ${err.message}`);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
// Direct start (Linux, or macOS fallback when no launchd plist)
|
|
1866
|
+
const bin = harperBin();
|
|
1867
|
+
if (!bin) {
|
|
1868
|
+
console.error("❌ Harper binary not found. Run 'flair init' first.");
|
|
1869
|
+
process.exit(1);
|
|
1870
|
+
}
|
|
1871
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
|
|
1872
|
+
const opsPort = resolveOpsPort(opts);
|
|
1873
|
+
const env = {
|
|
1874
|
+
...process.env,
|
|
1875
|
+
ROOTPATH: dataDir,
|
|
1876
|
+
DEFAULTS_MODE: "dev",
|
|
1877
|
+
HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
|
|
1878
|
+
HTTP_PORT: String(port),
|
|
1879
|
+
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
1880
|
+
LOCAL_STUDIO: "false",
|
|
1881
|
+
};
|
|
1882
|
+
// Only set HDB_ADMIN_PASSWORD if we have a real value — empty string
|
|
1883
|
+
// would strip Harper's auth on an existing install
|
|
1884
|
+
if (adminPass) {
|
|
1885
|
+
env.HDB_ADMIN_PASSWORD = adminPass;
|
|
1886
|
+
}
|
|
1887
|
+
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
1888
|
+
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
1889
|
+
});
|
|
1890
|
+
proc.unref();
|
|
1891
|
+
try {
|
|
1892
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
|
|
1893
|
+
console.log(`✅ Flair started on port ${port}`);
|
|
1894
|
+
}
|
|
1895
|
+
catch {
|
|
1896
|
+
console.error("❌ Flair failed to start within timeout. Check logs in " + join(dataDir, "harper.log"));
|
|
1897
|
+
process.exit(1);
|
|
1898
|
+
}
|
|
1899
|
+
});
|
|
1032
1900
|
// ─── flair restart ────────────────────────────────────────────────────────────
|
|
1033
1901
|
program
|
|
1034
1902
|
.command("restart")
|
|
@@ -1043,22 +1911,27 @@ program
|
|
|
1043
1911
|
if (existsSync(plistPath)) {
|
|
1044
1912
|
try {
|
|
1045
1913
|
const { execSync } = await import("node:child_process");
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1914
|
+
// Ensure the service is loaded (init writes the plist but doesn't load it)
|
|
1915
|
+
try {
|
|
1916
|
+
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
1917
|
+
}
|
|
1918
|
+
catch { }
|
|
1919
|
+
// Stop the service — KeepAlive=true in the plist auto-restarts it
|
|
1920
|
+
try {
|
|
1921
|
+
execSync(`launchctl stop ${label}`, { stdio: "pipe" });
|
|
1922
|
+
}
|
|
1923
|
+
catch { }
|
|
1924
|
+
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
1925
|
+
console.log("✅ Flair restarted");
|
|
1049
1926
|
return;
|
|
1050
1927
|
}
|
|
1051
1928
|
catch (err) {
|
|
1052
|
-
console.error(`launchd restart failed: ${err.message}`);
|
|
1929
|
+
console.error(`launchd restart failed, falling back to port restart: ${err.message}`);
|
|
1053
1930
|
}
|
|
1054
1931
|
}
|
|
1055
|
-
else {
|
|
1056
|
-
console.error("❌ No launchd service found. Run 'flair init' first.");
|
|
1057
|
-
process.exit(1);
|
|
1058
|
-
}
|
|
1059
1932
|
}
|
|
1060
|
-
|
|
1061
|
-
//
|
|
1933
|
+
{
|
|
1934
|
+
// Port-based restart (Linux, or macOS fallback)
|
|
1062
1935
|
console.log("Stopping...");
|
|
1063
1936
|
try {
|
|
1064
1937
|
const { execSync } = await import("node:child_process");
|
|
@@ -1269,80 +2142,71 @@ program
|
|
|
1269
2142
|
// ─── flair test ───────────────────────────────────────────────────────────────
|
|
1270
2143
|
program
|
|
1271
2144
|
.command("test")
|
|
1272
|
-
.description("Verify Flair
|
|
1273
|
-
.
|
|
2145
|
+
.description("Verify the full Flair stack: write, search, and delete a test memory")
|
|
2146
|
+
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
1274
2147
|
.option("--port <port>", "Harper HTTP port")
|
|
1275
2148
|
.action(async (opts) => {
|
|
1276
|
-
const
|
|
1277
|
-
const
|
|
1278
|
-
const agentId = opts.agent;
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
if (!existsSync(privPath)) {
|
|
1282
|
-
console.error(`❌ Key not found: ${privPath}`);
|
|
1283
|
-
console.error(` Run: flair init --agent-id ${agentId}`);
|
|
2149
|
+
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
2150
|
+
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
2151
|
+
const agentId = opts.agent ?? process.env.FLAIR_AGENT_ID;
|
|
2152
|
+
if (!agentId && !process.env.FLAIR_ADMIN_PASS) {
|
|
2153
|
+
console.error(red("Error: set --agent / FLAIR_AGENT_ID or FLAIR_ADMIN_PASS"));
|
|
1284
2154
|
process.exit(1);
|
|
1285
2155
|
}
|
|
1286
|
-
const
|
|
1287
|
-
|
|
2156
|
+
const baseUrl = `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
2157
|
+
console.log(`\nFlair test (url: ${baseUrl})\n`);
|
|
1288
2158
|
let passed = 0;
|
|
1289
2159
|
let failed = 0;
|
|
2160
|
+
let memoryId = null;
|
|
1290
2161
|
const check = async (name, fn) => {
|
|
1291
2162
|
try {
|
|
1292
2163
|
const ok = await fn();
|
|
1293
2164
|
if (ok) {
|
|
1294
|
-
console.log(`
|
|
2165
|
+
console.log(` ${green("PASS")} ${name}`);
|
|
1295
2166
|
passed++;
|
|
1296
2167
|
}
|
|
1297
2168
|
else {
|
|
1298
|
-
console.log(`
|
|
2169
|
+
console.log(` ${red("FAIL")} ${name}`);
|
|
1299
2170
|
failed++;
|
|
1300
2171
|
}
|
|
1301
2172
|
}
|
|
1302
2173
|
catch (e) {
|
|
1303
|
-
console.log(`
|
|
2174
|
+
console.log(` ${red("FAIL")} ${name}: ${e.message?.slice(0, 120)}`);
|
|
1304
2175
|
failed++;
|
|
1305
2176
|
}
|
|
1306
2177
|
};
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
return
|
|
1320
|
-
});
|
|
1321
|
-
// 3. Search
|
|
1322
|
-
await check("Semantic search", async () => {
|
|
1323
|
-
await new Promise(r => setTimeout(r, 2000)); // wait for indexing
|
|
1324
|
-
const res = await authFetch(baseUrl, agentId, privPath, "POST", "/SemanticSearch", {
|
|
1325
|
-
agentId, q: "flair test memory", limit: 5,
|
|
1326
|
-
});
|
|
1327
|
-
if (!res.ok)
|
|
1328
|
-
return false;
|
|
1329
|
-
const data = await res.json();
|
|
1330
|
-
return (data.results?.length ?? 0) > 0;
|
|
2178
|
+
// 1. Write a test memory via POST /Memory
|
|
2179
|
+
await check("Write test memory (POST /Memory)", async () => {
|
|
2180
|
+
const body = {
|
|
2181
|
+
content: "flair test \u2014 this will be deleted",
|
|
2182
|
+
durability: "ephemeral",
|
|
2183
|
+
createdAt: new Date().toISOString(),
|
|
2184
|
+
};
|
|
2185
|
+
if (agentId)
|
|
2186
|
+
body.agentId = agentId;
|
|
2187
|
+
const result = await api("POST", "/Memory", body);
|
|
2188
|
+
// id may be returned directly or nested
|
|
2189
|
+
memoryId = result?.id ?? result?.[0]?.id ?? null;
|
|
2190
|
+
return !!memoryId || result?.ok === true;
|
|
1331
2191
|
});
|
|
1332
|
-
//
|
|
1333
|
-
await check("
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
return (data.context?.length ?? 0) > 0;
|
|
2192
|
+
// 2. Search for the test memory via POST /SemanticSearch
|
|
2193
|
+
await check("Search for test memory (POST /SemanticSearch)", async () => {
|
|
2194
|
+
await new Promise(r => setTimeout(r, 1500)); // allow indexing
|
|
2195
|
+
const body = { q: "flair test", limit: 5 };
|
|
2196
|
+
if (agentId)
|
|
2197
|
+
body.agentId = agentId;
|
|
2198
|
+
const result = await api("POST", "/SemanticSearch", body);
|
|
2199
|
+
return (result?.results?.length ?? 0) > 0;
|
|
1341
2200
|
});
|
|
1342
|
-
//
|
|
1343
|
-
await check("Memory
|
|
1344
|
-
|
|
1345
|
-
|
|
2201
|
+
// 3. Delete the test memory via DELETE /Memory/<id>
|
|
2202
|
+
await check("Delete test memory (DELETE /Memory/<id>)", async () => {
|
|
2203
|
+
if (!memoryId) {
|
|
2204
|
+
// If write returned ok without an id, skip deletion cleanly
|
|
2205
|
+
console.log(` (skipped — no id returned from write step)`);
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
await api("DELETE", `/Memory/${memoryId}`, agentId ? { agentId } : undefined);
|
|
2209
|
+
return true;
|
|
1346
2210
|
});
|
|
1347
2211
|
console.log(`\n${passed} passed, ${failed} failed`);
|
|
1348
2212
|
if (failed > 0)
|
|
@@ -2227,4 +3091,9 @@ program
|
|
|
2227
3091
|
}
|
|
2228
3092
|
}
|
|
2229
3093
|
});
|
|
2230
|
-
|
|
3094
|
+
// Run CLI only when this is the entry point (not when imported for testing)
|
|
3095
|
+
if (import.meta.main) {
|
|
3096
|
+
await program.parseAsync();
|
|
3097
|
+
}
|
|
3098
|
+
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
3099
|
+
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };
|