@tpsdev-ai/flair 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/dist/bridges/runtime/allow-list.js +198 -0
- package/dist/bridges/runtime/import-runner.js +7 -3
- package/dist/bridges/runtime/load-bridge.js +126 -0
- package/dist/bridges/runtime/load-plugin.js +127 -0
- package/dist/cli.js +1895 -227
- package/dist/install/clients.js +225 -0
- package/dist/resources/Federation.js +21 -15
- package/dist/resources/auth-middleware.js +44 -11
- package/dist/resources/health.js +1 -1
- package/package.json +3 -1
- package/ui/observation-center.html +356 -62
- package/dist/bridges/runtime/load-descriptor.js +0 -46
package/dist/cli.js
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { load as parseYaml } from "js-yaml";
|
|
5
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, } from "node:fs";
|
|
6
|
+
import { homedir, hostname, tmpdir } from "node:os";
|
|
7
|
+
import { join, resolve, sep } from "node:path";
|
|
7
8
|
import { spawn } from "node:child_process";
|
|
8
|
-
import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
|
|
9
|
+
import { createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
|
|
10
|
+
import { create as tarCreate } from "tar";
|
|
9
11
|
import { keystore } from "./keystore.js";
|
|
10
12
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
13
|
+
import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
11
14
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
12
15
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
13
16
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -30,6 +33,50 @@ function signBody(body, secretKey) {
|
|
|
30
33
|
const sig = nacl.sign.detached(message, secretKey);
|
|
31
34
|
return Buffer.from(sig).toString("base64url");
|
|
32
35
|
}
|
|
36
|
+
// ─── Secret detection helpers ────────────────────────
|
|
37
|
+
/**
|
|
38
|
+
* Check if a value looks like a real secret/password/token.
|
|
39
|
+
* Triggers warning when:
|
|
40
|
+
* - length >= 8
|
|
41
|
+
* - contains only alphanumerics and URL-safe punctuation (._-)
|
|
42
|
+
* - NOT a URL (doesn't contain ://)
|
|
43
|
+
*/
|
|
44
|
+
function isLikelyRealSecret(value) {
|
|
45
|
+
if (!value || value.length < 8)
|
|
46
|
+
return false;
|
|
47
|
+
if (value.includes("://"))
|
|
48
|
+
return false; // exclude URLs
|
|
49
|
+
// Match typical password/token format: alphanumerics + URL-safe punct
|
|
50
|
+
const pattern = /^[A-Za-z0-9._-]+$/;
|
|
51
|
+
return pattern.test(value);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Determine if we should show an inline-secret warning.
|
|
55
|
+
*
|
|
56
|
+
* @param optValue - The value from the command line option
|
|
57
|
+
* @param fromEnv - Whether the value came from an environment variable (true = no warning)
|
|
58
|
+
* @param secretFlagNames - Set of flag names that carry secrets
|
|
59
|
+
* @param flagName - The flag being checked
|
|
60
|
+
* @returns true if warning should be shown
|
|
61
|
+
*/
|
|
62
|
+
function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagName) {
|
|
63
|
+
// Skip if no value provided
|
|
64
|
+
if (!optValue || optValue === "")
|
|
65
|
+
return false;
|
|
66
|
+
// Skip URLs (not secrets)
|
|
67
|
+
if (flagName === "--target" || flagName === "--url")
|
|
68
|
+
return false;
|
|
69
|
+
// Only warn for secret-bearing flags
|
|
70
|
+
if (!secretFlagNames.has(flagName))
|
|
71
|
+
return false;
|
|
72
|
+
// Skip if value came from env (not argv)
|
|
73
|
+
if (fromEnv)
|
|
74
|
+
return false;
|
|
75
|
+
// Check if value looks like a real secret
|
|
76
|
+
if (!isLikelyRealSecret(optValue))
|
|
77
|
+
return false;
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
33
80
|
// ─── Defaults ────────────────────────────────────────────────────────────────
|
|
34
81
|
const DEFAULT_PORT = 19926;
|
|
35
82
|
const DEFAULT_OPS_PORT = 19925;
|
|
@@ -103,6 +150,64 @@ function resolveOpsPort(opts) {
|
|
|
103
150
|
// Default: httpPort - 1
|
|
104
151
|
return resolveHttpPort(opts) - 1;
|
|
105
152
|
}
|
|
153
|
+
// ─── Target resolution (remote Flair instance) ─────────────────────────────────
|
|
154
|
+
// --target <url> (or FLAIR_TARGET env) points all CLI operations at a remote
|
|
155
|
+
// Flair instance instead of localhost. This enables bootstrapping and
|
|
156
|
+
// managing Fabric-deployed Flair instances.
|
|
157
|
+
function resolveTarget(opts) {
|
|
158
|
+
return opts.target || process.env.FLAIR_TARGET || undefined;
|
|
159
|
+
}
|
|
160
|
+
/** Resolve the ops API target URL from --ops-target flag or FLAIR_OPS_TARGET env.
|
|
161
|
+
* Returns undefined if neither is set (caller should fall back to derivation or localhost).
|
|
162
|
+
*/
|
|
163
|
+
function resolveOpsTarget(opts) {
|
|
164
|
+
return opts.opsTarget || process.env.FLAIR_OPS_TARGET || undefined;
|
|
165
|
+
}
|
|
166
|
+
/** Derive the ops API URL from a Flair base URL.
|
|
167
|
+
* Convention: ops port = HTTP port - 1.
|
|
168
|
+
* If target has an explicit port, use port-1 (validated: must be 1-65535).
|
|
169
|
+
* If no explicit port: https → 442 (443-1), http → 19925 (19926-1), bare host → https://<host>:19925.
|
|
170
|
+
* Throws on unparseable URLs.
|
|
171
|
+
*/
|
|
172
|
+
/** Compute the effective ops API URL for remote commands.
|
|
173
|
+
* - If --ops-target is set, use it directly (no derivation).
|
|
174
|
+
* - Else if --target is set, derive ops URL via resolveOpsUrlFromTarget.
|
|
175
|
+
* - Else return undefined (fall back to localhost resolution).
|
|
176
|
+
*/
|
|
177
|
+
function resolveEffectiveOpsUrl(opts) {
|
|
178
|
+
const opsTarget = resolveOpsTarget(opts);
|
|
179
|
+
if (opsTarget)
|
|
180
|
+
return opsTarget.replace(/\/$/, "");
|
|
181
|
+
const target = resolveTarget(opts);
|
|
182
|
+
if (target)
|
|
183
|
+
return resolveOpsUrlFromTarget(target);
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
function resolveOpsUrlFromTarget(targetUrl) {
|
|
187
|
+
// Normalise bare hosts: add https:// prefix so URL parser can handle them.
|
|
188
|
+
const normalised = targetUrl.includes("://") ? targetUrl : `https://${targetUrl}`;
|
|
189
|
+
const url = new URL(normalised);
|
|
190
|
+
const port = parseInt(url.port, 10);
|
|
191
|
+
if (!isNaN(port) && port > 0 && port <= 65535) {
|
|
192
|
+
const opsPort = port - 1;
|
|
193
|
+
if (opsPort < 1)
|
|
194
|
+
throw new Error(`Derived ops port ${opsPort} is out of range; target port must be > 1`);
|
|
195
|
+
url.port = String(opsPort);
|
|
196
|
+
return url.toString().replace(/\/$/, "");
|
|
197
|
+
}
|
|
198
|
+
// No valid explicit port — reject port 0 or out-of-range
|
|
199
|
+
if (url.port !== "" && url.port !== undefined) {
|
|
200
|
+
throw new Error(`Invalid target port: ${url.port} (must be 1-65535)`);
|
|
201
|
+
}
|
|
202
|
+
// No explicit port — infer from scheme
|
|
203
|
+
if (url.protocol === "https:") {
|
|
204
|
+
url.port = "442";
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
url.port = String(DEFAULT_OPS_PORT);
|
|
208
|
+
}
|
|
209
|
+
return url.toString().replace(/\/$/, "");
|
|
210
|
+
}
|
|
106
211
|
function writeConfig(port) {
|
|
107
212
|
const p = configPath();
|
|
108
213
|
mkdirSync(join(homedir(), ".flair"), { recursive: true });
|
|
@@ -136,22 +241,40 @@ function b64(bytes) {
|
|
|
136
241
|
function b64url(bytes) {
|
|
137
242
|
return Buffer.from(bytes).toString("base64url");
|
|
138
243
|
}
|
|
139
|
-
|
|
244
|
+
function isLocalBase(base) {
|
|
245
|
+
try {
|
|
246
|
+
const url = new URL(base);
|
|
247
|
+
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
return !base;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
async function api(method, path, body, options) {
|
|
140
254
|
// Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
|
|
255
|
+
// When baseUrl is provided (--target), use it directly.
|
|
141
256
|
const savedPort = readPortFromConfig();
|
|
142
257
|
const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
143
|
-
const base = process.env.FLAIR_URL || defaultUrl;
|
|
258
|
+
const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
|
|
259
|
+
const isLocal = isLocalBase(base);
|
|
144
260
|
// Auth resolution order:
|
|
145
261
|
// 1. FLAIR_TOKEN env → Bearer token (backward compat)
|
|
146
|
-
// 2.
|
|
147
|
-
//
|
|
148
|
-
//
|
|
262
|
+
// 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth (remote targets only).
|
|
263
|
+
// For local targets with authorizeLocal=true, skip Basic auth and let Harper handle it.
|
|
264
|
+
// 3. FLAIR_AGENT_ID env + key file → Ed25519 signature (standard)
|
|
265
|
+
// 4. No auth (Harper authorizeLocal handles local; remote will 401)
|
|
266
|
+
//
|
|
267
|
+
// NOTE: this function is for the Harper HTTP/REST API only. The Harper
|
|
268
|
+
// operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
|
|
269
|
+
// does NOT honor authorizeLocal — it always requires Basic admin auth, and
|
|
270
|
+
// those helpers send it unconditionally. authorizeLocal=true affects this
|
|
271
|
+
// path; it does not affect ops-API calls.
|
|
149
272
|
let authHeader;
|
|
150
273
|
const token = process.env.FLAIR_TOKEN;
|
|
151
274
|
if (token) {
|
|
152
275
|
authHeader = `Bearer ${token}`;
|
|
153
276
|
}
|
|
154
|
-
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
277
|
+
else if (!isLocal && (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD)) {
|
|
155
278
|
// Admin Basic auth — used by federation, backup, and other admin CLI commands
|
|
156
279
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
157
280
|
authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
@@ -174,7 +297,8 @@ async function api(method, path, body) {
|
|
|
174
297
|
}
|
|
175
298
|
catch (err) {
|
|
176
299
|
// Key exists but auth build failed — warn and continue without auth
|
|
177
|
-
|
|
300
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
301
|
+
console.error(`Warning: Ed25519 auth failed for agent '${agentId}': ${message}`);
|
|
178
302
|
}
|
|
179
303
|
}
|
|
180
304
|
}
|
|
@@ -303,9 +427,22 @@ function readHarperPid(dataDir) {
|
|
|
303
427
|
return null;
|
|
304
428
|
}
|
|
305
429
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
430
|
+
/**
|
|
431
|
+
* Seed an agent record via the Harper operations API.
|
|
432
|
+
* Accepts either a port number (localhost) or a full URL string (--target).
|
|
433
|
+
*
|
|
434
|
+
* `adminPass` is typed as optional but in practice the Harper operations API
|
|
435
|
+
* always requires Basic admin auth — every existing call site passes one.
|
|
436
|
+
* The optional signature leaves headroom for a future Harper that honors
|
|
437
|
+
* `authorizeLocal` on its ops endpoint; until then, callers must pass it.
|
|
438
|
+
*/
|
|
439
|
+
export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, adminUser, adminPass) {
|
|
440
|
+
const url = typeof opsPortOrUrl === "number"
|
|
441
|
+
? `http://127.0.0.1:${opsPortOrUrl}/`
|
|
442
|
+
: `${opsPortOrUrl.replace(/\/$/, "")}/`;
|
|
443
|
+
// Send Basic auth whenever the caller passed an adminPass. The caller decides
|
|
444
|
+
// when to omit it (e.g., local target with authorizeLocal=true).
|
|
445
|
+
const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
|
|
309
446
|
const body = {
|
|
310
447
|
operation: "insert",
|
|
311
448
|
database: "flair",
|
|
@@ -314,8 +451,9 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
|
|
|
314
451
|
};
|
|
315
452
|
const res = await fetch(url, {
|
|
316
453
|
method: "POST",
|
|
317
|
-
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
454
|
+
headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
|
|
318
455
|
body: JSON.stringify(body),
|
|
456
|
+
signal: AbortSignal.timeout(10_000),
|
|
319
457
|
});
|
|
320
458
|
if (!res.ok) {
|
|
321
459
|
const text = await res.text().catch(() => "");
|
|
@@ -324,6 +462,225 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
|
|
|
324
462
|
throw new Error(`Operations API insert failed (${res.status}): ${text}`);
|
|
325
463
|
}
|
|
326
464
|
}
|
|
465
|
+
// Seed an agent record via the Harper REST API.
|
|
466
|
+
// Uses localhost and the given HTTP port.
|
|
467
|
+
export async function seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass) {
|
|
468
|
+
const baseUrl = `http://127.0.0.1:${httpPort}`;
|
|
469
|
+
const body = {
|
|
470
|
+
operation: "insert",
|
|
471
|
+
database: "flair",
|
|
472
|
+
table: "Agent",
|
|
473
|
+
records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
|
|
474
|
+
};
|
|
475
|
+
// Only send Authorization header if adminPass is provided.
|
|
476
|
+
// Matches the auth pattern in api() which respects authorizeLocal=true.
|
|
477
|
+
const headers = { "Content-Type": "application/json" };
|
|
478
|
+
if (adminPass) {
|
|
479
|
+
headers.Authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
480
|
+
}
|
|
481
|
+
const res = await fetch(`${baseUrl}/Agent`, {
|
|
482
|
+
method: "POST",
|
|
483
|
+
headers,
|
|
484
|
+
body: JSON.stringify(body),
|
|
485
|
+
signal: AbortSignal.timeout(10_000),
|
|
486
|
+
});
|
|
487
|
+
if (!res.ok) {
|
|
488
|
+
const text = await res.text().catch(() => "");
|
|
489
|
+
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
490
|
+
return;
|
|
491
|
+
throw new Error(`REST API insert failed (${res.status}): ${text}`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
// ─── FederationInstance seed via ops API ──────────────────────────────────────
|
|
495
|
+
//
|
|
496
|
+
// Remote init writes FederationInstance through the ops API (Basic auth with
|
|
497
|
+
// admin:admin-pass), not the REST API (which needs server-side HDB_ADMIN_PASSWORD
|
|
498
|
+
// — unavailable on Fabric). Same pattern as seedAgentViaOpsApi above.
|
|
499
|
+
//
|
|
500
|
+
// `adminPass` is optional in the signature for symmetry with seedAgentViaOpsApi
|
|
501
|
+
// and to keep the door open for a future Harper that honors authorizeLocal on
|
|
502
|
+
// its ops endpoint. Today the Harper operations API always requires Basic admin
|
|
503
|
+
// auth; every current caller passes it.
|
|
504
|
+
export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId, publicKey, role, adminUser, adminPass) {
|
|
505
|
+
const url = typeof opsPortOrUrl === "number"
|
|
506
|
+
? `http://127.0.0.1:${opsPortOrUrl}/`
|
|
507
|
+
: `${opsPortOrUrl.replace(/\/$/, "")}/`;
|
|
508
|
+
// Send Basic auth whenever the caller passed an adminPass. The caller decides
|
|
509
|
+
// when to omit it (e.g., local target with authorizeLocal=true).
|
|
510
|
+
const auth = adminPass !== undefined ? Buffer.from(`${adminUser}:${adminPass}`).toString("base64") : undefined;
|
|
511
|
+
const now = new Date().toISOString();
|
|
512
|
+
const body = {
|
|
513
|
+
operation: "insert",
|
|
514
|
+
database: "flair",
|
|
515
|
+
table: "Instance",
|
|
516
|
+
records: [{
|
|
517
|
+
id: instanceId,
|
|
518
|
+
publicKey,
|
|
519
|
+
role,
|
|
520
|
+
status: "active",
|
|
521
|
+
createdAt: now,
|
|
522
|
+
updatedAt: now,
|
|
523
|
+
}],
|
|
524
|
+
};
|
|
525
|
+
const res = await fetch(url, {
|
|
526
|
+
method: "POST",
|
|
527
|
+
headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
|
|
528
|
+
body: JSON.stringify(body),
|
|
529
|
+
signal: AbortSignal.timeout(10_000),
|
|
530
|
+
});
|
|
531
|
+
if (!res.ok) {
|
|
532
|
+
const text = await res.text().catch(() => "");
|
|
533
|
+
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
534
|
+
return;
|
|
535
|
+
throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
// ─── Provision Flair on Harper Fabric (ops-2kyi) ───────────────────────────
|
|
539
|
+
//
|
|
540
|
+
// Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
|
|
541
|
+
// tarball with .env baked in, deploys via ops API, waits for restart, and
|
|
542
|
+
// creates the super_user admin account.
|
|
543
|
+
export async function callOpsApi(opsUrl, body, user, pass) {
|
|
544
|
+
const url = `${opsUrl.replace(/\/$/, "")}/`;
|
|
545
|
+
const auth = Buffer.from(`${user}:${pass}`).toString("base64");
|
|
546
|
+
const res = await fetch(url, {
|
|
547
|
+
method: "POST",
|
|
548
|
+
headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
|
|
549
|
+
body: JSON.stringify(body),
|
|
550
|
+
signal: AbortSignal.timeout(30_000),
|
|
551
|
+
});
|
|
552
|
+
if (!res.ok) {
|
|
553
|
+
const text = await res.text().catch(() => "");
|
|
554
|
+
throw new Error(`Ops API call failed (${res.status}): ${text}`);
|
|
555
|
+
}
|
|
556
|
+
return res.json();
|
|
557
|
+
}
|
|
558
|
+
export async function buildDeployTarball(projectRoot, flairAdminPass) {
|
|
559
|
+
const tmpDir = mkdtempSync(join(tmpdir(), "flair-deploy-"));
|
|
560
|
+
try {
|
|
561
|
+
// Copy deployment files into temp directory
|
|
562
|
+
const entries = ["dist", "schemas", "config.yaml", "package.json", "LICENSE", "README.md", "SECURITY.md"];
|
|
563
|
+
if (existsSync(join(projectRoot, "ui")))
|
|
564
|
+
entries.push("ui");
|
|
565
|
+
for (const entry of entries) {
|
|
566
|
+
const src = join(projectRoot, entry);
|
|
567
|
+
const dst = join(tmpDir, entry);
|
|
568
|
+
if (existsSync(src)) {
|
|
569
|
+
cpSync(src, dst, { recursive: true });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
// Write .env with 600 permissions
|
|
573
|
+
const envContent = [
|
|
574
|
+
`HDB_ADMIN_PASSWORD=${flairAdminPass}`,
|
|
575
|
+
`FLAIR_ADMIN_PASSWORD=${flairAdminPass}`,
|
|
576
|
+
"",
|
|
577
|
+
].join("\n");
|
|
578
|
+
writeFileSync(join(tmpDir, ".env"), envContent, { mode: 0o600 });
|
|
579
|
+
// Build compressed tarball
|
|
580
|
+
const tarballPath = join(tmpDir, "deploy.tar.gz");
|
|
581
|
+
await tarCreate({ gzip: true, cwd: tmpDir, file: tarballPath, portable: true }, entries);
|
|
582
|
+
const buf = readFileSync(tarballPath);
|
|
583
|
+
return { tarballB64: buf.toString("base64") };
|
|
584
|
+
}
|
|
585
|
+
finally {
|
|
586
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
export async function waitForFlairRestart(targetUrl, maxWaitMs = 30_000) {
|
|
590
|
+
const url = `${targetUrl.replace(/\/$/, "")}/FederationPair`;
|
|
591
|
+
const intervalMs = 1_000;
|
|
592
|
+
const deadline = Date.now() + maxWaitMs;
|
|
593
|
+
while (Date.now() < deadline) {
|
|
594
|
+
try {
|
|
595
|
+
const res = await fetch(url, {
|
|
596
|
+
method: "POST",
|
|
597
|
+
headers: { "Content-Type": "application/json" },
|
|
598
|
+
body: JSON.stringify({}),
|
|
599
|
+
signal: AbortSignal.timeout(5_000),
|
|
600
|
+
});
|
|
601
|
+
const text = await res.text().catch(() => "");
|
|
602
|
+
// The resource handler responds with "instanceId and publicKey required"
|
|
603
|
+
// when the deployment is live and Flair is serving requests.
|
|
604
|
+
if (text.includes("instanceId and publicKey required"))
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
// Not ready yet — keep polling
|
|
609
|
+
}
|
|
610
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
611
|
+
}
|
|
612
|
+
throw new Error(`Flair did not respond within ${maxWaitMs / 1000}s`);
|
|
613
|
+
}
|
|
614
|
+
export async function provisionFabric(target, opsTarget, clusterAdminUser, clusterAdminPass, flairAdminPass) {
|
|
615
|
+
const projectRoot = process.cwd();
|
|
616
|
+
// 1. Build and deploy component tarball
|
|
617
|
+
console.log("Building deploy tarball...");
|
|
618
|
+
const { tarballB64 } = await buildDeployTarball(projectRoot, flairAdminPass);
|
|
619
|
+
console.log("Deploying via ops API...");
|
|
620
|
+
await callOpsApi(opsTarget, {
|
|
621
|
+
operation: "deploy_component",
|
|
622
|
+
project: "flair",
|
|
623
|
+
payload: tarballB64,
|
|
624
|
+
restart: "rolling",
|
|
625
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
626
|
+
// 2. Wait for restart
|
|
627
|
+
console.log("Waiting for Flair to restart...");
|
|
628
|
+
await waitForFlairRestart(target);
|
|
629
|
+
console.log("Flair is running ✓");
|
|
630
|
+
// 3. Provision Harper super_user
|
|
631
|
+
// Since ops-lzmg is merged, the username doesn't have to be "admin".
|
|
632
|
+
// We can use the cluster-admin user directly if it's already a super_user.
|
|
633
|
+
// Check if cluster admin is already a super_user first:
|
|
634
|
+
let clusterAdminIsSuperUser = false;
|
|
635
|
+
try {
|
|
636
|
+
const userInfo = await callOpsApi(opsTarget, {
|
|
637
|
+
operation: "list_users",
|
|
638
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
639
|
+
// list_users returns an array of user objects with role/permission info
|
|
640
|
+
const users = Array.isArray(userInfo) ? userInfo : [];
|
|
641
|
+
const adminRecord = users.find((u) => u.username === clusterAdminUser || u.user?.username === clusterAdminUser);
|
|
642
|
+
clusterAdminIsSuperUser = !!(adminRecord?.role?.permission?.super_user ??
|
|
643
|
+
adminRecord?.permission?.super_user ??
|
|
644
|
+
false);
|
|
645
|
+
}
|
|
646
|
+
catch {
|
|
647
|
+
// If we can't check, assume not and proceed with add_user
|
|
648
|
+
}
|
|
649
|
+
if (clusterAdminIsSuperUser) {
|
|
650
|
+
console.log(`Cluster admin '${clusterAdminUser}' is already a super_user — skipping user provisioning`);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
console.log(`Provisioning Harper user 'admin' as super_user...`);
|
|
654
|
+
try {
|
|
655
|
+
await callOpsApi(opsTarget, {
|
|
656
|
+
operation: "add_user",
|
|
657
|
+
username: "admin",
|
|
658
|
+
password: flairAdminPass,
|
|
659
|
+
role: "super_user",
|
|
660
|
+
active: true,
|
|
661
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
662
|
+
console.log("User 'admin' created ✓");
|
|
663
|
+
}
|
|
664
|
+
catch (err) {
|
|
665
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
666
|
+
if (msg.includes("already exists") || msg.includes("duplicate")) {
|
|
667
|
+
// Idempotent: fall back to alter_user
|
|
668
|
+
console.log("User 'admin' already exists — updating password...");
|
|
669
|
+
await callOpsApi(opsTarget, {
|
|
670
|
+
operation: "alter_user",
|
|
671
|
+
username: "admin",
|
|
672
|
+
password: flairAdminPass,
|
|
673
|
+
role: "super_user",
|
|
674
|
+
active: true,
|
|
675
|
+
}, clusterAdminUser, clusterAdminPass);
|
|
676
|
+
console.log("User 'admin' updated ✓");
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
throw err;
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
327
684
|
// ─── Upgrade presence probes ──────────────────────────────────────────────────
|
|
328
685
|
//
|
|
329
686
|
// `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
|
|
@@ -515,7 +872,8 @@ async function runSoulWizard(agentId) {
|
|
|
515
872
|
}
|
|
516
873
|
}
|
|
517
874
|
catch (err) {
|
|
518
|
-
|
|
875
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
876
|
+
console.log(`\n Couldn't parse JSON (${message}). Falling back to custom prompts.`);
|
|
519
877
|
entries = await customSoulPrompts(ask);
|
|
520
878
|
}
|
|
521
879
|
}
|
|
@@ -542,24 +900,257 @@ program.name("flair").version(__pkgVersion, "-v, --version");
|
|
|
542
900
|
// ─── flair init ──────────────────────────────────────────────────────────────
|
|
543
901
|
program
|
|
544
902
|
.command("init")
|
|
545
|
-
.description("Bootstrap a
|
|
546
|
-
.option("--agent-id <id>", "Agent ID to register
|
|
903
|
+
.description("Bootstrap a Flair (Harper) instance for an agent")
|
|
904
|
+
.option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
|
|
547
905
|
.option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
|
|
548
906
|
.option("--ops-port <port>", "Harper operations API port")
|
|
549
907
|
.option("--admin-pass <pass>", "Admin password (generated if omitted)")
|
|
908
|
+
.option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
|
|
550
909
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
551
910
|
.option("--data-dir <dir>", "Harper data directory")
|
|
552
911
|
.option("--skip-start", "Skip Harper startup (assume already running)")
|
|
553
912
|
.option("--skip-soul", "Skip interactive personality setup")
|
|
913
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
914
|
+
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
915
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
916
|
+
.option("--force", "Skip confirmation prompt for remote writes (required with --target)")
|
|
917
|
+
.option("--cluster-admin-user <user>", "Harper cluster admin username (env: FLAIR_CLUSTER_ADMIN_USER)")
|
|
918
|
+
.option("--cluster-admin-pass <pass>", "Harper cluster admin password (env: FLAIR_CLUSTER_ADMIN_PASS)")
|
|
919
|
+
.option("--flair-admin-pass <pass>", "Password for Flair's admin user (env: FLAIR_ADMIN_PASS; generated if omitted)")
|
|
554
920
|
.action(async (opts) => {
|
|
555
921
|
const agentId = opts.agentId;
|
|
922
|
+
const target = resolveTarget(opts);
|
|
923
|
+
const opsTarget = resolveOpsTarget(opts);
|
|
924
|
+
// ── Remote init: --target and/or --ops-target drive a remote Flair instance ──
|
|
925
|
+
if (target || opsTarget) {
|
|
926
|
+
// When -only- --ops-target is provided, attempt to derive REST URL
|
|
927
|
+
if (!target && opsTarget) {
|
|
928
|
+
console.error("Error: --ops-target requires --target as well. Pass --target <rest-url> for the REST API surface.");
|
|
929
|
+
console.error(" Currently only explicit --ops-target + --target combination is supported.");
|
|
930
|
+
process.exit(1);
|
|
931
|
+
}
|
|
932
|
+
const baseUrl = target.replace(/\/$/, "");
|
|
933
|
+
// --ops-target overrides derivation; otherwise derive from --target
|
|
934
|
+
const opsUrl = opsTarget ? opsTarget.replace(/\/$/, "") : resolveOpsUrlFromTarget(baseUrl);
|
|
935
|
+
// Check for cluster-admin provisioning (new atomic flow)
|
|
936
|
+
const clusterAdminUser = opts.clusterAdminUser || process.env.FLAIR_CLUSTER_ADMIN_USER;
|
|
937
|
+
const clusterAdminPass = opts.clusterAdminPass || process.env.FLAIR_CLUSTER_ADMIN_PASS;
|
|
938
|
+
let flairAdminPass = opts.flairAdminPass || process.env.FLAIR_ADMIN_PASS;
|
|
939
|
+
let didProvision = false;
|
|
940
|
+
if (clusterAdminUser && clusterAdminPass) {
|
|
941
|
+
// ── New provisioning path: deploy Flair to Fabric, wait, provision super_user ──
|
|
942
|
+
if (!opts.force) {
|
|
943
|
+
console.error("Error: --force is required with --target/--ops-target (remote init provisions a live Fabric instance)");
|
|
944
|
+
console.error(" Pass --force to confirm this is intended.");
|
|
945
|
+
process.exit(1);
|
|
946
|
+
}
|
|
947
|
+
// Generate flair admin pass if not provided
|
|
948
|
+
if (!flairAdminPass) {
|
|
949
|
+
flairAdminPass = randomBytes(24).toString("base64url");
|
|
950
|
+
}
|
|
951
|
+
// Write the flair admin pass to secrets directory
|
|
952
|
+
const secretsDir = join(homedir(), ".tps", "secrets");
|
|
953
|
+
mkdirSync(secretsDir, { recursive: true });
|
|
954
|
+
const secretPath = join(secretsDir, "flair-fabric-hdb");
|
|
955
|
+
writeFileSync(secretPath, flairAdminPass + "\n", { mode: 0o600 });
|
|
956
|
+
console.log(`Admin password written to ${secretPath}`);
|
|
957
|
+
// Atomic provisioning: deploy + wait + provision user
|
|
958
|
+
await provisionFabric(baseUrl, opsUrl, clusterAdminUser, clusterAdminPass, flairAdminPass);
|
|
959
|
+
didProvision = true;
|
|
960
|
+
}
|
|
961
|
+
else {
|
|
962
|
+
// ── Existing behavior: --admin-pass required for already-running Flair ──
|
|
963
|
+
if (!opts.adminPass) {
|
|
964
|
+
console.error("Error: --admin-pass is required with --target/--ops-target (remote init without --cluster-admin-user/--cluster-admin-pass)");
|
|
965
|
+
console.error(" Use --cluster-admin-user and --cluster-admin-pass for automated Fabric provisioning.");
|
|
966
|
+
process.exit(1);
|
|
967
|
+
}
|
|
968
|
+
if (!opts.force) {
|
|
969
|
+
const displayTarget = target || opsTarget;
|
|
970
|
+
console.error(`Error: --force is required with --target/--ops-target. Remote init writes to a live Flair instance at ${displayTarget}.`);
|
|
971
|
+
console.error(" Pass --force to confirm this is intended.");
|
|
972
|
+
process.exit(1);
|
|
973
|
+
}
|
|
974
|
+
flairAdminPass = opts.adminPass;
|
|
975
|
+
}
|
|
976
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
977
|
+
const auth = `Basic ${Buffer.from(`${adminUser}:${flairAdminPass}`).toString("base64")}`;
|
|
978
|
+
const role = opts.remote ? "hub" : undefined;
|
|
979
|
+
// Generate or reuse keypair (only if --agent-id provided, or --remote needs
|
|
980
|
+
// a public key for the FederationInstance row)
|
|
981
|
+
let pubKeyB64url;
|
|
982
|
+
let privPath;
|
|
983
|
+
let instanceId;
|
|
984
|
+
if (agentId || role) {
|
|
985
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
986
|
+
mkdirSync(keysDir, { recursive: true });
|
|
987
|
+
if (agentId) {
|
|
988
|
+
privPath = privKeyPath(agentId, keysDir);
|
|
989
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
990
|
+
if (existsSync(privPath)) {
|
|
991
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
992
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
993
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
994
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
995
|
+
}
|
|
996
|
+
else {
|
|
997
|
+
console.log("Generating Ed25519 keypair...");
|
|
998
|
+
const kp = nacl.sign.keyPair();
|
|
999
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
1000
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
1001
|
+
chmodSync(privPath, 0o600);
|
|
1002
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
1003
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1004
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
1005
|
+
}
|
|
1006
|
+
// Seed agent via remote ops API
|
|
1007
|
+
console.log(`Seeding agent '${agentId}' on ${baseUrl}...`);
|
|
1008
|
+
await seedAgentViaOpsApi(opsUrl, agentId, pubKeyB64url, adminUser, flairAdminPass);
|
|
1009
|
+
console.log(`Agent '${agentId}' registered on remote instance ✓`);
|
|
1010
|
+
}
|
|
1011
|
+
else {
|
|
1012
|
+
// No agentId -- generate throwaway keypair for FederationInstance row
|
|
1013
|
+
console.log("Generating federation instance keypair...");
|
|
1014
|
+
const kp = nacl.sign.keyPair();
|
|
1015
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
else {
|
|
1019
|
+
console.log("No --agent-id provided -- skipping agent registration");
|
|
1020
|
+
}
|
|
1021
|
+
// Write FederationInstance row if --remote (hub role)
|
|
1022
|
+
if (role) {
|
|
1023
|
+
if (!pubKeyB64url) {
|
|
1024
|
+
const kp = nacl.sign.keyPair();
|
|
1025
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1026
|
+
}
|
|
1027
|
+
instanceId = randomUUID();
|
|
1028
|
+
console.log(`Writing federation Instance (role=${role}) via ops API...`);
|
|
1029
|
+
await seedFederationInstanceViaOpsApi(opsUrl, instanceId, pubKeyB64url, role, adminUser, flairAdminPass);
|
|
1030
|
+
console.log(`Federation Instance created: ${instanceId} (${role}) ✓`);
|
|
1031
|
+
}
|
|
1032
|
+
// Verify connectivity
|
|
1033
|
+
if (didProvision) {
|
|
1034
|
+
// Use /FederationInstance with Basic auth (not /Health which false-401s on Fabric)
|
|
1035
|
+
console.log("Verifying remote connectivity...");
|
|
1036
|
+
const verifyRes = await fetch(`${baseUrl}/FederationInstance`, {
|
|
1037
|
+
headers: { Authorization: auth },
|
|
1038
|
+
signal: AbortSignal.timeout(5000),
|
|
1039
|
+
});
|
|
1040
|
+
if (!verifyRes.ok) {
|
|
1041
|
+
const body = await verifyRes.text().catch(() => "");
|
|
1042
|
+
console.error(`Remote verification failed (${verifyRes.status}): ${body}`);
|
|
1043
|
+
process.exit(1);
|
|
1044
|
+
}
|
|
1045
|
+
console.log("✓ Hub ready at " + baseUrl);
|
|
1046
|
+
}
|
|
1047
|
+
else {
|
|
1048
|
+
// Existing behavior: /Health check (already-running Flair)
|
|
1049
|
+
console.log("Verifying remote connectivity...");
|
|
1050
|
+
const verifyRes = await fetch(`${baseUrl}/Health`, { signal: AbortSignal.timeout(5000) });
|
|
1051
|
+
if (!verifyRes.ok) {
|
|
1052
|
+
console.error(`Remote health check failed: ${verifyRes.status}`);
|
|
1053
|
+
process.exit(1);
|
|
1054
|
+
}
|
|
1055
|
+
console.log("Remote Flair instance healthy ✓");
|
|
1056
|
+
}
|
|
1057
|
+
// Print summary
|
|
1058
|
+
if (didProvision) {
|
|
1059
|
+
const pkg = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf-8"));
|
|
1060
|
+
const flavor = pkg.version || "(unknown)";
|
|
1061
|
+
const displayTarget = target || opsTarget;
|
|
1062
|
+
console.log(`\n✓ Flair hub deployed to ${displayTarget}`);
|
|
1063
|
+
console.log(` Component: flair@${flavor}`);
|
|
1064
|
+
console.log(` Admin user: ${adminUser} (pass written to ${join(homedir(), ".tps", "secrets", "flair-fabric-hdb")})`);
|
|
1065
|
+
if (instanceId)
|
|
1066
|
+
console.log(` Instance: ${instanceId} (role=${role})`);
|
|
1067
|
+
console.log(` Federation: ready — run \`flair federation token\` to mint a pairing token`);
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
console.log(`\n✅ Remote Flair initialized`);
|
|
1071
|
+
if (agentId)
|
|
1072
|
+
console.log(` Agent ID: ${agentId}`);
|
|
1073
|
+
console.log(` Target: ${baseUrl}`);
|
|
1074
|
+
if (agentId)
|
|
1075
|
+
console.log(` Private key: ${privPath}`);
|
|
1076
|
+
if (role)
|
|
1077
|
+
console.log(` Role: ${role}`);
|
|
1078
|
+
console.log(`\n Export: FLAIR_URL=${baseUrl}`);
|
|
1079
|
+
}
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
// ── Local init (original behavior) ──
|
|
556
1083
|
const httpPort = resolveHttpPort(opts);
|
|
557
1084
|
const opsPort = resolveOpsPort(opts);
|
|
558
1085
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
559
1086
|
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
560
|
-
// Admin password:
|
|
561
|
-
|
|
1087
|
+
// Admin password: determine from opts, env, or generate
|
|
1088
|
+
// Priority: 1) --admin-pass-file, 2) env vars, 3) generate new
|
|
1089
|
+
let adminPass;
|
|
1090
|
+
let passwordSource = "generated";
|
|
1091
|
+
// Warn if --admin-pass is passed inline (not from env)
|
|
1092
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, false, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
1093
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-file <path> or FLAIR_ADMIN_PASS env " +
|
|
1094
|
+
"to keep secrets out of shell history.");
|
|
1095
|
+
}
|
|
1096
|
+
// Read from file if provided
|
|
1097
|
+
if (opts.adminPassFile) {
|
|
1098
|
+
if (!existsSync(opts.adminPassFile)) {
|
|
1099
|
+
console.error(`Error: --admin-pass-file path does not exist: ${opts.adminPassFile}`);
|
|
1100
|
+
process.exit(1);
|
|
1101
|
+
}
|
|
1102
|
+
const fileContent = readFileSync(opts.adminPassFile, "utf-8");
|
|
1103
|
+
adminPass = fileContent.trim();
|
|
1104
|
+
if (!adminPass) {
|
|
1105
|
+
console.error(`Error: admin password file is empty or contains only whitespace: ${opts.adminPassFile}`);
|
|
1106
|
+
process.exit(1);
|
|
1107
|
+
}
|
|
1108
|
+
passwordSource = "file";
|
|
1109
|
+
}
|
|
1110
|
+
else if (process.env.FLAIR_ADMIN_PASS) {
|
|
1111
|
+
adminPass = process.env.FLAIR_ADMIN_PASS;
|
|
1112
|
+
passwordSource = "env";
|
|
1113
|
+
}
|
|
1114
|
+
else if (process.env.HDB_ADMIN_PASSWORD) {
|
|
1115
|
+
adminPass = process.env.HDB_ADMIN_PASSWORD;
|
|
1116
|
+
passwordSource = "env";
|
|
1117
|
+
}
|
|
1118
|
+
else if (opts.adminPass) {
|
|
1119
|
+
// Inline admin pass (deprecated)
|
|
1120
|
+
adminPass = opts.adminPass;
|
|
1121
|
+
// Don't generate - don't write to file
|
|
1122
|
+
passwordSource = "env"; // Treat same as env for display purposes
|
|
1123
|
+
}
|
|
1124
|
+
else {
|
|
1125
|
+
// Generate new password and write to file atomically
|
|
1126
|
+
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
1127
|
+
passwordSource = "generated";
|
|
1128
|
+
// Atomic write: create temp file in same dir, then rename
|
|
1129
|
+
const flairDir = join(homedir(), ".flair");
|
|
1130
|
+
mkdirSync(flairDir, { recursive: true });
|
|
1131
|
+
const adminPassPath = join(flairDir, "admin-pass");
|
|
1132
|
+
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
1133
|
+
const finalTempPath = join(tempPath, "admin-pass");
|
|
1134
|
+
try {
|
|
1135
|
+
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
1136
|
+
renameSync(finalTempPath, adminPassPath);
|
|
1137
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1138
|
+
}
|
|
1139
|
+
catch (err) {
|
|
1140
|
+
// Clean up temp dir on failure
|
|
1141
|
+
try {
|
|
1142
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1143
|
+
}
|
|
1144
|
+
catch { }
|
|
1145
|
+
throw err;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
562
1148
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
1149
|
+
// If we generated the password, report where it was saved
|
|
1150
|
+
if (passwordSource === "generated") {
|
|
1151
|
+
const adminPassPath = join(homedir(), ".flair", "admin-pass");
|
|
1152
|
+
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
1153
|
+
}
|
|
563
1154
|
// Check Node.js version
|
|
564
1155
|
const major = parseInt(process.version.slice(1), 10);
|
|
565
1156
|
if (major < 18)
|
|
@@ -719,125 +1310,490 @@ program
|
|
|
719
1310
|
}
|
|
720
1311
|
// Persist port to config so other commands can find this instance
|
|
721
1312
|
writeConfig(httpPort);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1313
|
+
if (agentId) {
|
|
1314
|
+
// Generate or reuse keypair
|
|
1315
|
+
mkdirSync(keysDir, { recursive: true });
|
|
1316
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
1317
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
1318
|
+
let pubKeyB64url;
|
|
1319
|
+
if (existsSync(privPath)) {
|
|
1320
|
+
console.log(`Reusing existing key: ${privPath}`);
|
|
1321
|
+
const seed = new Uint8Array(readFileSync(privPath));
|
|
1322
|
+
const kp = nacl.sign.keyPair.fromSeed(seed);
|
|
1323
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1324
|
+
}
|
|
1325
|
+
else {
|
|
1326
|
+
console.log("Generating Ed25519 keypair...");
|
|
1327
|
+
const kp = nacl.sign.keyPair();
|
|
1328
|
+
// Store only the 32-byte seed (first 32 bytes of secretKey)
|
|
1329
|
+
const seed = kp.secretKey.slice(0, 32);
|
|
1330
|
+
writeFileSync(privPath, Buffer.from(seed));
|
|
1331
|
+
chmodSync(privPath, 0o600);
|
|
1332
|
+
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
1333
|
+
pubKeyB64url = b64url(kp.publicKey);
|
|
1334
|
+
console.log(`Keypair written: ${privPath} ✓`);
|
|
1335
|
+
}
|
|
1336
|
+
// Seed agent via operations API
|
|
1337
|
+
console.log(`Seeding agent '${agentId}' via operations API...`);
|
|
1338
|
+
await seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass);
|
|
1339
|
+
console.log(`Agent '${agentId}' registered ✓`);
|
|
1340
|
+
// Verify Ed25519 auth
|
|
1341
|
+
console.log("Verifying Ed25519 auth...");
|
|
1342
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1343
|
+
const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
|
|
1344
|
+
if (!verifyRes.ok)
|
|
1345
|
+
throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
|
|
1346
|
+
console.log("Ed25519 auth verified ✓");
|
|
1347
|
+
// Output — admin password printed once, never written to disk
|
|
1348
|
+
console.log("\n✅ Flair initialized successfully");
|
|
1349
|
+
console.log(` Agent ID: ${agentId}`);
|
|
1350
|
+
console.log(` Flair URL: ${httpUrl}`);
|
|
1351
|
+
console.log(` Private key: ${privPath}`);
|
|
1352
|
+
// Display admin credentials when password was generated or from a file
|
|
1353
|
+
// Do NOT display when from env (to avoid showing the env var value)
|
|
1354
|
+
if (passwordSource !== "env" && !alreadyRunning) {
|
|
1355
|
+
const passDisplay = passwordSource === "file"
|
|
1356
|
+
? opts.adminPassFile ?? "(file path)"
|
|
1357
|
+
: "~/.flair/admin-pass";
|
|
1358
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
1359
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
1360
|
+
console.log(` │ │`);
|
|
1361
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
1362
|
+
console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
|
|
1363
|
+
console.log(` │ │`);
|
|
1364
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
1365
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
1366
|
+
}
|
|
1367
|
+
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
1368
|
+
// ── First-run soul setup ──────────────────────────────────────────────
|
|
1369
|
+
// Interactive wizard to set initial personality (see runSoulWizard).
|
|
1370
|
+
// Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
|
|
1371
|
+
//
|
|
1372
|
+
// Non-TTY / --skip-soul used to seed placeholder text like
|
|
1373
|
+
// "AI assistant [default]" — it leaked into bootstrap output and
|
|
1374
|
+
// confused users. Now those paths leave the soul empty and nudge the
|
|
1375
|
+
// user toward `flair soul set` / `flair doctor` instead.
|
|
1376
|
+
if (!opts.skipSoul && process.stdin.isTTY) {
|
|
1377
|
+
const soulEntries = await runSoulWizard(agentId);
|
|
1378
|
+
if (soulEntries.length > 0) {
|
|
1379
|
+
console.log("");
|
|
1380
|
+
for (const [key, value] of soulEntries) {
|
|
1381
|
+
try {
|
|
1382
|
+
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
1383
|
+
console.log(` ✓ soul:${key} set`);
|
|
1384
|
+
}
|
|
1385
|
+
catch (err) {
|
|
1386
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1387
|
+
console.warn(` ⚠ soul:${key} failed: ${message}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
console.log(`\n ${soulEntries.length} soul entries saved.`);
|
|
1391
|
+
console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
console.log(`\n No soul entries saved. Add later with:`);
|
|
1395
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
1396
|
+
console.log(` Or run \`flair doctor\` anytime for a nudge.`);
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
|
|
1401
|
+
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
1402
|
+
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
1403
|
+
}
|
|
1404
|
+
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
1405
|
+
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
1406
|
+
// Auto-wire MCP config into ~/.claude.json if Claude Code is installed
|
|
1407
|
+
const claudeJsonPath = join(homedir(), ".claude.json");
|
|
1408
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
1409
|
+
const flairMcpConfig = {
|
|
1410
|
+
type: "stdio",
|
|
1411
|
+
command: "flair-mcp",
|
|
1412
|
+
args: [],
|
|
1413
|
+
env: mcpEnv,
|
|
1414
|
+
};
|
|
1415
|
+
try {
|
|
1416
|
+
if (existsSync(claudeJsonPath)) {
|
|
1417
|
+
const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf-8"));
|
|
1418
|
+
const existing = claudeJson.mcpServers?.flair;
|
|
1419
|
+
if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
|
|
1420
|
+
console.log(`\n MCP config already set in ~/.claude.json ✓`);
|
|
1421
|
+
}
|
|
1422
|
+
else {
|
|
1423
|
+
claudeJson.mcpServers = claudeJson.mcpServers || {};
|
|
1424
|
+
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
1425
|
+
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
1426
|
+
console.log(`\n MCP config written to ~/.claude.json ✓`);
|
|
1427
|
+
console.log(` Restart Claude Code to pick up the new config.`);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
else {
|
|
1431
|
+
console.log(`\n MCP config (add to ~/.claude.json):`);
|
|
1432
|
+
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
catch {
|
|
1436
|
+
console.log(`\n MCP config (add manually to ~/.claude.json):`);
|
|
1437
|
+
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
else {
|
|
1441
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1442
|
+
console.log("\n✅ Flair initialized (no agent registered)");
|
|
1443
|
+
console.log(` Flair URL: ${httpUrl}`);
|
|
1444
|
+
// Display admin credentials when password was generated or from a file
|
|
1445
|
+
// Do NOT display when from env (to avoid showing the env var value)
|
|
1446
|
+
if (passwordSource !== "env" && !alreadyRunning) {
|
|
1447
|
+
const passDisplay = passwordSource === "file"
|
|
1448
|
+
? opts.adminPassFile ?? "(file path)"
|
|
1449
|
+
: "~/.flair/admin-pass";
|
|
1450
|
+
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
1451
|
+
console.log(` │ Harper admin credentials (save these now): │`);
|
|
1452
|
+
console.log(` │ │`);
|
|
1453
|
+
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
1454
|
+
console.log(` │ Password: ${passDisplay.padEnd(37)}│`);
|
|
1455
|
+
console.log(` │ │`);
|
|
1456
|
+
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
1457
|
+
console.log(` └─────────────────────────────────────────────────┘`);
|
|
1458
|
+
}
|
|
1459
|
+
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
// ─── flair install ───────────────────────────────────────────────────────────
|
|
1463
|
+
program
|
|
1464
|
+
.command("install")
|
|
1465
|
+
.description("One-command Flair setup — init, agent, and MCP client wiring")
|
|
1466
|
+
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
|
|
1467
|
+
.option("--agent <id>", "Agent ID (defaults to hostname short-form)")
|
|
1468
|
+
.option("--no-mcp", "Skip MCP wiring (init + agent only)")
|
|
1469
|
+
.option("--port <port>", "Harper HTTP port")
|
|
1470
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
1471
|
+
.option("--data-dir <dir>", "Harper data directory")
|
|
1472
|
+
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
1473
|
+
.option("--skip-smoke", "Skip MCP smoke test")
|
|
1474
|
+
.action(async (opts) => {
|
|
1475
|
+
const httpPort = resolveHttpPort(opts);
|
|
1476
|
+
const opsPort = resolveOpsPort(opts);
|
|
1477
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
1478
|
+
const dataDir = opts.dataDir ?? defaultDataDir();
|
|
1479
|
+
// Resolve client selection
|
|
1480
|
+
const clientOpt = opts.client;
|
|
1481
|
+
const noMcp = opts.noMcp === true;
|
|
1482
|
+
const selectedClients = [];
|
|
1483
|
+
if (clientOpt === "none" || noMcp) {
|
|
1484
|
+
// Skip MCP entirely — just init + agent
|
|
1485
|
+
}
|
|
1486
|
+
else if (clientOpt === "all") {
|
|
1487
|
+
// Wire all detected clients
|
|
1488
|
+
}
|
|
1489
|
+
else if (clientOpt) {
|
|
1490
|
+
// Wire a specific client
|
|
1491
|
+
const valid = ["claude-code", "codex", "gemini", "cursor"];
|
|
1492
|
+
if (!valid.includes(clientOpt)) {
|
|
1493
|
+
console.error(`Unknown client: ${clientOpt}. Valid: claude-code, codex, gemini, cursor, all, none`);
|
|
1494
|
+
process.exit(1);
|
|
1495
|
+
}
|
|
1496
|
+
selectedClients.push(clientOpt);
|
|
1497
|
+
}
|
|
1498
|
+
// If no --client flag: interactive detection later
|
|
1499
|
+
// ── Step 1: Ensure Flair is initialized ──
|
|
1500
|
+
let alreadyInitialized = false;
|
|
1501
|
+
let alreadyRunning = false;
|
|
1502
|
+
let adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1503
|
+
const adminUser = DEFAULT_ADMIN_USER;
|
|
1504
|
+
// Check if Flair is already initialized (config with port exists)
|
|
1505
|
+
try {
|
|
1506
|
+
const cp = configPath();
|
|
1507
|
+
if (existsSync(cp)) {
|
|
1508
|
+
const yaml = readFileSync(cp, "utf-8");
|
|
1509
|
+
if (yaml.match(/port:\s*\d+/)) {
|
|
1510
|
+
alreadyInitialized = true;
|
|
1511
|
+
console.log("Flair already initialized — skipping init.");
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
catch { /* not initialized */ }
|
|
1516
|
+
if (!alreadyInitialized) {
|
|
1517
|
+
const major = parseInt(process.version.slice(1), 10);
|
|
1518
|
+
if (major < 18)
|
|
1519
|
+
throw new Error(`Node.js >= 18 required (found ${process.version})`);
|
|
1520
|
+
// Only generate a password if none provided via env (fresh install)
|
|
1521
|
+
// If we generate, write it atomically to ~/.flair/admin-pass
|
|
1522
|
+
let adminPassGenerated = false;
|
|
1523
|
+
if (!adminPass) {
|
|
1524
|
+
adminPass = Buffer.from(nacl.randomBytes(18)).toString("base64url");
|
|
1525
|
+
adminPassGenerated = true;
|
|
1526
|
+
// Atomic write: create temp file in same dir, then rename
|
|
1527
|
+
const flairDir = join(homedir(), ".flair");
|
|
1528
|
+
mkdirSync(flairDir, { recursive: true });
|
|
1529
|
+
const adminPassPath = join(flairDir, "admin-pass");
|
|
1530
|
+
const tempPath = mkdtempSync(join(flairDir, ".admin-pass.tmp-"));
|
|
1531
|
+
const finalTempPath = join(tempPath, "admin-pass");
|
|
1532
|
+
try {
|
|
1533
|
+
writeFileSync(finalTempPath, adminPass + "\n", { mode: 0o600 });
|
|
1534
|
+
renameSync(finalTempPath, adminPassPath);
|
|
1535
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1536
|
+
}
|
|
1537
|
+
catch (err) {
|
|
1538
|
+
// Clean up temp dir on failure
|
|
1539
|
+
try {
|
|
1540
|
+
rmSync(tempPath, { recursive: true, force: true });
|
|
1541
|
+
}
|
|
1542
|
+
catch { }
|
|
1543
|
+
throw err;
|
|
1544
|
+
}
|
|
1545
|
+
console.log(`Admin password saved to: ${adminPassPath}`);
|
|
1546
|
+
}
|
|
1547
|
+
// Check if Harper is already running on this port
|
|
1548
|
+
try {
|
|
1549
|
+
const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(1000) });
|
|
1550
|
+
if (res.status > 0) {
|
|
1551
|
+
alreadyRunning = true;
|
|
1552
|
+
console.log(`Harper already running on port ${httpPort} — skipping start`);
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
catch { /* not running */ }
|
|
1556
|
+
if (!alreadyRunning) {
|
|
1557
|
+
const bin = harperBin();
|
|
1558
|
+
if (!bin)
|
|
1559
|
+
throw new Error("@harperfast/harper not found in node_modules.\nRun: npm install @harperfast/harper");
|
|
1560
|
+
mkdirSync(dataDir, { recursive: true });
|
|
1561
|
+
const alreadyInstalled = existsSync(join(dataDir, "harper-config.yaml"));
|
|
1562
|
+
const harperSetConfig = JSON.stringify({
|
|
1563
|
+
rootPath: dataDir,
|
|
1564
|
+
http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
|
|
1565
|
+
operationsApi: { network: { port: opsPort, cors: true }, domainSocket: join(dataDir, "operations-server") },
|
|
1566
|
+
mqtt: { network: { port: null }, webSocket: false },
|
|
1567
|
+
localStudio: { enabled: false },
|
|
1568
|
+
authentication: { authorizeLocal: true, enableSessions: true },
|
|
1569
|
+
});
|
|
1570
|
+
const env = {
|
|
1571
|
+
...process.env,
|
|
1572
|
+
ROOTPATH: dataDir,
|
|
1573
|
+
HARPER_SET_CONFIG: harperSetConfig,
|
|
1574
|
+
DEFAULTS_MODE: "dev",
|
|
1575
|
+
HDB_ADMIN_USERNAME: adminUser,
|
|
1576
|
+
HDB_ADMIN_PASSWORD: adminPass,
|
|
1577
|
+
THREADS_COUNT: "1",
|
|
1578
|
+
NODE_HOSTNAME: "localhost",
|
|
1579
|
+
HTTP_PORT: String(httpPort),
|
|
1580
|
+
OPERATIONSAPI_NETWORK_PORT: String(opsPort),
|
|
1581
|
+
LOCAL_STUDIO: "false",
|
|
1582
|
+
};
|
|
1583
|
+
if (alreadyInstalled) {
|
|
1584
|
+
console.log("Existing Harper installation found — skipping install.");
|
|
1585
|
+
}
|
|
1586
|
+
else {
|
|
1587
|
+
const installEnv = { ...env, HOME: join(dataDir, "..") };
|
|
1588
|
+
console.log("Installing Harper...");
|
|
1589
|
+
console.log("Downloading embedding model (nomic-embed-text-v1.5, ~80MB) — this may take a minute...");
|
|
1590
|
+
await new Promise((resolve, reject) => {
|
|
1591
|
+
let output = "";
|
|
1592
|
+
let dotTimer = null;
|
|
1593
|
+
const install = spawn(process.execPath, [bin, "install"], { cwd: flairPackageDir(), env: installEnv });
|
|
1594
|
+
dotTimer = setInterval(() => process.stdout.write("."), 3000);
|
|
1595
|
+
install.stdout?.on("data", (d) => { output += d.toString(); });
|
|
1596
|
+
install.stderr?.on("data", (d) => { output += d.toString(); });
|
|
1597
|
+
install.on("exit", (code) => {
|
|
1598
|
+
if (dotTimer) {
|
|
1599
|
+
clearInterval(dotTimer);
|
|
1600
|
+
process.stdout.write("\n");
|
|
1601
|
+
}
|
|
1602
|
+
code === 0 ? resolve() : reject(new Error(`Harper install failed (${code}): ${output}`));
|
|
1603
|
+
});
|
|
1604
|
+
install.on("error", (err) => {
|
|
1605
|
+
if (dotTimer) {
|
|
1606
|
+
clearInterval(dotTimer);
|
|
1607
|
+
process.stdout.write("\n");
|
|
1608
|
+
}
|
|
1609
|
+
reject(err);
|
|
1610
|
+
});
|
|
1611
|
+
setTimeout(() => {
|
|
1612
|
+
install.kill();
|
|
1613
|
+
if (dotTimer) {
|
|
1614
|
+
clearInterval(dotTimer);
|
|
1615
|
+
process.stdout.write("\n");
|
|
1616
|
+
}
|
|
1617
|
+
reject(new Error(`Harper install timed out: ${output}`));
|
|
1618
|
+
}, 60_000);
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
// Start Harper
|
|
1622
|
+
console.log(`Starting Harper on port ${httpPort}...`);
|
|
1623
|
+
const proc = spawn(process.execPath, [bin, "run", "."], { cwd: flairPackageDir(), env, detached: true, stdio: "ignore" });
|
|
1624
|
+
proc.unref();
|
|
1625
|
+
}
|
|
1626
|
+
// Wait for health
|
|
1627
|
+
console.log("Waiting for Harper health check...");
|
|
1628
|
+
await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
|
|
1629
|
+
console.log("Harper is healthy ✓");
|
|
1630
|
+
// Write config so other commands can find this instance
|
|
1631
|
+
writeConfig(httpPort);
|
|
1632
|
+
}
|
|
1633
|
+
else {
|
|
1634
|
+
// Flair already initialized — resolve admin pass from env or running instance
|
|
1635
|
+
adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1636
|
+
}
|
|
1637
|
+
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1638
|
+
// ── Step 2: Detect MCP clients ──
|
|
1639
|
+
let clients = detectClients();
|
|
1640
|
+
if (selectedClients.length > 0) {
|
|
1641
|
+
// Explicit --client flag — override detection
|
|
1642
|
+
if (clientOpt === "all" || clientOpt === "none" || noMcp) {
|
|
1643
|
+
// all/none handled below
|
|
1644
|
+
}
|
|
1645
|
+
else {
|
|
1646
|
+
// Filter to only the selected client
|
|
1647
|
+
clients = [{ id: selectedClients[0], label: selectedClients[0], detected: true }];
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
if (!clientOpt && !noMcp) {
|
|
1651
|
+
// No --client flag — print detected clients
|
|
1652
|
+
const detected = clients.filter(c => c.detected);
|
|
1653
|
+
if (detected.length === 0) {
|
|
1654
|
+
console.log("No MCP clients detected. Run with --client <name> to wire a specific client.");
|
|
1655
|
+
}
|
|
1656
|
+
else {
|
|
1657
|
+
console.log(`Detected MCP clients: ${detected.map(c => c.label).join(", ")}`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
// ── Step 3: Resolve agent identity ──
|
|
1661
|
+
const agentId = opts.agent ?? (() => {
|
|
1662
|
+
try {
|
|
1663
|
+
const hn = hostname();
|
|
1664
|
+
return hn.split(".")[0];
|
|
1665
|
+
}
|
|
1666
|
+
catch {
|
|
1667
|
+
return "flair-agent";
|
|
1668
|
+
}
|
|
1669
|
+
})();
|
|
1670
|
+
// Validate agentId to prevent path traversal
|
|
1671
|
+
const VALID_AGENT_ID = /^[a-zA-Z0-9_-]+$/;
|
|
1672
|
+
if (!VALID_AGENT_ID.test(agentId)) {
|
|
1673
|
+
throw new Error(`Invalid agent ID: ${agentId}. Agent ID must contain only letters, numbers, underscores, and hyphens.`);
|
|
1674
|
+
}
|
|
1675
|
+
let agentExists = false;
|
|
1676
|
+
// Check if agent already exists locally
|
|
1677
|
+
const privPath = privKeyPath(agentId, keysDir);
|
|
1678
|
+
if (existsSync(privPath)) {
|
|
1679
|
+
agentExists = true;
|
|
1680
|
+
console.log(`Agent '${agentId}' already exists ✓`);
|
|
732
1681
|
}
|
|
733
1682
|
else {
|
|
1683
|
+
// Create agent
|
|
1684
|
+
mkdirSync(keysDir, { recursive: true });
|
|
1685
|
+
const pubPath = pubKeyPath(agentId, keysDir);
|
|
734
1686
|
console.log("Generating Ed25519 keypair...");
|
|
735
1687
|
const kp = nacl.sign.keyPair();
|
|
736
|
-
// Store only the 32-byte seed (first 32 bytes of secretKey)
|
|
737
1688
|
const seed = kp.secretKey.slice(0, 32);
|
|
738
1689
|
writeFileSync(privPath, Buffer.from(seed));
|
|
739
1690
|
chmodSync(privPath, 0o600);
|
|
740
1691
|
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
741
|
-
pubKeyB64url = b64url(kp.publicKey);
|
|
1692
|
+
const pubKeyB64url = b64url(kp.publicKey);
|
|
742
1693
|
console.log(`Keypair written: ${privPath} ✓`);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
// Verify Ed25519 auth
|
|
749
|
-
console.log("Verifying Ed25519 auth...");
|
|
750
|
-
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
751
|
-
const verifyRes = await authFetch(httpUrl, agentId, privPath, "GET", `/Agent/${agentId}`);
|
|
752
|
-
if (!verifyRes.ok)
|
|
753
|
-
throw new Error(`Ed25519 auth verification failed: ${verifyRes.status}`);
|
|
754
|
-
console.log("Ed25519 auth verified ✓");
|
|
755
|
-
// Output — admin password printed once, never written to disk
|
|
756
|
-
console.log("\n✅ Flair initialized successfully");
|
|
757
|
-
console.log(` Agent ID: ${agentId}`);
|
|
758
|
-
console.log(` Flair URL: ${httpUrl}`);
|
|
759
|
-
console.log(` Private key: ${privPath}`);
|
|
760
|
-
if (!opts.adminPass && !alreadyRunning) {
|
|
761
|
-
console.log(`\n ┌─────────────────────────────────────────────────┐`);
|
|
762
|
-
console.log(` │ Harper admin credentials (save these now): │`);
|
|
763
|
-
console.log(` │ │`);
|
|
764
|
-
console.log(` │ Username: ${DEFAULT_ADMIN_USER.padEnd(37)}│`);
|
|
765
|
-
console.log(` │ Password: ${adminPass.padEnd(37)}│`);
|
|
766
|
-
console.log(` │ │`);
|
|
767
|
-
console.log(` │ ⚠️ The password won't be shown again. │`);
|
|
768
|
-
console.log(` └─────────────────────────────────────────────────┘`);
|
|
769
|
-
}
|
|
770
|
-
console.log(`\n Export: FLAIR_URL=${httpUrl}`);
|
|
771
|
-
// ── First-run soul setup ──────────────────────────────────────────────
|
|
772
|
-
// Interactive wizard to set initial personality (see runSoulWizard).
|
|
773
|
-
// Skipped with --skip-soul or when stdin is not a TTY (CI, scripts, pipe).
|
|
774
|
-
//
|
|
775
|
-
// Non-TTY / --skip-soul used to seed placeholder text like
|
|
776
|
-
// "AI assistant [default]" — it leaked into bootstrap output and
|
|
777
|
-
// confused users. Now those paths leave the soul empty and nudge the
|
|
778
|
-
// user toward `flair soul set` / `flair doctor` instead.
|
|
779
|
-
if (!opts.skipSoul && process.stdin.isTTY) {
|
|
780
|
-
const soulEntries = await runSoulWizard(agentId);
|
|
781
|
-
if (soulEntries.length > 0) {
|
|
782
|
-
console.log("");
|
|
783
|
-
for (const [key, value] of soulEntries) {
|
|
784
|
-
try {
|
|
785
|
-
await authFetch(httpUrl, agentId, privPath, "PUT", `/Soul/${agentId}:${key}`, { id: `${agentId}:${key}`, agentId, key, value, createdAt: new Date().toISOString() });
|
|
786
|
-
console.log(` ✓ soul:${key} set`);
|
|
787
|
-
}
|
|
788
|
-
catch (err) {
|
|
789
|
-
console.warn(` ⚠ soul:${key} failed: ${err.message}`);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
console.log(`\n ${soulEntries.length} soul entries saved.`);
|
|
793
|
-
console.log(` Preview what an agent will see: flair bootstrap --agent ${agentId}`);
|
|
1694
|
+
// Seed agent - use REST API for local Harper, Ops API only when --ops-target is specified
|
|
1695
|
+
if (opts.opsTarget) {
|
|
1696
|
+
// Remote Harper via explicit ops target
|
|
1697
|
+
console.log(`Seeding agent '${agentId}' via operations API (--ops-target)...`);
|
|
1698
|
+
await seedAgentViaOpsApi(opts.opsTarget, agentId, pubKeyB64url, adminUser, adminPass);
|
|
794
1699
|
}
|
|
795
1700
|
else {
|
|
796
|
-
|
|
797
|
-
console.log(`
|
|
798
|
-
|
|
1701
|
+
// Local Harper - use REST API
|
|
1702
|
+
console.log(`Seeding agent '${agentId}' via REST API...`);
|
|
1703
|
+
await seedAgentViaRestApi(httpPort, agentId, pubKeyB64url, adminPass);
|
|
799
1704
|
}
|
|
1705
|
+
console.log(`Agent '${agentId}' registered ✓`);
|
|
800
1706
|
}
|
|
801
|
-
|
|
802
|
-
const reason = opts.skipSoul ? "--skip-soul" : "non-interactive";
|
|
803
|
-
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
804
|
-
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
805
|
-
}
|
|
806
|
-
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
807
|
-
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
808
|
-
// Auto-wire MCP config into ~/.claude.json if Claude Code is installed
|
|
809
|
-
const claudeJsonPath = join(homedir(), ".claude.json");
|
|
1707
|
+
// ── Step 4: Wire MCP clients ──
|
|
810
1708
|
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
811
|
-
const
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1709
|
+
const wiringResults = [];
|
|
1710
|
+
if (!noMcp && clientOpt !== "none") {
|
|
1711
|
+
const toWire = clientOpt === "all"
|
|
1712
|
+
? clients.filter(c => c.detected).map(c => c.id)
|
|
1713
|
+
: selectedClients.length > 0
|
|
1714
|
+
? selectedClients
|
|
1715
|
+
: clients.filter(c => c.detected).map(c => c.id);
|
|
1716
|
+
for (const clientId of toWire) {
|
|
1717
|
+
let result;
|
|
1718
|
+
switch (clientId) {
|
|
1719
|
+
case "claude-code":
|
|
1720
|
+
result = wireClaudeCode(mcpEnv, httpUrl);
|
|
1721
|
+
break;
|
|
1722
|
+
case "codex":
|
|
1723
|
+
result = wireCodex(mcpEnv);
|
|
1724
|
+
break;
|
|
1725
|
+
case "gemini":
|
|
1726
|
+
result = wireGemini(mcpEnv);
|
|
1727
|
+
break;
|
|
1728
|
+
case "cursor":
|
|
1729
|
+
result = wireCursor(mcpEnv);
|
|
1730
|
+
break;
|
|
1731
|
+
default: result = { ok: false, message: `Unknown client: ${clientId}` };
|
|
823
1732
|
}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
1733
|
+
wiringResults.push({ client: clientId, message: result.message });
|
|
1734
|
+
console.log(` ${result.ok ? "✓" : "✗"} ${result.message}`);
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
// ── Step 5: Smoke test the MCP server ──
|
|
1738
|
+
if (!opts.skipSmoke && !noMcp && clientOpt !== "none" && wiringResults.length > 0) {
|
|
1739
|
+
console.log("Smoke-testing MCP server...");
|
|
1740
|
+
try {
|
|
1741
|
+
// Launch flair-mcp and send initialize request over stdio
|
|
1742
|
+
const mcpProc = spawn("npx", ["-y", "@tpsdev-ai/flair-mcp"], {
|
|
1743
|
+
env: { ...process.env, FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl },
|
|
1744
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1745
|
+
timeout: 15_000,
|
|
1746
|
+
});
|
|
1747
|
+
// Send initialize request
|
|
1748
|
+
const initMsg = JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "0.1", capabilities: {}, clientInfo: { name: "flair-install", version: "1.0.0" } } });
|
|
1749
|
+
mcpProc.stdin.write(initMsg + "\n");
|
|
1750
|
+
mcpProc.stdin.end();
|
|
1751
|
+
let stdout = "";
|
|
1752
|
+
mcpProc.stdout.on("data", (d) => { stdout += d.toString(); });
|
|
1753
|
+
await new Promise((resolve, reject) => {
|
|
1754
|
+
mcpProc.on("exit", (code) => {
|
|
1755
|
+
if (code === 0 && stdout.length > 0) {
|
|
1756
|
+
resolve();
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
reject(new Error(`MCP server exited with code ${code}`));
|
|
1760
|
+
}
|
|
1761
|
+
});
|
|
1762
|
+
mcpProc.on("error", reject);
|
|
1763
|
+
setTimeout(() => { mcpProc.kill(); reject(new Error("MCP smoke test timed out")); }, 15_000);
|
|
1764
|
+
});
|
|
1765
|
+
// Check for a valid JSON-RPC response
|
|
1766
|
+
try {
|
|
1767
|
+
const lines = stdout.split("\n").filter(l => l.trim());
|
|
1768
|
+
for (const line of lines) {
|
|
1769
|
+
const parsed = JSON.parse(line);
|
|
1770
|
+
if (parsed.jsonrpc === "2.0" && parsed.id === 1 && !parsed.error) {
|
|
1771
|
+
console.log(" ✓ MCP server responded");
|
|
1772
|
+
break;
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
catch {
|
|
1777
|
+
console.log(" ⚠ MCP server responded but response could not be parsed");
|
|
830
1778
|
}
|
|
831
1779
|
}
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
console.log(`
|
|
1780
|
+
catch (err) {
|
|
1781
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1782
|
+
console.log(` ⚠ MCP smoke test failed: ${message}`);
|
|
1783
|
+
console.log(" Use --skip-smoke to bypass.");
|
|
835
1784
|
}
|
|
836
1785
|
}
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
1786
|
+
// ── Step 6: Summary ──
|
|
1787
|
+
console.log("");
|
|
1788
|
+
console.log("✓ Flair installed.");
|
|
1789
|
+
console.log(` Agent: ${agentId}`);
|
|
1790
|
+
if (existsSync(privKeyPath(agentId, keysDir))) {
|
|
1791
|
+
console.log(` Private key: ${privKeyPath(agentId, keysDir)}`);
|
|
840
1792
|
}
|
|
1793
|
+
console.log(` Local: ${httpUrl}`);
|
|
1794
|
+
console.log(` MCP: ${wiringResults.length > 0 ? wiringResults.map(r => r.client).join(", ") + " ✓ wired" : "none wired"}`);
|
|
1795
|
+
console.log("");
|
|
1796
|
+
console.log(` Try it: in Claude Code, ask the agent "what do you remember about me?"`);
|
|
841
1797
|
});
|
|
842
1798
|
// ─── flair agent ─────────────────────────────────────────────────────────────
|
|
843
1799
|
const agent = program.command("agent").description("Manage Flair agents");
|
|
@@ -891,7 +1847,13 @@ agent
|
|
|
891
1847
|
.option("--port <port>", "Harper HTTP port")
|
|
892
1848
|
.action(async (opts) => {
|
|
893
1849
|
const port = resolveHttpPort(opts);
|
|
894
|
-
|
|
1850
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
1851
|
+
const adminPassFromEnv = !opts.adminPass && (!!process.env.FLAIR_ADMIN_PASS || !!process.env.HDB_ADMIN_PASSWORD);
|
|
1852
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
1853
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
|
|
1854
|
+
"to keep secrets out of shell history.");
|
|
1855
|
+
}
|
|
1856
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
895
1857
|
if (adminPass) {
|
|
896
1858
|
// Use admin basic auth against ops API to list agents directly
|
|
897
1859
|
const opsPort = resolveOpsPort(opts);
|
|
@@ -899,18 +1861,37 @@ agent
|
|
|
899
1861
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
900
1862
|
method: "POST",
|
|
901
1863
|
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
902
|
-
body: JSON.stringify({ operation: "
|
|
1864
|
+
body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Agent", search_attribute: "id", search_type: "starts_with", search_value: "", get_attributes: ["id", "name", "createdAt"] }),
|
|
903
1865
|
});
|
|
904
1866
|
if (!res.ok) {
|
|
905
1867
|
const text = await res.text().catch(() => "");
|
|
906
1868
|
console.error(`Error: ${res.status} ${text}`);
|
|
907
1869
|
process.exit(1);
|
|
908
1870
|
}
|
|
909
|
-
|
|
1871
|
+
const agents = await res.json();
|
|
1872
|
+
agents.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1873
|
+
console.log(JSON.stringify(agents, null, 2));
|
|
910
1874
|
}
|
|
911
1875
|
else {
|
|
912
|
-
//
|
|
913
|
-
|
|
1876
|
+
// Localhost operator path: allow IDs-only enumeration without per-agent auth
|
|
1877
|
+
// This treats localhost as a trusted boundary for read-only public metadata
|
|
1878
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
1879
|
+
const res = await fetch(`${baseUrl}/Agent`, {
|
|
1880
|
+
headers: { "Content-Type": "application/json" },
|
|
1881
|
+
});
|
|
1882
|
+
if (!res.ok) {
|
|
1883
|
+
const text = await res.text().catch(() => "");
|
|
1884
|
+
console.error(`Error: ${res.status} ${text}`);
|
|
1885
|
+
process.exit(1);
|
|
1886
|
+
}
|
|
1887
|
+
const data = await res.json();
|
|
1888
|
+
// Filter to IDs-only to respect the localhost trust boundary
|
|
1889
|
+
if (Array.isArray(data)) {
|
|
1890
|
+
console.log(JSON.stringify(data.map((a) => ({ id: a.id, name: a.name, createdAt: a.createdAt })), null, 2));
|
|
1891
|
+
}
|
|
1892
|
+
else {
|
|
1893
|
+
console.log(JSON.stringify(data, null, 2));
|
|
1894
|
+
}
|
|
914
1895
|
}
|
|
915
1896
|
});
|
|
916
1897
|
agent
|
|
@@ -927,6 +1908,12 @@ agent
|
|
|
927
1908
|
.action(async (id, opts) => {
|
|
928
1909
|
const httpPort = resolveHttpPort(opts);
|
|
929
1910
|
const opsPort = resolveOpsPort(opts);
|
|
1911
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
1912
|
+
const adminPassFromEnv = !opts.adminPass && !!process.env.FLAIR_ADMIN_PASS;
|
|
1913
|
+
if (shouldShowInlineSecretWarning(opts.adminPass, adminPassFromEnv, new Set(["--admin-pass"]), "--admin-pass")) {
|
|
1914
|
+
console.error("warning: --admin-pass passed inline. Consider --admin-pass-from <file> or FLAIR_ADMIN_PASS env " +
|
|
1915
|
+
"to keep secrets out of shell history.");
|
|
1916
|
+
}
|
|
930
1917
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
931
1918
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
932
1919
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
@@ -1204,14 +2191,20 @@ principal
|
|
|
1204
2191
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1205
2192
|
process.exit(1);
|
|
1206
2193
|
}
|
|
1207
|
-
const kindFilter = opts.kind ? ` WHERE kind = '${opts.kind}'` : "";
|
|
1208
2194
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
2195
|
+
const conditions = opts.kind
|
|
2196
|
+
? [{ search_attribute: "kind", search_type: "equals", search_value: opts.kind }]
|
|
2197
|
+
: [{ search_attribute: "id", search_type: "starts_with", search_value: "" }];
|
|
1209
2198
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1210
2199
|
method: "POST",
|
|
1211
2200
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1212
2201
|
body: JSON.stringify({
|
|
1213
|
-
operation: "
|
|
1214
|
-
|
|
2202
|
+
operation: "search_by_conditions",
|
|
2203
|
+
schema: "flair",
|
|
2204
|
+
table: "Agent",
|
|
2205
|
+
operator: "and",
|
|
2206
|
+
conditions,
|
|
2207
|
+
get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
|
|
1215
2208
|
}),
|
|
1216
2209
|
});
|
|
1217
2210
|
if (!res.ok) {
|
|
@@ -1220,6 +2213,7 @@ principal
|
|
|
1220
2213
|
process.exit(1);
|
|
1221
2214
|
}
|
|
1222
2215
|
const records = await res.json();
|
|
2216
|
+
records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1223
2217
|
if (records.length === 0) {
|
|
1224
2218
|
console.log("No principals found.");
|
|
1225
2219
|
return;
|
|
@@ -1382,8 +2376,13 @@ idp
|
|
|
1382
2376
|
method: "POST",
|
|
1383
2377
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1384
2378
|
body: JSON.stringify({
|
|
1385
|
-
operation: "
|
|
1386
|
-
|
|
2379
|
+
operation: "search_by_value",
|
|
2380
|
+
schema: "flair",
|
|
2381
|
+
table: "IdpConfig",
|
|
2382
|
+
search_attribute: "id",
|
|
2383
|
+
search_type: "starts_with",
|
|
2384
|
+
search_value: "",
|
|
2385
|
+
get_attributes: ["id", "name", "issuer", "requiredDomain", "jitProvision", "enabled", "createdAt"],
|
|
1387
2386
|
}),
|
|
1388
2387
|
});
|
|
1389
2388
|
if (!res.ok) {
|
|
@@ -1392,6 +2391,7 @@ idp
|
|
|
1392
2391
|
process.exit(1);
|
|
1393
2392
|
}
|
|
1394
2393
|
const records = await res.json();
|
|
2394
|
+
records.sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""));
|
|
1395
2395
|
if (records.length === 0) {
|
|
1396
2396
|
console.log("No IdPs configured.");
|
|
1397
2397
|
return;
|
|
@@ -1443,18 +2443,14 @@ idp
|
|
|
1443
2443
|
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required");
|
|
1444
2444
|
process.exit(1);
|
|
1445
2445
|
}
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
});
|
|
1452
|
-
const records = await res.json();
|
|
1453
|
-
if (records.length === 0) {
|
|
2446
|
+
let cfg;
|
|
2447
|
+
try {
|
|
2448
|
+
cfg = await api("GET", `/IdpConfig/${id}`);
|
|
2449
|
+
}
|
|
2450
|
+
catch {
|
|
1454
2451
|
console.error(`IdP '${id}' not found`);
|
|
1455
2452
|
process.exit(1);
|
|
1456
2453
|
}
|
|
1457
|
-
const cfg = records[0];
|
|
1458
2454
|
console.log(`Testing IdP: ${cfg.name} (${cfg.issuer})`);
|
|
1459
2455
|
console.log(` JWKS endpoint: ${cfg.jwksUri}`);
|
|
1460
2456
|
try {
|
|
@@ -1468,7 +2464,8 @@ idp
|
|
|
1468
2464
|
console.log(` ✅ JWKS reachable — ${keyCount} key(s) found`);
|
|
1469
2465
|
}
|
|
1470
2466
|
catch (err) {
|
|
1471
|
-
|
|
2467
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2468
|
+
console.error(` ❌ JWKS fetch error: ${message}`);
|
|
1472
2469
|
process.exit(1);
|
|
1473
2470
|
}
|
|
1474
2471
|
});
|
|
@@ -1581,7 +2578,7 @@ async function loadInstanceSecretKey(instanceId, opts) {
|
|
|
1581
2578
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
1582
2579
|
method: "POST",
|
|
1583
2580
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1584
|
-
body: JSON.stringify({ operation: "
|
|
2581
|
+
body: JSON.stringify({ operation: "search_by_value", schema: "flair", table: "Instance", search_attribute: "id", search_type: "equals", search_value: instanceId, get_attributes: ["*"] }),
|
|
1585
2582
|
});
|
|
1586
2583
|
if (res.ok) {
|
|
1587
2584
|
const rows = await res.json();
|
|
@@ -1607,14 +2604,18 @@ federation
|
|
|
1607
2604
|
.command("status")
|
|
1608
2605
|
.description("Show federation status and peer connections")
|
|
1609
2606
|
.option("--port <port>", "Harper HTTP port")
|
|
2607
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2608
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
1610
2609
|
.action(async (opts) => {
|
|
2610
|
+
const target = resolveTarget(opts);
|
|
2611
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
1611
2612
|
try {
|
|
1612
|
-
const instance = await api("GET", "/FederationInstance");
|
|
2613
|
+
const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
1613
2614
|
console.log(`Instance: ${instance.id} (${instance.role})`);
|
|
1614
2615
|
console.log(`Public key: ${instance.publicKey}`);
|
|
1615
2616
|
console.log(`Status: ${instance.status}`);
|
|
1616
2617
|
console.log();
|
|
1617
|
-
const { peers } = await api("GET", "/FederationPeers");
|
|
2618
|
+
const { peers } = await api("GET", "/FederationPeers", undefined, baseUrl ? { baseUrl } : undefined);
|
|
1618
2619
|
if (peers.length === 0) {
|
|
1619
2620
|
console.log("No peers configured. Use 'flair federation pair' to connect to a hub.");
|
|
1620
2621
|
}
|
|
@@ -1638,22 +2639,38 @@ federation
|
|
|
1638
2639
|
.option("--port <port>", "Harper HTTP port")
|
|
1639
2640
|
.option("--admin-pass <pass>", "Admin password")
|
|
1640
2641
|
.option("--ops-port <port>", "Harper operations API port")
|
|
1641
|
-
.option("--token <token>", "One-time pairing token from hub admin")
|
|
2642
|
+
.option("--token <token>", "One-time pairing token from hub admin (env: FLAIR_PAIRING_TOKEN)")
|
|
2643
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2644
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
1642
2645
|
.action(async (hubUrl, opts) => {
|
|
2646
|
+
const target = resolveTarget(opts);
|
|
2647
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
1643
2648
|
try {
|
|
1644
|
-
const instance = await api("GET", "/FederationInstance");
|
|
1645
|
-
console.log(
|
|
2649
|
+
const instance = await api("GET", "/FederationInstance", undefined, baseUrl ? { baseUrl } : undefined);
|
|
2650
|
+
console.log(`${target ? "Remote" : "Local"} instance: ${instance.id} (${instance.role})`);
|
|
1646
2651
|
if (!opts.token) {
|
|
1647
2652
|
console.error("Error: --token is required. Ask the hub admin to run 'flair federation token' and provide the token.");
|
|
1648
2653
|
process.exit(1);
|
|
1649
2654
|
}
|
|
1650
|
-
//
|
|
2655
|
+
// Warning: inline token may leak to shell history.
|
|
2656
|
+
// fromEnv is true ONLY when the resolved value came from env (no inline override).
|
|
2657
|
+
const tokenFromEnv = !opts.token && !!process.env.FLAIR_PAIRING_TOKEN;
|
|
2658
|
+
if (shouldShowInlineSecretWarning(opts.token, tokenFromEnv, new Set(["--token"]), "--token")) {
|
|
2659
|
+
console.error("warning: --token passed inline. Consider --token-from <file> or FLAIR_PAIRING_TOKEN env " +
|
|
2660
|
+
"to keep secrets out of shell history.");
|
|
2661
|
+
}
|
|
2662
|
+
// Load secret key and sign the pairing request. The pairing token is
|
|
2663
|
+
// included in the signed body (not in an Authorization header) because
|
|
2664
|
+
// Harper's auth layer claims any "Bearer X" Authorization header for
|
|
2665
|
+
// itself and 401s before our resource ever runs.
|
|
1651
2666
|
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
2667
|
+
// Env var fallback for --token: FLAIR_PAIRING_TOKEN
|
|
2668
|
+
const pairingToken = opts.token || process.env.FLAIR_PAIRING_TOKEN;
|
|
1652
2669
|
const pairBody = {
|
|
1653
2670
|
instanceId: instance.id,
|
|
1654
2671
|
publicKey: instance.publicKey,
|
|
1655
2672
|
role: "spoke",
|
|
1656
|
-
pairingToken
|
|
2673
|
+
pairingToken,
|
|
1657
2674
|
};
|
|
1658
2675
|
const signedBody = signRequestBody(pairBody, secretKey);
|
|
1659
2676
|
const res = await fetch(`${hubUrl}/FederationPair`, {
|
|
@@ -1668,12 +2685,12 @@ federation
|
|
|
1668
2685
|
}
|
|
1669
2686
|
const result = await res.json();
|
|
1670
2687
|
console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
|
|
1671
|
-
// Record the hub as our peer locally
|
|
1672
|
-
const opsPort = resolveOpsPort(opts);
|
|
2688
|
+
// Record the hub as our peer — locally or remotely depending on --target
|
|
1673
2689
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1674
2690
|
if (adminPass) {
|
|
1675
2691
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1676
|
-
|
|
2692
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
2693
|
+
await fetch(`${opsEndpoint}/`, {
|
|
1677
2694
|
method: "POST",
|
|
1678
2695
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1679
2696
|
body: JSON.stringify({
|
|
@@ -1687,6 +2704,7 @@ federation
|
|
|
1687
2704
|
updatedAt: new Date().toISOString(),
|
|
1688
2705
|
}],
|
|
1689
2706
|
}),
|
|
2707
|
+
signal: AbortSignal.timeout(10_000),
|
|
1690
2708
|
});
|
|
1691
2709
|
}
|
|
1692
2710
|
}
|
|
@@ -1702,16 +2720,20 @@ federation
|
|
|
1702
2720
|
.option("--admin-pass <pass>", "Admin password")
|
|
1703
2721
|
.option("--ops-port <port>", "Harper operations API port")
|
|
1704
2722
|
.option("--ttl <minutes>", "Token TTL in minutes (default: 60)", "60")
|
|
2723
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2724
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
1705
2725
|
.action(async (opts) => {
|
|
2726
|
+
const target = resolveTarget(opts);
|
|
2727
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
1706
2728
|
try {
|
|
1707
2729
|
const { randomBytes } = await import("node:crypto");
|
|
1708
2730
|
const token = randomBytes(24).toString("base64url");
|
|
1709
2731
|
const ttlMinutes = parseInt(opts.ttl, 10) || 60;
|
|
1710
2732
|
const expiresAt = new Date(Date.now() + ttlMinutes * 60 * 1000).toISOString();
|
|
1711
|
-
const
|
|
2733
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
1712
2734
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1713
2735
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1714
|
-
await fetch(
|
|
2736
|
+
const opsRes = await fetch(`${opsEndpoint}/`, {
|
|
1715
2737
|
method: "POST",
|
|
1716
2738
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1717
2739
|
body: JSON.stringify({
|
|
@@ -1722,7 +2744,12 @@ federation
|
|
|
1722
2744
|
expiresAt,
|
|
1723
2745
|
}],
|
|
1724
2746
|
}),
|
|
2747
|
+
signal: AbortSignal.timeout(10_000),
|
|
1725
2748
|
});
|
|
2749
|
+
if (!opsRes.ok) {
|
|
2750
|
+
const detail = await opsRes.text().catch(() => "");
|
|
2751
|
+
throw new Error(`Failed to persist pairing token (${opsRes.status}): ${detail || "no body"}`);
|
|
2752
|
+
}
|
|
1726
2753
|
console.log(`Pairing token (expires in ${ttlMinutes}m):`);
|
|
1727
2754
|
console.log(` ${token}`);
|
|
1728
2755
|
console.log(`\nGive this to the spoke admin to run:`);
|
|
@@ -1733,46 +2760,48 @@ federation
|
|
|
1733
2760
|
process.exit(1);
|
|
1734
2761
|
}
|
|
1735
2762
|
});
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
.option("--admin-pass <pass>", "Admin password")
|
|
1741
|
-
.option("--ops-port <port>", "Harper operations API port")
|
|
1742
|
-
.action(async (opts) => {
|
|
2763
|
+
export async function runFederationSyncOnce(opts) {
|
|
2764
|
+
const target = resolveTarget(opts);
|
|
2765
|
+
const baseUrl = target ? target.replace(/\/$/, "") : undefined;
|
|
2766
|
+
const apiOpts = baseUrl ? { baseUrl } : undefined;
|
|
1743
2767
|
try {
|
|
1744
|
-
const { peers } = await api("GET", "/FederationPeers");
|
|
2768
|
+
const { peers } = await api("GET", "/FederationPeers", undefined, apiOpts);
|
|
1745
2769
|
const hub = peers.find((p) => p.role === "hub" && p.status !== "revoked");
|
|
1746
2770
|
if (!hub) {
|
|
1747
|
-
|
|
1748
|
-
process.exit(1);
|
|
2771
|
+
return { pushed: 0, skipped: 0, error: new Error("No hub peer configured. Use 'flair federation pair' first.") };
|
|
1749
2772
|
}
|
|
1750
2773
|
console.log(`Syncing to hub: ${hub.id}...`);
|
|
1751
2774
|
const since = hub.lastSyncAt ?? new Date(0).toISOString();
|
|
1752
|
-
const
|
|
2775
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
1753
2776
|
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "";
|
|
1754
2777
|
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
1755
2778
|
const tables = ["Memory", "Soul", "Agent", "Relationship"];
|
|
1756
2779
|
const records = [];
|
|
1757
|
-
const instance = await api("GET", "/FederationInstance");
|
|
2780
|
+
const instance = await api("GET", "/FederationInstance", undefined, apiOpts);
|
|
1758
2781
|
for (const table of tables) {
|
|
2782
|
+
let res;
|
|
1759
2783
|
try {
|
|
1760
|
-
|
|
2784
|
+
res = await fetch(`${opsEndpoint}/`, {
|
|
1761
2785
|
method: "POST",
|
|
1762
2786
|
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
1763
|
-
body: JSON.stringify({ operation: "
|
|
2787
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since }], get_attributes: ["*"] }),
|
|
2788
|
+
signal: AbortSignal.timeout(15_000),
|
|
1764
2789
|
});
|
|
1765
|
-
if (res.ok) {
|
|
1766
|
-
for (const row of await res.json()) {
|
|
1767
|
-
records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1770
2790
|
}
|
|
1771
|
-
catch {
|
|
2791
|
+
catch (err) {
|
|
2792
|
+
return { pushed: 0, skipped: 0, error: err instanceof Error ? err : new Error(String(err)) };
|
|
2793
|
+
}
|
|
2794
|
+
if (!res.ok) {
|
|
2795
|
+
const text = await res.text().catch(() => "");
|
|
2796
|
+
return { pushed: 0, skipped: 0, error: new Error(`SQL query failed (${res.status}): ${text}`) };
|
|
2797
|
+
}
|
|
2798
|
+
for (const row of await res.json()) {
|
|
2799
|
+
records.push({ table, id: row.id, data: row, updatedAt: row.updatedAt ?? row.createdAt, originatorInstanceId: instance.id });
|
|
2800
|
+
}
|
|
1772
2801
|
}
|
|
1773
2802
|
if (records.length === 0) {
|
|
1774
2803
|
console.log("No changes since last sync.");
|
|
1775
|
-
return;
|
|
2804
|
+
return { pushed: 0, skipped: 0 };
|
|
1776
2805
|
}
|
|
1777
2806
|
// Sign the sync request with our instance key
|
|
1778
2807
|
const secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
@@ -1784,17 +2813,78 @@ federation
|
|
|
1784
2813
|
body: JSON.stringify(signedSyncBody),
|
|
1785
2814
|
});
|
|
1786
2815
|
if (!syncRes.ok) {
|
|
1787
|
-
|
|
1788
|
-
|
|
2816
|
+
const text = await syncRes.text().catch(() => "");
|
|
2817
|
+
return { pushed: 0, skipped: 0, error: new Error(`Sync failed: ${syncRes.status} ${text}`) };
|
|
1789
2818
|
}
|
|
1790
2819
|
const result = await syncRes.json();
|
|
1791
2820
|
console.log(`✅ Synced ${result.merged} records (${result.skipped} skipped) in ${result.durationMs}ms`);
|
|
2821
|
+
return { pushed: result.merged ?? 0, skipped: result.skipped ?? 0 };
|
|
1792
2822
|
}
|
|
1793
2823
|
catch (err) {
|
|
1794
|
-
|
|
2824
|
+
return { pushed: 0, skipped: 0, error: err instanceof Error ? err : new Error(String(err)) };
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
federation
|
|
2828
|
+
.command("sync")
|
|
2829
|
+
.description("Push local changes to the hub")
|
|
2830
|
+
.option("--port <port>", "Harper HTTP port")
|
|
2831
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
2832
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
2833
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
2834
|
+
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
2835
|
+
.action(async (opts) => {
|
|
2836
|
+
const r = await runFederationSyncOnce(opts);
|
|
2837
|
+
if (r.error) {
|
|
2838
|
+
console.error(`Error: ${r.error.message}`);
|
|
1795
2839
|
process.exit(1);
|
|
1796
2840
|
}
|
|
1797
2841
|
});
|
|
2842
|
+
export async function runFederationWatch(opts) {
|
|
2843
|
+
const intervalMs = Math.max(5, parseFloat(opts.interval) || 30) * 1000;
|
|
2844
|
+
let stopped = false;
|
|
2845
|
+
const stop = () => { stopped = true; };
|
|
2846
|
+
process.on("SIGINT", stop);
|
|
2847
|
+
process.on("SIGTERM", stop);
|
|
2848
|
+
console.log(`flair federation watch — interval ${intervalMs / 1000}s. Ctrl-C to stop.`);
|
|
2849
|
+
try {
|
|
2850
|
+
while (!stopped) {
|
|
2851
|
+
try {
|
|
2852
|
+
const r = await runFederationSyncOnce(opts);
|
|
2853
|
+
const ts = new Date().toISOString();
|
|
2854
|
+
if (r.error)
|
|
2855
|
+
console.error(`[${ts}] sync error: ${r.error.message}`);
|
|
2856
|
+
else
|
|
2857
|
+
console.log(`[${ts}] sync ok — pushed ${r.pushed}, skipped ${r.skipped}`);
|
|
2858
|
+
}
|
|
2859
|
+
catch (err) {
|
|
2860
|
+
console.error(`[${new Date().toISOString()}] watch loop error: ${err.message}`);
|
|
2861
|
+
}
|
|
2862
|
+
// Sleep but exit early on signal
|
|
2863
|
+
const t = Date.now();
|
|
2864
|
+
while (!stopped && Date.now() - t < intervalMs) {
|
|
2865
|
+
const remaining = intervalMs - (Date.now() - t);
|
|
2866
|
+
await new Promise((r) => setTimeout(r, Math.min(250, remaining)));
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
}
|
|
2870
|
+
finally {
|
|
2871
|
+
process.removeListener("SIGINT", stop);
|
|
2872
|
+
process.removeListener("SIGTERM", stop);
|
|
2873
|
+
}
|
|
2874
|
+
console.log("flair federation watch — stopped.");
|
|
2875
|
+
}
|
|
2876
|
+
federation
|
|
2877
|
+
.command("watch")
|
|
2878
|
+
.description("Run federation sync in a loop (foreground daemon)")
|
|
2879
|
+
.option("--interval <seconds>", "Seconds between syncs", "30")
|
|
2880
|
+
.option("--port <port>", "Harper HTTP port")
|
|
2881
|
+
.option("--admin-pass <pass>", "Admin password")
|
|
2882
|
+
.option("--ops-port <port>", "Harper operations API port")
|
|
2883
|
+
.option("--target <url>", "Remote Flair URL")
|
|
2884
|
+
.option("--ops-target <url>", "Explicit ops API URL")
|
|
2885
|
+
.action(async (opts) => {
|
|
2886
|
+
await runFederationWatch(opts);
|
|
2887
|
+
});
|
|
1798
2888
|
// ─── flair rem ───────────────────────────────────────────────────────────────
|
|
1799
2889
|
// Memory hygiene and reflection: light (NREM), rapid (REM), restorative (deep).
|
|
1800
2890
|
const rem = program.command("rem").description("Memory hygiene and reflection");
|
|
@@ -2057,7 +3147,8 @@ function oauthDetailLines(o) {
|
|
|
2057
3147
|
}
|
|
2058
3148
|
async function fetchHealthDetail(opts) {
|
|
2059
3149
|
const port = resolveHttpPort(opts);
|
|
2060
|
-
|
|
3150
|
+
// --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
|
|
3151
|
+
const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
|
|
2061
3152
|
let healthy = false;
|
|
2062
3153
|
let healthData = null;
|
|
2063
3154
|
try {
|
|
@@ -2114,6 +3205,7 @@ const statusCmd = program
|
|
|
2114
3205
|
.description("Show Flair instance status, memory stats, and agent info")
|
|
2115
3206
|
.option("--port <port>", "Harper HTTP port")
|
|
2116
3207
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
3208
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET; alias for --url)")
|
|
2117
3209
|
.option("--json", "Output as JSON")
|
|
2118
3210
|
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
2119
3211
|
.action(async (opts) => {
|
|
@@ -2142,17 +3234,42 @@ const statusCmd = program
|
|
|
2142
3234
|
const agents = healthData?.agents;
|
|
2143
3235
|
const memories = healthData?.memories;
|
|
2144
3236
|
const warnings = Array.isArray(healthData?.warnings) ? healthData.warnings : [];
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
3237
|
+
// Scope warnings to the filtered agent if --agent is set
|
|
3238
|
+
const scopedWarnings = opts.agent && healthData?.agents?.perAgent
|
|
3239
|
+
? warnings.filter((w) => {
|
|
3240
|
+
// Hash-fallback warnings contain agent-specific counts
|
|
3241
|
+
if (w.message.includes("hash-fallback")) {
|
|
3242
|
+
const match = w.message.match(/\b(\d+)\/(\d+) \((\d+)%\)/);
|
|
3243
|
+
if (match) {
|
|
3244
|
+
const hashCount = parseInt(match[1]);
|
|
3245
|
+
const totalCount = parseInt(match[2]);
|
|
3246
|
+
const agentRow = healthData.agents.perAgent.find((r) => r.id === opts.agent);
|
|
3247
|
+
if (agentRow && agentRow.hashFallback === hashCount && agentRow.memoryCount === totalCount) {
|
|
3248
|
+
return true;
|
|
3249
|
+
}
|
|
3250
|
+
return false;
|
|
3251
|
+
}
|
|
3252
|
+
}
|
|
3253
|
+
// Mixed-model warnings are fleet-wide; keep them
|
|
3254
|
+
if (w.message.includes("multiple embedding models"))
|
|
3255
|
+
return true;
|
|
3256
|
+
// Federation warnings are fleet-wide; keep them
|
|
3257
|
+
if (w.message.includes("federation"))
|
|
3258
|
+
return true;
|
|
3259
|
+
// REM warnings are fleet-wide; keep them
|
|
3260
|
+
if (w.message.includes("REM") || w.message.includes("nightly"))
|
|
3261
|
+
return true;
|
|
3262
|
+
// Default: keep fleet-wide warnings
|
|
3263
|
+
return !w.message.includes(opts.agent);
|
|
3264
|
+
})
|
|
3265
|
+
: warnings;
|
|
3266
|
+
const hasWarn = scopedWarnings.some((w) => w.level === "warn");
|
|
2150
3267
|
const headerIcon = hasWarn ? "🟡" : "🟢";
|
|
2151
3268
|
console.log(`Flair v${__pkgVersion} — ${headerIcon} running${pid ? ` (PID ${pid}` : ""}${uptimeStr ? `, uptime ${uptimeStr})` : pid ? ")" : ""}`);
|
|
2152
3269
|
console.log(` URL: ${baseUrl}`);
|
|
2153
|
-
if (
|
|
2154
|
-
console.log(`\n⚠ Warnings: ${
|
|
2155
|
-
for (const w of
|
|
3270
|
+
if (scopedWarnings.length > 0) {
|
|
3271
|
+
console.log(`\n⚠ Warnings: ${scopedWarnings.length}`);
|
|
3272
|
+
for (const w of scopedWarnings)
|
|
2156
3273
|
console.log(` • ${w.level} ${w.message}`);
|
|
2157
3274
|
}
|
|
2158
3275
|
if (memories) {
|
|
@@ -2248,6 +3365,9 @@ const statusCmd = program
|
|
|
2248
3365
|
if (f.pendingTokens > 0)
|
|
2249
3366
|
console.log(` Pairing: ${f.pendingTokens} unconsumed token(s)`);
|
|
2250
3367
|
}
|
|
3368
|
+
else {
|
|
3369
|
+
console.log("\nFederation: not configured");
|
|
3370
|
+
}
|
|
2251
3371
|
if (healthData?.oauth) {
|
|
2252
3372
|
const lines = oauthSummaryLines(healthData.oauth);
|
|
2253
3373
|
for (const line of lines)
|
|
@@ -2263,6 +3383,9 @@ const statusCmd = program
|
|
|
2263
3383
|
if (b.lastExport)
|
|
2264
3384
|
console.log(` Last export: ${relativeTime(b.lastExport)}`);
|
|
2265
3385
|
}
|
|
3386
|
+
else {
|
|
3387
|
+
console.log("\nBridges: none installed");
|
|
3388
|
+
}
|
|
2266
3389
|
if (healthData?.disk) {
|
|
2267
3390
|
const d = healthData.disk;
|
|
2268
3391
|
console.log("\nDisk:");
|
|
@@ -2270,8 +3393,8 @@ const statusCmd = program
|
|
|
2270
3393
|
console.log(` Snapshots: ${d.snapshotDir} — ${humanBytes(d.snapshotBytes ?? 0)}`);
|
|
2271
3394
|
}
|
|
2272
3395
|
console.log("");
|
|
2273
|
-
if (
|
|
2274
|
-
console.log(` Health: ⚠ ${
|
|
3396
|
+
if (scopedWarnings.length > 0)
|
|
3397
|
+
console.log(` Health: ⚠ ${scopedWarnings.length} warning(s)`);
|
|
2275
3398
|
else
|
|
2276
3399
|
console.log(` Health: ✅ all checks passing`);
|
|
2277
3400
|
});
|
|
@@ -2806,7 +3929,7 @@ program
|
|
|
2806
3929
|
program
|
|
2807
3930
|
.command("reembed")
|
|
2808
3931
|
.description("Re-generate embeddings for memories with stale or missing model tags")
|
|
2809
|
-
.
|
|
3932
|
+
.option("--agent <id>", "Agent ID to re-embed memories for (defaults to all agents with stale rows)")
|
|
2810
3933
|
.option("--stale-only", "Only re-embed memories with mismatched model tag")
|
|
2811
3934
|
.option("--dry-run", "Show count without modifying")
|
|
2812
3935
|
.option("--port <port>", "Harper HTTP port")
|
|
@@ -2821,13 +3944,100 @@ program
|
|
|
2821
3944
|
const batchSize = Number(opts.batchSize);
|
|
2822
3945
|
const delayMs = Number(opts.delayMs);
|
|
2823
3946
|
const currentModel = process.env.FLAIR_EMBEDDING_MODEL ?? "nomic-embed-text-v1.5-Q4_K_M";
|
|
2824
|
-
|
|
3947
|
+
if (agentId) {
|
|
3948
|
+
console.log(`Re-embedding memories for agent: ${agentId}`);
|
|
3949
|
+
}
|
|
3950
|
+
else {
|
|
3951
|
+
console.log("Re-embedding memories for all agents with stale rows");
|
|
3952
|
+
}
|
|
2825
3953
|
console.log(`Current model: ${currentModel}`);
|
|
2826
3954
|
if (staleOnly)
|
|
2827
3955
|
console.log("Mode: stale-only (skipping up-to-date memories)");
|
|
2828
3956
|
if (dryRun)
|
|
2829
3957
|
console.log("Mode: dry-run (no modifications)");
|
|
2830
3958
|
console.log("");
|
|
3959
|
+
// When no agent specified, use admin auth to fetch all memories
|
|
3960
|
+
if (!agentId) {
|
|
3961
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
3962
|
+
if (!adminPass) {
|
|
3963
|
+
console.error("❌ Admin password required when --agent is not specified (set FLAIR_ADMIN_PASS or HDB_ADMIN_PASSWORD)");
|
|
3964
|
+
process.exit(1);
|
|
3965
|
+
}
|
|
3966
|
+
// Fetch all memories with admin auth
|
|
3967
|
+
const searchRes = await fetch(`${baseUrl}/SemanticSearch`, {
|
|
3968
|
+
method: "POST",
|
|
3969
|
+
headers: {
|
|
3970
|
+
"Content-Type": "application/json",
|
|
3971
|
+
Authorization: `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`,
|
|
3972
|
+
},
|
|
3973
|
+
body: JSON.stringify({ limit: 10000 }),
|
|
3974
|
+
});
|
|
3975
|
+
if (!searchRes.ok) {
|
|
3976
|
+
console.error(`❌ Failed to fetch memories: ${searchRes.status}`);
|
|
3977
|
+
process.exit(1);
|
|
3978
|
+
}
|
|
3979
|
+
const data = await searchRes.json();
|
|
3980
|
+
const allMemories = data.results ?? [];
|
|
3981
|
+
// Group by agentId
|
|
3982
|
+
const byAgent = new Map();
|
|
3983
|
+
for (const m of allMemories) {
|
|
3984
|
+
if (!m.content)
|
|
3985
|
+
continue;
|
|
3986
|
+
if (staleOnly && m.embeddingModel === currentModel)
|
|
3987
|
+
continue;
|
|
3988
|
+
const agent = m.agentId || "unknown";
|
|
3989
|
+
if (!byAgent.has(agent))
|
|
3990
|
+
byAgent.set(agent, []);
|
|
3991
|
+
byAgent.get(agent).push(m);
|
|
3992
|
+
}
|
|
3993
|
+
// Process each agent
|
|
3994
|
+
let totalProcessed = 0;
|
|
3995
|
+
let totalErrors = 0;
|
|
3996
|
+
const agentCount = byAgent.size;
|
|
3997
|
+
let agentIndex = 0;
|
|
3998
|
+
for (const [agent, memories] of byAgent) {
|
|
3999
|
+
agentIndex++;
|
|
4000
|
+
console.log(`\nAgent ${agentIndex}/${agentCount}: ${agent}`);
|
|
4001
|
+
console.log(` Memories to re-embed: ${memories.length}`);
|
|
4002
|
+
const keysDir = defaultKeysDir();
|
|
4003
|
+
const privPath = privKeyPath(agent, keysDir);
|
|
4004
|
+
if (!existsSync(privPath)) {
|
|
4005
|
+
console.error(` ❌ Key not found: ${privPath} — skipping`);
|
|
4006
|
+
continue;
|
|
4007
|
+
}
|
|
4008
|
+
if (dryRun)
|
|
4009
|
+
continue;
|
|
4010
|
+
let processed = 0;
|
|
4011
|
+
let errors = 0;
|
|
4012
|
+
for (let i = 0; i < memories.length; i += batchSize) {
|
|
4013
|
+
const batch = memories.slice(i, i + batchSize);
|
|
4014
|
+
for (const memory of batch) {
|
|
4015
|
+
try {
|
|
4016
|
+
const updateRes = await authFetch(baseUrl, agent, privPath, "PUT", `/Memory/${memory.id}`, {
|
|
4017
|
+
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || agent,
|
|
4018
|
+
});
|
|
4019
|
+
if (updateRes.ok)
|
|
4020
|
+
processed++;
|
|
4021
|
+
else
|
|
4022
|
+
errors++;
|
|
4023
|
+
}
|
|
4024
|
+
catch {
|
|
4025
|
+
errors++;
|
|
4026
|
+
}
|
|
4027
|
+
}
|
|
4028
|
+
const pct = Math.round(((i + batch.length) / memories.length) * 100);
|
|
4029
|
+
process.stdout.write(` \r Re-embedded ${processed}/${memories.length} (${pct}%)${errors > 0 ? ` [${errors} errors]` : ""}`);
|
|
4030
|
+
if (i + batchSize < memories.length)
|
|
4031
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
4032
|
+
}
|
|
4033
|
+
console.log(`\n ✅ Agent ${agent}: ${processed} updated, ${errors} errors`);
|
|
4034
|
+
totalProcessed += processed;
|
|
4035
|
+
totalErrors += errors;
|
|
4036
|
+
}
|
|
4037
|
+
console.log(`\n\n✅ Re-embedding complete: ${totalProcessed} updated, ${totalErrors} errors`);
|
|
4038
|
+
return;
|
|
4039
|
+
}
|
|
4040
|
+
// Original single-agent path
|
|
2831
4041
|
const keysDir = defaultKeysDir();
|
|
2832
4042
|
const privPath = privKeyPath(agentId, keysDir);
|
|
2833
4043
|
if (!existsSync(privPath)) {
|
|
@@ -2869,7 +4079,7 @@ program
|
|
|
2869
4079
|
for (const memory of batch) {
|
|
2870
4080
|
try {
|
|
2871
4081
|
const updateRes = await authFetch(baseUrl, agentId, privPath, "PUT", `/Memory/${memory.id}`, {
|
|
2872
|
-
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined,
|
|
4082
|
+
id: memory.id, content: memory.content, embedding: undefined, embeddingModel: undefined, agentId: memory.agentId || opts.agent,
|
|
2873
4083
|
});
|
|
2874
4084
|
if (updateRes.ok)
|
|
2875
4085
|
processed++;
|
|
@@ -2919,7 +4129,8 @@ program
|
|
|
2919
4129
|
}
|
|
2920
4130
|
}
|
|
2921
4131
|
catch (e) {
|
|
2922
|
-
|
|
4132
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
4133
|
+
console.log(` ${red("FAIL")} ${name}: ${message?.slice(0, 120)}`);
|
|
2923
4134
|
failed++;
|
|
2924
4135
|
}
|
|
2925
4136
|
};
|
|
@@ -2963,6 +4174,13 @@ program
|
|
|
2963
4174
|
process.exit(1);
|
|
2964
4175
|
});
|
|
2965
4176
|
// ─── flair deploy ─────────────────────────────────────────────────────────────
|
|
4177
|
+
// NOTE on env-var naming for `flair deploy`: the FABRIC_* env vars below intentionally
|
|
4178
|
+
// do NOT carry the FLAIR_ prefix that the rest of the CLI uses (FLAIR_ADMIN_PASS,
|
|
4179
|
+
// FLAIR_TARGET, FLAIR_PAIRING_TOKEN, etc.). FABRIC_* credentials are shared with
|
|
4180
|
+
// the broader TPS tooling stack — multiple tools deploy to the same Harper Fabric
|
|
4181
|
+
// org/cluster with the same auth, and demanding a tool-specific prefix would force
|
|
4182
|
+
// operators to maintain duplicated env vars. Per Kern review on PR #306: the
|
|
4183
|
+
// inconsistency is deliberate, document it here so the next agent doesn't "fix" it.
|
|
2966
4184
|
program
|
|
2967
4185
|
.command("deploy")
|
|
2968
4186
|
.description("Deploy Flair as a component to a remote Harper Fabric cluster")
|
|
@@ -3544,7 +4762,7 @@ bridge
|
|
|
3544
4762
|
const cwd = opts.cwd ?? srcArg ?? process.cwd();
|
|
3545
4763
|
const { discover } = await import("./bridges/discover.js");
|
|
3546
4764
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3547
|
-
const {
|
|
4765
|
+
const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
|
|
3548
4766
|
const { runImport } = await import("./bridges/runtime/import-runner.js");
|
|
3549
4767
|
const { makeContext } = await import("./bridges/runtime/context.js");
|
|
3550
4768
|
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
@@ -3554,9 +4772,9 @@ bridge
|
|
|
3554
4772
|
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3555
4773
|
process.exit(1);
|
|
3556
4774
|
}
|
|
3557
|
-
let
|
|
4775
|
+
let loaded;
|
|
3558
4776
|
try {
|
|
3559
|
-
|
|
4777
|
+
loaded = await loadBridge(target);
|
|
3560
4778
|
}
|
|
3561
4779
|
catch (err) {
|
|
3562
4780
|
printBridgeError(err);
|
|
@@ -3591,10 +4809,10 @@ bridge
|
|
|
3591
4809
|
if (ev.type === "done") {
|
|
3592
4810
|
const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
|
|
3593
4811
|
if (opts.dryRun) {
|
|
3594
|
-
console.log(`\n${
|
|
4812
|
+
console.log(`\n${target.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
|
|
3595
4813
|
}
|
|
3596
4814
|
else {
|
|
3597
|
-
console.log(`\n${
|
|
4815
|
+
console.log(`\n${target.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
|
|
3598
4816
|
}
|
|
3599
4817
|
return;
|
|
3600
4818
|
}
|
|
@@ -3611,15 +4829,40 @@ bridge
|
|
|
3611
4829
|
}
|
|
3612
4830
|
};
|
|
3613
4831
|
try {
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
4832
|
+
if (loaded.kind === "yaml") {
|
|
4833
|
+
await runImport({
|
|
4834
|
+
bridgeName: target.name,
|
|
4835
|
+
descriptor: loaded.descriptor,
|
|
4836
|
+
cwd,
|
|
4837
|
+
agentId,
|
|
4838
|
+
dryRun: !!opts.dryRun,
|
|
4839
|
+
putMemory,
|
|
4840
|
+
onProgress,
|
|
4841
|
+
ctx,
|
|
4842
|
+
});
|
|
4843
|
+
}
|
|
4844
|
+
else {
|
|
4845
|
+
// Code plugin: invoke bridge.import(opts, ctx) directly; the plugin
|
|
4846
|
+
// returns an AsyncIterable of BridgeMemory that runImport processes.
|
|
4847
|
+
if (!loaded.plugin.import) {
|
|
4848
|
+
console.error(`Bridge "${name}" is a code plugin without an import() function — can only export through it.`);
|
|
4849
|
+
process.exit(1);
|
|
4850
|
+
}
|
|
4851
|
+
// Code-plugin options: pass through all --X flags as a single object.
|
|
4852
|
+
// The plugin's declared `options` descriptor validates what it actually cares about.
|
|
4853
|
+
const pluginOpts = { ...opts };
|
|
4854
|
+
const source = loaded.plugin.import(pluginOpts, ctx);
|
|
4855
|
+
await runImport({
|
|
4856
|
+
bridgeName: target.name,
|
|
4857
|
+
source,
|
|
4858
|
+
cwd,
|
|
4859
|
+
agentId,
|
|
4860
|
+
dryRun: !!opts.dryRun,
|
|
4861
|
+
putMemory,
|
|
4862
|
+
onProgress,
|
|
4863
|
+
ctx,
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
3623
4866
|
}
|
|
3624
4867
|
catch (err) {
|
|
3625
4868
|
if (err instanceof BridgeRuntimeError) {
|
|
@@ -3651,7 +4894,7 @@ bridge
|
|
|
3651
4894
|
const cwd = opts.cwd ?? dst;
|
|
3652
4895
|
const { discover } = await import("./bridges/discover.js");
|
|
3653
4896
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3654
|
-
const {
|
|
4897
|
+
const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
|
|
3655
4898
|
const { runExport } = await import("./bridges/runtime/export-runner.js");
|
|
3656
4899
|
const { makeContext } = await import("./bridges/runtime/context.js");
|
|
3657
4900
|
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
@@ -3661,18 +4904,22 @@ bridge
|
|
|
3661
4904
|
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3662
4905
|
process.exit(1);
|
|
3663
4906
|
}
|
|
3664
|
-
let
|
|
4907
|
+
let loaded;
|
|
3665
4908
|
try {
|
|
3666
|
-
|
|
4909
|
+
loaded = await loadBridge(target);
|
|
3667
4910
|
}
|
|
3668
4911
|
catch (err) {
|
|
3669
4912
|
printBridgeError(err);
|
|
3670
4913
|
process.exit(1);
|
|
3671
4914
|
}
|
|
3672
|
-
if (!descriptor.export) {
|
|
4915
|
+
if (loaded.kind === "yaml" && !loaded.descriptor.export) {
|
|
3673
4916
|
console.error(`Bridge "${name}" has no export block — cannot export through it.`);
|
|
3674
4917
|
process.exit(1);
|
|
3675
4918
|
}
|
|
4919
|
+
if (loaded.kind === "code" && !loaded.plugin.export) {
|
|
4920
|
+
console.error(`Bridge "${name}" is a code plugin without an export() function — can only import through it.`);
|
|
4921
|
+
process.exit(1);
|
|
4922
|
+
}
|
|
3676
4923
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
3677
4924
|
const ctx = makeContext({ bridge: name });
|
|
3678
4925
|
// Memory fetcher — paginates GET /Memory?agentId=... applying any
|
|
@@ -3710,10 +4957,10 @@ bridge
|
|
|
3710
4957
|
const onProgress = (ev) => {
|
|
3711
4958
|
if (ev.type === "done") {
|
|
3712
4959
|
if (opts.dryRun) {
|
|
3713
|
-
console.log(`\n${
|
|
4960
|
+
console.log(`\n${target.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
|
|
3714
4961
|
}
|
|
3715
4962
|
else {
|
|
3716
|
-
console.log(`\n${
|
|
4963
|
+
console.log(`\n${target.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
|
|
3717
4964
|
}
|
|
3718
4965
|
return;
|
|
3719
4966
|
}
|
|
@@ -3733,15 +4980,28 @@ bridge
|
|
|
3733
4980
|
process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
|
|
3734
4981
|
};
|
|
3735
4982
|
try {
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
4983
|
+
if (loaded.kind === "yaml") {
|
|
4984
|
+
await runExport({
|
|
4985
|
+
descriptor: loaded.descriptor,
|
|
4986
|
+
cwd,
|
|
4987
|
+
fetchMemories,
|
|
4988
|
+
filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
|
|
4989
|
+
dryRun: !!opts.dryRun,
|
|
4990
|
+
ctx,
|
|
4991
|
+
onProgress,
|
|
4992
|
+
});
|
|
4993
|
+
}
|
|
4994
|
+
else {
|
|
4995
|
+
// Code plugin export: invoke plugin.export(memoryStream, opts, ctx) directly.
|
|
4996
|
+
// Plugin writes to its target however it likes (HTTP, file, etc.).
|
|
4997
|
+
if (opts.dryRun) {
|
|
4998
|
+
console.log(`${target.name}: dry-run not supported for code-plugin exports; aborting before invoking plugin.export().`);
|
|
4999
|
+
process.exit(2);
|
|
5000
|
+
}
|
|
5001
|
+
const pluginOpts = { ...opts };
|
|
5002
|
+
await loaded.plugin.export(fetchMemories({ agentId, subject: opts.subject, source: opts.source, since: opts.since }), pluginOpts, ctx);
|
|
5003
|
+
console.log(`${target.name}: code-plugin export completed. Record count not reported by the plugin.`);
|
|
5004
|
+
}
|
|
3745
5005
|
}
|
|
3746
5006
|
catch (err) {
|
|
3747
5007
|
if (err instanceof BridgeRuntimeError) {
|
|
@@ -3762,7 +5022,7 @@ bridge
|
|
|
3762
5022
|
const cwd = opts.cwd ?? process.cwd();
|
|
3763
5023
|
const { discover } = await import("./bridges/discover.js");
|
|
3764
5024
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3765
|
-
const {
|
|
5025
|
+
const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
|
|
3766
5026
|
const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
|
|
3767
5027
|
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
3768
5028
|
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
@@ -3771,25 +5031,30 @@ bridge
|
|
|
3771
5031
|
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3772
5032
|
process.exit(1);
|
|
3773
5033
|
}
|
|
3774
|
-
let
|
|
5034
|
+
let loaded;
|
|
3775
5035
|
try {
|
|
3776
|
-
|
|
5036
|
+
loaded = await loadBridge(target);
|
|
3777
5037
|
}
|
|
3778
5038
|
catch (err) {
|
|
3779
5039
|
printBridgeError(err);
|
|
3780
5040
|
process.exit(1);
|
|
3781
5041
|
}
|
|
5042
|
+
if (loaded.kind === "code") {
|
|
5043
|
+
console.error(`Bridge "${name}" is a code plugin — round-trip testing for code plugins lands in slice 3d (requires mocked fetch transport).`);
|
|
5044
|
+
console.error(`For now, code plugins should ship their own tests alongside the npm package.`);
|
|
5045
|
+
process.exit(2);
|
|
5046
|
+
}
|
|
3782
5047
|
try {
|
|
3783
|
-
const result = await runRoundTrip({ descriptor, cwd, fixturePath: opts.fixture });
|
|
5048
|
+
const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
|
|
3784
5049
|
if (opts.json) {
|
|
3785
5050
|
console.log(JSON.stringify(result, null, 2));
|
|
3786
5051
|
process.exit(result.passed ? 0 : 1);
|
|
3787
5052
|
}
|
|
3788
5053
|
if (result.passed) {
|
|
3789
|
-
console.log(`✅ ${
|
|
5054
|
+
console.log(`✅ ${target.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
|
|
3790
5055
|
process.exit(0);
|
|
3791
5056
|
}
|
|
3792
|
-
console.log(`❌ ${
|
|
5057
|
+
console.log(`❌ ${target.name} round-trip failed.`);
|
|
3793
5058
|
console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
|
|
3794
5059
|
if (result.missingInPass2.length > 0) {
|
|
3795
5060
|
console.log(` missing from re-import (${result.missingInPass2.length}):`);
|
|
@@ -3825,11 +5090,92 @@ bridge
|
|
|
3825
5090
|
process.exit(1);
|
|
3826
5091
|
}
|
|
3827
5092
|
});
|
|
5093
|
+
bridge
|
|
5094
|
+
.command("allow <name>")
|
|
5095
|
+
.description("Approve an npm code-plugin bridge for execution. Approval is pinned to the package's location and package.json contents — a malicious package squatting on the same name in a different node_modules tree will be refused at load-time.")
|
|
5096
|
+
.action(async (name) => {
|
|
5097
|
+
const { discover } = await import("./bridges/discover.js");
|
|
5098
|
+
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
5099
|
+
const { allow } = await import("./bridges/runtime/allow-list.js");
|
|
5100
|
+
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
5101
|
+
const target = found.find((b) => b.name === name);
|
|
5102
|
+
if (!target) {
|
|
5103
|
+
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
5104
|
+
process.exit(1);
|
|
5105
|
+
}
|
|
5106
|
+
if (target.source !== "npm-package") {
|
|
5107
|
+
console.error(`"${name}" is a ${target.source} bridge; only npm code plugins require allow-list approval.`);
|
|
5108
|
+
console.error(`YAML and built-in bridges run via the descriptor runtime and don't execute arbitrary JS.`);
|
|
5109
|
+
process.exit(1);
|
|
5110
|
+
}
|
|
5111
|
+
try {
|
|
5112
|
+
const result = await allow(name, target.path);
|
|
5113
|
+
if (result.alreadyAllowed) {
|
|
5114
|
+
console.log(`${name} was already allowed at ${result.entry.packageDir} — no change.`);
|
|
5115
|
+
return;
|
|
5116
|
+
}
|
|
5117
|
+
const verb = result.updated ? "re-approved" : "allowed";
|
|
5118
|
+
console.log(`✓ ${name} ${verb}.`);
|
|
5119
|
+
console.log(` location: ${result.entry.packageDir}`);
|
|
5120
|
+
console.log(` version: ${result.entry.version ?? "(not declared)"}`);
|
|
5121
|
+
console.log(` digest: ${result.entry.packageJsonSha256.slice(0, 16)}…`);
|
|
5122
|
+
console.log(` If the package later moves or its package.json content changes, execution is refused until you re-run this command.`);
|
|
5123
|
+
console.log(` Revoke anytime with: flair bridge revoke ${name}`);
|
|
5124
|
+
}
|
|
5125
|
+
catch (err) {
|
|
5126
|
+
console.error(`Failed to approve "${name}": ${err?.message ?? err}`);
|
|
5127
|
+
process.exit(1);
|
|
5128
|
+
}
|
|
5129
|
+
});
|
|
5130
|
+
bridge
|
|
5131
|
+
.command("revoke <name>")
|
|
5132
|
+
.description("Revoke approval for an npm code-plugin bridge (future invocations will require `flair bridge allow <name>` again)")
|
|
5133
|
+
.action(async (name) => {
|
|
5134
|
+
const { revoke } = await import("./bridges/runtime/allow-list.js");
|
|
5135
|
+
const result = await revoke(name);
|
|
5136
|
+
if (!result.wasAllowed) {
|
|
5137
|
+
console.log(`${name} was not on the allow-list — no change.`);
|
|
5138
|
+
return;
|
|
5139
|
+
}
|
|
5140
|
+
console.log(`✓ ${name} revoked. Future invocations require \`flair bridge allow ${name}\` again.`);
|
|
5141
|
+
});
|
|
5142
|
+
bridge
|
|
5143
|
+
.command("allow-list")
|
|
5144
|
+
.description("Show the allow-listed code-plugin bridges")
|
|
5145
|
+
.option("--json", "Emit raw JSON")
|
|
5146
|
+
.action(async (opts) => {
|
|
5147
|
+
const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
|
|
5148
|
+
const entries = await listAllowed();
|
|
5149
|
+
if (opts.json) {
|
|
5150
|
+
console.log(JSON.stringify(entries, null, 2));
|
|
5151
|
+
return;
|
|
5152
|
+
}
|
|
5153
|
+
if (entries.length === 0) {
|
|
5154
|
+
console.log("No code-plugin bridges are allow-listed yet.");
|
|
5155
|
+
console.log("Allow one with: flair bridge allow <name>");
|
|
5156
|
+
return;
|
|
5157
|
+
}
|
|
5158
|
+
const nameW = Math.max(4, ...entries.map((e) => e.name.length));
|
|
5159
|
+
const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
|
|
5160
|
+
console.log(` ${"name".padEnd(nameW)} ${"version".padEnd(verW)} allowed-at location / digest`);
|
|
5161
|
+
for (const e of entries) {
|
|
5162
|
+
console.log(` ${e.name.padEnd(nameW)} ${(e.version ?? "—").padEnd(verW)} ${e.allowedAt} ${e.packageDir}`);
|
|
5163
|
+
console.log(` ${" ".repeat(nameW)} ${" ".repeat(verW)} ${" ".repeat(24)} sha256:${e.packageJsonSha256.slice(0, 16)}…`);
|
|
5164
|
+
}
|
|
5165
|
+
});
|
|
3828
5166
|
function printBridgeError(err) {
|
|
3829
5167
|
// Pretty-print BridgeRuntimeError as the structured shape from §10 of the
|
|
3830
5168
|
// spec, plus a one-line human summary so the operator gets both.
|
|
3831
5169
|
const detail = err?.detail;
|
|
3832
5170
|
if (detail && typeof detail === "object") {
|
|
5171
|
+
// Trust-check failures get a dedicated, operator-facing rendering.
|
|
5172
|
+
// Dumping the full spec-§10 JSON is useful when an operator is
|
|
5173
|
+
// debugging a broken YAML descriptor; for trust errors it buries the
|
|
5174
|
+
// one thing that matters — the command to re-approve.
|
|
5175
|
+
if (detail.field === "(trust)") {
|
|
5176
|
+
printTrustError(detail);
|
|
5177
|
+
return;
|
|
5178
|
+
}
|
|
3833
5179
|
console.error(`Bridge error: ${detail.hint ?? err.message}`);
|
|
3834
5180
|
console.error(JSON.stringify(detail, null, 2));
|
|
3835
5181
|
}
|
|
@@ -3837,6 +5183,73 @@ function printBridgeError(err) {
|
|
|
3837
5183
|
console.error(`Bridge error: ${err.message ?? String(err)}`);
|
|
3838
5184
|
}
|
|
3839
5185
|
}
|
|
5186
|
+
function printTrustError(detail) {
|
|
5187
|
+
const name = detail.bridge ?? "(unknown)";
|
|
5188
|
+
const ctx = detail.context ?? {};
|
|
5189
|
+
const reapprove = ` flair bridge allow ${name}`;
|
|
5190
|
+
const bar = "─".repeat(60);
|
|
5191
|
+
const header = (title) => {
|
|
5192
|
+
console.error("");
|
|
5193
|
+
console.error(`⚠ ${title} — ${name}`);
|
|
5194
|
+
console.error(bar);
|
|
5195
|
+
};
|
|
5196
|
+
const footer = (label) => {
|
|
5197
|
+
console.error("");
|
|
5198
|
+
console.error(`${label}:`);
|
|
5199
|
+
console.error(reapprove);
|
|
5200
|
+
console.error("");
|
|
5201
|
+
};
|
|
5202
|
+
switch (detail.got) {
|
|
5203
|
+
case "not-allowed":
|
|
5204
|
+
header("Approval required");
|
|
5205
|
+
console.error("This bridge is an npm code plugin — it runs arbitrary JavaScript.");
|
|
5206
|
+
console.error("First-use approval is required before Flair will execute it.");
|
|
5207
|
+
footer("Approve it with");
|
|
5208
|
+
return;
|
|
5209
|
+
case "path-mismatch":
|
|
5210
|
+
header("Trust check failed: package location changed");
|
|
5211
|
+
console.error("A different package with the same name was discovered. This is how");
|
|
5212
|
+
console.error("local squatting attacks present — a planted `node_modules/flair-bridge-*`");
|
|
5213
|
+
console.error("in an unrelated project tree.");
|
|
5214
|
+
console.error("");
|
|
5215
|
+
console.error(` approved: ${ctx.approvedPath ?? "(unknown)"}`);
|
|
5216
|
+
console.error(` version ${ctx.approvedVersion ?? "?"} at ${ctx.approvedAt ?? "?"}`);
|
|
5217
|
+
console.error(` now: ${ctx.observedPath ?? "(unknown)"}`);
|
|
5218
|
+
footer("If the new location is intentional, re-approve");
|
|
5219
|
+
return;
|
|
5220
|
+
case "digest-mismatch":
|
|
5221
|
+
header("Trust check failed: package contents changed");
|
|
5222
|
+
console.error("The package.json at the approved location has changed since you");
|
|
5223
|
+
console.error("approved this bridge. This fires on every upgrade — it's a trust");
|
|
5224
|
+
console.error("event, not an error. If the update is intentional, re-approve.");
|
|
5225
|
+
console.error("");
|
|
5226
|
+
console.error(` location: ${ctx.packagePath ?? "(unknown)"}`);
|
|
5227
|
+
console.error(` approved version: ${ctx.approvedVersion ?? "?"} (at ${ctx.approvedAt ?? "?"})`);
|
|
5228
|
+
console.error(` approved digest: sha256:${(ctx.approvedDigest ?? "").slice(0, 16)}…`);
|
|
5229
|
+
console.error(` observed digest: sha256:${(ctx.observedDigest ?? "").slice(0, 16)}…`);
|
|
5230
|
+
footer("Re-approve");
|
|
5231
|
+
return;
|
|
5232
|
+
case "entry-incomplete":
|
|
5233
|
+
header("Trust check failed: approval record is incomplete");
|
|
5234
|
+
console.error("The allow-list entry for this bridge is missing a location or digest.");
|
|
5235
|
+
console.error("This usually means the record was created by a pre-fix Flair version");
|
|
5236
|
+
console.error("(0.6.0 / 0.6.1) that only stored the name. Re-approve to upgrade.");
|
|
5237
|
+
footer("Re-approve");
|
|
5238
|
+
return;
|
|
5239
|
+
case "package-missing":
|
|
5240
|
+
header("Trust check failed: approved package missing on disk");
|
|
5241
|
+
console.error("The package location recorded at allow-time is no longer readable.");
|
|
5242
|
+
console.error("");
|
|
5243
|
+
console.error(` approved at: ${ctx.approvedPath ?? "(unknown)"}`);
|
|
5244
|
+
console.error(` discovered: ${ctx.discoveredPath ?? "(unknown)"}`);
|
|
5245
|
+
footer("Reinstall the package, then re-approve");
|
|
5246
|
+
return;
|
|
5247
|
+
default:
|
|
5248
|
+
// Unknown trust sub-reason — fall back to the raw structured print.
|
|
5249
|
+
console.error(`Bridge error (trust): ${detail.hint ?? detail.got ?? "unknown"}`);
|
|
5250
|
+
console.error(JSON.stringify(detail, null, 2));
|
|
5251
|
+
}
|
|
5252
|
+
}
|
|
3840
5253
|
// ─── flair backup ────────────────────────────────────────────────────────────
|
|
3841
5254
|
program
|
|
3842
5255
|
.command("backup")
|
|
@@ -4246,9 +5659,264 @@ program
|
|
|
4246
5659
|
console.log(`Souls: ${(data.souls ?? []).length}`);
|
|
4247
5660
|
}
|
|
4248
5661
|
});
|
|
5662
|
+
// ─── flair migrate-harness-memory ───────────────────────────────────────────
|
|
5663
|
+
/** Resolve the memory directory path for a target harness. */
|
|
5664
|
+
function resolveMemoryDir(target, agentId) {
|
|
5665
|
+
switch (target) {
|
|
5666
|
+
case "claude-code": {
|
|
5667
|
+
const cwd = process.cwd();
|
|
5668
|
+
const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
|
|
5669
|
+
const memoryDir = join(homedir(), ".claude", "projects", encodedCwd, "memory");
|
|
5670
|
+
const resolved = resolve(memoryDir);
|
|
5671
|
+
const expectedRoot = join(homedir(), ".claude", "projects");
|
|
5672
|
+
if (!resolved.startsWith(expectedRoot + sep)) {
|
|
5673
|
+
throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
|
|
5674
|
+
}
|
|
5675
|
+
return resolved;
|
|
5676
|
+
}
|
|
5677
|
+
case "openclaw": {
|
|
5678
|
+
const cwd = process.cwd();
|
|
5679
|
+
const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
|
|
5680
|
+
const memoryDir = join(homedir(), ".openclaw", "projects", encodedCwd, "memory");
|
|
5681
|
+
const resolved = resolve(memoryDir);
|
|
5682
|
+
const expectedRoot = join(homedir(), ".openclaw", "projects");
|
|
5683
|
+
if (!resolved.startsWith(expectedRoot + sep)) {
|
|
5684
|
+
throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
|
|
5685
|
+
}
|
|
5686
|
+
return resolved;
|
|
5687
|
+
}
|
|
5688
|
+
case "pi": {
|
|
5689
|
+
const cwd = process.cwd();
|
|
5690
|
+
const encodedCwd = encodeURIComponent(cwd).replace(/%2F/g, "/");
|
|
5691
|
+
const memoryDir = join(homedir(), ".pi", "projects", encodedCwd, "memory");
|
|
5692
|
+
const resolved = resolve(memoryDir);
|
|
5693
|
+
const expectedRoot = join(homedir(), ".pi", "projects");
|
|
5694
|
+
if (!resolved.startsWith(expectedRoot + sep)) {
|
|
5695
|
+
throw new Error(`Memory dir must be within ${expectedRoot}, got ${resolved}`);
|
|
5696
|
+
}
|
|
5697
|
+
return resolved;
|
|
5698
|
+
}
|
|
5699
|
+
default:
|
|
5700
|
+
throw new Error(`Unknown target: ${target}. Valid: claude-code, openclaw, pi`);
|
|
5701
|
+
}
|
|
5702
|
+
}
|
|
5703
|
+
/** Parse a memory file with YAML frontmatter. */
|
|
5704
|
+
function parseMemoryFile(filePath) {
|
|
5705
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
5706
|
+
const lines = raw.split("\n");
|
|
5707
|
+
if (!lines[0].startsWith("---")) {
|
|
5708
|
+
return { meta: {}, body: raw };
|
|
5709
|
+
}
|
|
5710
|
+
let endIdx = 1;
|
|
5711
|
+
while (endIdx < lines.length && !lines[endIdx].startsWith("---")) {
|
|
5712
|
+
endIdx++;
|
|
5713
|
+
}
|
|
5714
|
+
const frontmatterLines = lines.slice(1, endIdx);
|
|
5715
|
+
const bodyLines = lines.slice(endIdx + 1);
|
|
5716
|
+
const yamlContent = frontmatterLines.join("\n");
|
|
5717
|
+
const meta = parseYaml(yamlContent) || {};
|
|
5718
|
+
return {
|
|
5719
|
+
meta: {
|
|
5720
|
+
name: meta.name,
|
|
5721
|
+
description: meta.description,
|
|
5722
|
+
type: meta.type,
|
|
5723
|
+
tags: Array.isArray(meta.tags) ? meta.tags : (meta.tags ? [meta.tags] : []),
|
|
5724
|
+
},
|
|
5725
|
+
body: bodyLines.join("\n").trim(),
|
|
5726
|
+
};
|
|
5727
|
+
}
|
|
5728
|
+
/** Map memory type to durability. */
|
|
5729
|
+
function mapDurability(type) {
|
|
5730
|
+
switch (type) {
|
|
5731
|
+
case "feedback":
|
|
5732
|
+
case "reference":
|
|
5733
|
+
return "permanent";
|
|
5734
|
+
case "project":
|
|
5735
|
+
case "user":
|
|
5736
|
+
return "persistent";
|
|
5737
|
+
default:
|
|
5738
|
+
return "standard";
|
|
5739
|
+
}
|
|
5740
|
+
}
|
|
5741
|
+
/** Extract keywords from filename for tags. */
|
|
5742
|
+
function extractKeywordsFromFilename(filename) {
|
|
5743
|
+
const base = filename.replace(/\.md$/, "");
|
|
5744
|
+
const withoutType = base.replace(/^(feedback|project|reference|user)_/, "");
|
|
5745
|
+
const parts = withoutType.split(/[_-]+/).map(p => p.toLowerCase());
|
|
5746
|
+
const stopwords = new Set(["the", "and", "for", "with", "about", "on", "in", "to", "of", "a", "an"]);
|
|
5747
|
+
return parts.filter(p => p.length > 2 && !stopwords.has(p));
|
|
5748
|
+
}
|
|
5749
|
+
program
|
|
5750
|
+
.command("migrate-harness-memory")
|
|
5751
|
+
.description("Migrate harness-local memories to Flair")
|
|
5752
|
+
.requiredOption("--target <target>", "Target harness: claude-code, openclaw, pi")
|
|
5753
|
+
.requiredOption("--agent <id>", "Agent ID to write memories under")
|
|
5754
|
+
.option("--dry-run", "Show what would be migrated without writing")
|
|
5755
|
+
.action(async (opts) => {
|
|
5756
|
+
const target = opts.target;
|
|
5757
|
+
const agentId = opts.agent;
|
|
5758
|
+
const dryRun = !!opts.dryRun;
|
|
5759
|
+
let memoryDir;
|
|
5760
|
+
try {
|
|
5761
|
+
memoryDir = resolveMemoryDir(target, agentId);
|
|
5762
|
+
}
|
|
5763
|
+
catch (e) {
|
|
5764
|
+
console.error(`Error resolving memory directory: ${e.message}`);
|
|
5765
|
+
process.exit(1);
|
|
5766
|
+
}
|
|
5767
|
+
if (!existsSync(memoryDir)) {
|
|
5768
|
+
console.error(`Error: Memory directory not found: ${memoryDir}`);
|
|
5769
|
+
process.exit(1);
|
|
5770
|
+
}
|
|
5771
|
+
const migratedDir = join(memoryDir, ".migrated");
|
|
5772
|
+
if (!dryRun) {
|
|
5773
|
+
mkdirSync(migratedDir, { recursive: true, mode: 0o700 });
|
|
5774
|
+
}
|
|
5775
|
+
console.log(`Migrating memories from ${memoryDir} to Flair (agentId=${agentId})`);
|
|
5776
|
+
if (dryRun)
|
|
5777
|
+
console.log(" (DRY RUN - no files will be modified)");
|
|
5778
|
+
const files = readdirSync(memoryDir, { withFileTypes: true })
|
|
5779
|
+
.filter(d => d.isFile() && d.name.endsWith(".md") && d.name !== "MEMORY.md")
|
|
5780
|
+
.map(d => d.name)
|
|
5781
|
+
.sort();
|
|
5782
|
+
if (files.length === 0) {
|
|
5783
|
+
console.log("No memory files found.");
|
|
5784
|
+
return;
|
|
5785
|
+
}
|
|
5786
|
+
console.log(`Found ${files.length} memory file(s) to migrate.`);
|
|
5787
|
+
let successCount = 0;
|
|
5788
|
+
let skipCount = 0;
|
|
5789
|
+
let failCount = 0;
|
|
5790
|
+
for (const filename of files) {
|
|
5791
|
+
const sourcePath = join(memoryDir, filename);
|
|
5792
|
+
const migratedPath = join(migratedDir, filename);
|
|
5793
|
+
if (existsSync(migratedPath)) {
|
|
5794
|
+
console.log(` ${filename}: already migrated, skipping`);
|
|
5795
|
+
skipCount++;
|
|
5796
|
+
continue;
|
|
5797
|
+
}
|
|
5798
|
+
console.log(` Processing: ${filename}...`);
|
|
5799
|
+
let parsed;
|
|
5800
|
+
try {
|
|
5801
|
+
parsed = parseMemoryFile(sourcePath);
|
|
5802
|
+
}
|
|
5803
|
+
catch (e) {
|
|
5804
|
+
console.error(` Failed to parse: ${e.message}`);
|
|
5805
|
+
failCount++;
|
|
5806
|
+
continue;
|
|
5807
|
+
}
|
|
5808
|
+
let type = parsed.meta.type;
|
|
5809
|
+
if (!type) {
|
|
5810
|
+
if (filename.startsWith("feedback_"))
|
|
5811
|
+
type = "feedback";
|
|
5812
|
+
else if (filename.startsWith("project_"))
|
|
5813
|
+
type = "project";
|
|
5814
|
+
else if (filename.startsWith("reference_"))
|
|
5815
|
+
type = "reference";
|
|
5816
|
+
else
|
|
5817
|
+
type = "session";
|
|
5818
|
+
}
|
|
5819
|
+
const durability = mapDurability(type);
|
|
5820
|
+
const tags = [...(parsed.meta.tags || [])];
|
|
5821
|
+
const filenameTags = extractKeywordsFromFilename(filename);
|
|
5822
|
+
for (const t of filenameTags) {
|
|
5823
|
+
if (!tags.includes(t))
|
|
5824
|
+
tags.push(t);
|
|
5825
|
+
}
|
|
5826
|
+
tags.push(type);
|
|
5827
|
+
const content = parsed.body;
|
|
5828
|
+
console.log(` Type: ${type}, Durability: ${durability}`);
|
|
5829
|
+
console.log(` Tags: ${tags.join(", ")}`);
|
|
5830
|
+
console.log(` Content preview: ${content.slice(0, 100)}${content.length > 100 ? "..." : ""}`);
|
|
5831
|
+
if (!dryRun) {
|
|
5832
|
+
console.log(` Writing to Flair...`);
|
|
5833
|
+
try {
|
|
5834
|
+
const DEFAULT_PORT = 19926;
|
|
5835
|
+
const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
5836
|
+
const agentKeyId = `${agentId}.key`;
|
|
5837
|
+
const keysDir = join(homedir(), ".flair", "keys");
|
|
5838
|
+
const keyPath = join(keysDir, agentKeyId);
|
|
5839
|
+
let authSuccess = false;
|
|
5840
|
+
if (existsSync(keyPath)) {
|
|
5841
|
+
const memoryId = `${agentId}-${randomUUID()}`;
|
|
5842
|
+
const body = {
|
|
5843
|
+
id: memoryId,
|
|
5844
|
+
agentId: agentId,
|
|
5845
|
+
content: content,
|
|
5846
|
+
type: type,
|
|
5847
|
+
durability: durability,
|
|
5848
|
+
tags: tags,
|
|
5849
|
+
createdAt: new Date().toISOString(),
|
|
5850
|
+
};
|
|
5851
|
+
const memoryPath = `/Memory/${memoryId}`;
|
|
5852
|
+
const res = await authFetch(httpUrl, agentId, keyPath, "PUT", memoryPath, body);
|
|
5853
|
+
if (res.ok) {
|
|
5854
|
+
authSuccess = true;
|
|
5855
|
+
console.log(` Write to Flair successful (Ed25519 auth)`);
|
|
5856
|
+
}
|
|
5857
|
+
}
|
|
5858
|
+
if (!authSuccess) {
|
|
5859
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD || "";
|
|
5860
|
+
if (adminPass) {
|
|
5861
|
+
const memoryId = `${agentId}-${randomUUID()}`;
|
|
5862
|
+
const body = {
|
|
5863
|
+
id: memoryId,
|
|
5864
|
+
agentId: agentId,
|
|
5865
|
+
content: content,
|
|
5866
|
+
type: type,
|
|
5867
|
+
durability: durability,
|
|
5868
|
+
tags: tags,
|
|
5869
|
+
createdAt: new Date().toISOString(),
|
|
5870
|
+
};
|
|
5871
|
+
const memoryPath = `/Memory/${memoryId}`;
|
|
5872
|
+
const auth = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
5873
|
+
const res = await fetch(`${httpUrl}${memoryPath}`, {
|
|
5874
|
+
method: "PUT",
|
|
5875
|
+
headers: {
|
|
5876
|
+
Authorization: auth,
|
|
5877
|
+
"Content-Type": "application/json",
|
|
5878
|
+
},
|
|
5879
|
+
body: JSON.stringify(body),
|
|
5880
|
+
signal: AbortSignal.timeout(10000),
|
|
5881
|
+
});
|
|
5882
|
+
if (res.ok) {
|
|
5883
|
+
console.log(` Write to Flair successful (Basic auth)`);
|
|
5884
|
+
}
|
|
5885
|
+
else {
|
|
5886
|
+
const text = await res.text();
|
|
5887
|
+
throw new Error(`HTTP ${res.status}: ${text}`);
|
|
5888
|
+
}
|
|
5889
|
+
}
|
|
5890
|
+
else {
|
|
5891
|
+
throw new Error("No authentication method available");
|
|
5892
|
+
}
|
|
5893
|
+
}
|
|
5894
|
+
renameSync(sourcePath, migratedPath);
|
|
5895
|
+
console.log(` Moved to .migrated/${filename}`);
|
|
5896
|
+
}
|
|
5897
|
+
catch (e) {
|
|
5898
|
+
console.error(` Failed: ${e.message}`);
|
|
5899
|
+
failCount++;
|
|
5900
|
+
continue;
|
|
5901
|
+
}
|
|
5902
|
+
}
|
|
5903
|
+
else {
|
|
5904
|
+
console.log(` [dry-run] Would write to Flair and move to .migrated/${filename}`);
|
|
5905
|
+
}
|
|
5906
|
+
successCount++;
|
|
5907
|
+
}
|
|
5908
|
+
console.log(`\nMigration complete:`);
|
|
5909
|
+
console.log(` Processed: ${files.length}`);
|
|
5910
|
+
console.log(` Successful: ${successCount}`);
|
|
5911
|
+
console.log(` Skipped: ${skipCount}`);
|
|
5912
|
+
console.log(` Failed: ${failCount}`);
|
|
5913
|
+
if (failCount > 0) {
|
|
5914
|
+
process.exit(1);
|
|
5915
|
+
}
|
|
5916
|
+
});
|
|
4249
5917
|
// Run CLI only when this is the entry point (not when imported for testing)
|
|
4250
5918
|
if (import.meta.main) {
|
|
4251
5919
|
await program.parseAsync();
|
|
4252
5920
|
}
|
|
4253
5921
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
4254
|
-
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, signRequestBody, b64, b64url, program, };
|
|
5922
|
+
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, };
|