@tpsdev-ai/flair 0.31.1 → 0.32.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 +32 -6
- package/dist/cli.js +124 -40
- package/dist/resources/in-process-api.js +382 -0
- package/docs/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +171 -76
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/upgrade.md +6 -2
- package/package.json +7 -1
package/README.md
CHANGED
|
@@ -40,7 +40,19 @@ Self-hosted on [Harper](https://harper.fast) as a single process. No sidecars, n
|
|
|
40
40
|
|
|
41
41
|
## Quick start
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Two front doors. Pick by where your code already runs.
|
|
44
|
+
|
|
45
|
+
### Already on Harper? Load Flair as a component
|
|
46
|
+
|
|
47
|
+
Flair *is* a Harper component. Deploy it into the instance your application already runs in and call its resources directly — `await h.post({ agentId, content })` is a **method call**, not a network call. No second service to operate, no HTTP round trip, and no key to distribute: a caller in the same process is already inside the trust boundary and names the agent it is acting as, per call.
|
|
48
|
+
|
|
49
|
+
Adding it takes nothing away. The HTTP surface keeps serving MCP clients and remote agents exactly as before.
|
|
50
|
+
|
|
51
|
+
**→ [Embedding Flair in a Harper app](docs/embedding-in-a-harper-app.md)** — the whole in-process contract, measured against a real instance: resolving the resource, the table-vs-resource distinction that decides whether your memories are scoped at all, N agents in one process, and registering agents with no shell on the node.
|
|
52
|
+
|
|
53
|
+
### Everywhere else — install the CLI
|
|
54
|
+
|
|
55
|
+
A laptop, a VPS, an MCP client, or any language over HTTP. Needs **Node.js 22+** and a **user-writable npm global prefix**.
|
|
44
56
|
|
|
45
57
|
> ⚠️ **Never `sudo npm install -g @tpsdev-ai/flair`.** A root-owned install can't write the embedding model into its own package directory, so semantic search silently degrades to keyword-only. `flair init` and `flair doctor` will warn you loudly. Use `nvm`, or point npm at your home directory: `npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to `PATH`.
|
|
46
58
|
|
|
@@ -73,6 +85,14 @@ That trailing figure is a rank score, normalized so the top hit is always near 1
|
|
|
73
85
|
|
|
74
86
|
Full walkthrough with expected output at every step: **[docs/quickstart.md](docs/quickstart.md)**.
|
|
75
87
|
|
|
88
|
+
### Where the agent's key lives
|
|
89
|
+
|
|
90
|
+
`flair init --agent mybot` writes the private key to `~/.flair/keys/mybot.key` (mode `0600`) and the public half beside it as `mybot.pub`. Only the **public** key is registered on the instance; the private key never leaves the machine. `--keys-dir` writes both somewhere else.
|
|
91
|
+
|
|
92
|
+
**Back that file up — it is the agent's identity, and there is one copy.** Memories are not encrypted with it, so losing it costs the identity, not the data: the agent can no longer sign, and every HTTP call it makes fails. Recovery is `flair agent rotate-key mybot`, which mints a new pair and re-registers the public half — it needs the admin password `flair init` wrote to `~/.flair/admin-pass`, so back that up too. Otherwise treat the key like an SSH key: one per agent per host, never copied between machines ([docs/secrets-and-keys.md](docs/secrets-and-keys.md)).
|
|
93
|
+
|
|
94
|
+
**The in-process path needs no key at all.** Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance asserts identity through the call context instead — `agentContext("mybot")` — which Flair reads and acts on with no signature, no `Agent`-table lookup and no registration. That is deliberate: same-process code could write the storage tables directly, so demanding a signature from it would be theatre. It is also why that id must come from your own server-side state and never from request data.
|
|
95
|
+
|
|
76
96
|
### Useful flags
|
|
77
97
|
|
|
78
98
|
```bash
|
|
@@ -278,24 +298,30 @@ Sign `agentId:timestamp:nonce:METHOD:/path` with the agent's private key. Protoc
|
|
|
278
298
|
|
|
279
299
|
### Embedded in a Harper app (in-process)
|
|
280
300
|
|
|
281
|
-
Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly — a method call instead of an HTTP round trip.
|
|
301
|
+
Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly — a method call instead of an HTTP round trip, and no key anywhere.
|
|
282
302
|
|
|
283
303
|
```javascript
|
|
284
304
|
import { server } from "harper";
|
|
305
|
+
import { agentContext, collectionResource } from "@tpsdev-ai/flair/dist/resources/in-process.js";
|
|
306
|
+
|
|
307
|
+
// The RESOURCE — auth, read-scoping, visibility, embedding.
|
|
308
|
+
// Registry keys carry no leading slash: get("Memory"), never get("/Memory").
|
|
309
|
+
const Memory = server.resources.get("Memory").Resource;
|
|
285
310
|
|
|
286
|
-
const
|
|
287
|
-
const h = new Memory(undefined, { request: { tpsAgent: "mybot" } });
|
|
311
|
+
const h = await collectionResource(Memory, agentContext("mybot"));
|
|
288
312
|
await h.post({ agentId: "mybot", content: "...", durability: "standard" });
|
|
289
313
|
```
|
|
290
314
|
|
|
291
|
-
`databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live.
|
|
315
|
+
`databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. `new Memory(...)` is not a substitute for `collectionResource` — a create needs a collection-bound instance only Harper can produce. Both helpers refuse a missing agent id rather than defaulting it, because a resource invoked with no context resolves to Flair's trusted `internal` verdict and runs unfiltered across every agent. Full guide: **[docs/embedding-in-a-harper-app.md](docs/embedding-in-a-harper-app.md)**.
|
|
292
316
|
|
|
293
317
|
### Auth across surfaces
|
|
294
318
|
|
|
295
|
-
|
|
319
|
+
For every caller that reaches Flair over the network the default is **Ed25519 per-agent**: each agent holds its own key at `~/.flair/keys/<agent>.key` and signs every request. That gives write isolation — no agent can write as another — and identity-verified reads. It does *not* refuse cross-agent reads: within one instance, any verified agent can read any other agent's non-private memory by design. The hard boundary is the federation edge, not intra-instance reads. See [SECURITY.md](SECURITY.md).
|
|
296
320
|
|
|
297
321
|
One exception: the **`n8n-nodes-flair`** node authenticates with the Harper **admin password** (Basic auth), which bypasses agent scoping entirely — it can read other agents' `visibility: private` memories and write as anyone. That is acceptable only on a single-tenant, operator-controlled n8n with trusted workflow inputs. Otherwise prefer the Ed25519 path. Full breakdown in **[docs/auth.md](docs/auth.md#auth-across-surfaces-read-this-first)**.
|
|
298
322
|
|
|
323
|
+
In-process callers are a different model, not an exception to this one: they never sign, because identity is asserted through the call context rather than proven. Co-location *is* the grant — which is why Flair beside untrusted co-tenants on a shared instance is a different proposition to Flair inside your own app.
|
|
324
|
+
|
|
299
325
|
## Deployment
|
|
300
326
|
|
|
301
327
|
### Local (default)
|
package/dist/cli.js
CHANGED
|
@@ -269,7 +269,19 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
|
|
|
269
269
|
}
|
|
270
270
|
catch { /* best effort */ }
|
|
271
271
|
const legacyContent = readFileSync(resolved.plistPath, "utf-8");
|
|
272
|
-
|
|
272
|
+
// Use a function replacer to avoid $-sensitivity in the replacement
|
|
273
|
+
// string (flair#919). String.prototype.replace interprets $&, $', $`
|
|
274
|
+
// etc. in the replacement even when the search value is a plain string.
|
|
275
|
+
const labelSearch = `<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`;
|
|
276
|
+
const labelReplacement = `<key>Label</key><string>${newLabel}</string>`;
|
|
277
|
+
const newContent = legacyContent.replace(labelSearch, () => labelReplacement);
|
|
278
|
+
// Refuse to propagate a malformed plist: if the Label wasn't found,
|
|
279
|
+
// the plist is not what we expect and migration must not write it.
|
|
280
|
+
if (newContent === legacyContent) {
|
|
281
|
+
throw new Error(`Legacy plist at ${resolved.plistPath} does not contain the expected ` +
|
|
282
|
+
`Label key — it may be malformed or from an unknown Flair version. ` +
|
|
283
|
+
`Remove it manually and re-run 'flair init'.`);
|
|
284
|
+
}
|
|
273
285
|
writeFileSync(newPlistPath, newContent);
|
|
274
286
|
try {
|
|
275
287
|
unlinkSync(resolved.plistPath);
|
|
@@ -277,6 +289,58 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
|
|
|
277
289
|
catch { /* best effort */ }
|
|
278
290
|
return { migrated: true, label: newLabel, plistPath: newPlistPath };
|
|
279
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* Clean up a pre-flair#693 legacy launchd plist (ai.tpsdev.flair) during
|
|
294
|
+
* init, but ONLY when it belongs to the data dir being initialised.
|
|
295
|
+
*
|
|
296
|
+
* flair#966: the legacy plist is a single global label — init must not
|
|
297
|
+
* unload/delete it unless ROOTPATH proves it serves this data dir.
|
|
298
|
+
*
|
|
299
|
+
* `runLaunchctl` is injected so tests can record/mock without touching
|
|
300
|
+
* real launchd. The caller (init) passes a real execSync wrapper; tests
|
|
301
|
+
* pass a recording stub.
|
|
302
|
+
*/
|
|
303
|
+
function cleanupLegacyLaunchdPlist(dataDir, plistDir, runLaunchctl) {
|
|
304
|
+
const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
|
|
305
|
+
if (!existsSync(legacyPlistPath))
|
|
306
|
+
return { action: "none" };
|
|
307
|
+
const legacyRootPath = readPlistRootPath(legacyPlistPath);
|
|
308
|
+
const legacyOwnedByUs = legacyRootPath !== null && resolve(legacyRootPath) === resolve(dataDir);
|
|
309
|
+
if (legacyOwnedByUs) {
|
|
310
|
+
let unloadFailed;
|
|
311
|
+
let deleteFailed;
|
|
312
|
+
try {
|
|
313
|
+
runLaunchctl(`launchctl unload "${legacyPlistPath}"`);
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
unloadFailed = err?.message ?? String(err);
|
|
317
|
+
console.error(`Failed to unload legacy launchd service (${LEGACY_LAUNCHD_LABEL}): ` +
|
|
318
|
+
`${unloadFailed}. ` +
|
|
319
|
+
`The plist at ${legacyPlistPath} may still be loaded — ` +
|
|
320
|
+
`unload it manually with: launchctl unload "${legacyPlistPath}"`);
|
|
321
|
+
}
|
|
322
|
+
try {
|
|
323
|
+
unlinkSync(legacyPlistPath);
|
|
324
|
+
console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
deleteFailed = err?.message ?? String(err);
|
|
328
|
+
console.error(`Failed to remove legacy launchd plist at ${legacyPlistPath}: ` +
|
|
329
|
+
`${deleteFailed}. Remove it manually.`);
|
|
330
|
+
}
|
|
331
|
+
return { action: "unloaded", unloadFailed, deleteFailed };
|
|
332
|
+
}
|
|
333
|
+
if (legacyRootPath !== null) {
|
|
334
|
+
console.log(`Skipped legacy launchd cleanup: the plist at ${legacyPlistPath} ` +
|
|
335
|
+
`belongs to data dir ${resolve(legacyRootPath)}, not ${resolve(dataDir)} — ` +
|
|
336
|
+
`that is a different Flair instance.`);
|
|
337
|
+
return { action: "skipped-foreign", foreignDataDir: resolve(legacyRootPath) };
|
|
338
|
+
}
|
|
339
|
+
console.log(`Skipped legacy launchd cleanup: could not determine which data dir ` +
|
|
340
|
+
`the plist at ${legacyPlistPath} serves. ` +
|
|
341
|
+
`If it is yours, remove it manually with: rm "${legacyPlistPath}"`);
|
|
342
|
+
return { action: "skipped-unknown" };
|
|
343
|
+
}
|
|
280
344
|
/**
|
|
281
345
|
* Load + start `dataDir`'s launchd service, migrating off a pre-flair#693
|
|
282
346
|
* legacy registration FIRST if one is found (migrateLegacyLaunchdLabel
|
|
@@ -290,6 +354,13 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
|
|
|
290
354
|
*/
|
|
291
355
|
function ensureLaunchdServiceLoaded(dataDir, runLaunchctl, launchAgentsDir = defaultLaunchAgentsDir()) {
|
|
292
356
|
const migration = migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir);
|
|
357
|
+
// Unload first so a rewritten plist is re-read (flair#872).
|
|
358
|
+
// launchd caches the environment of an already-loaded job; load
|
|
359
|
+
// alone does not pick up changes to the plist on disk.
|
|
360
|
+
try {
|
|
361
|
+
runLaunchctl(`launchctl unload "${migration.plistPath}"`);
|
|
362
|
+
}
|
|
363
|
+
catch { /* not loaded, etc. — best effort */ }
|
|
293
364
|
try {
|
|
294
365
|
runLaunchctl(`launchctl load "${migration.plistPath}"`);
|
|
295
366
|
}
|
|
@@ -2933,25 +3004,20 @@ program
|
|
|
2933
3004
|
const plistDir = defaultLaunchAgentsDir();
|
|
2934
3005
|
mkdirSync(plistDir, { recursive: true });
|
|
2935
3006
|
const plistPath = launchdPlistPath(label, plistDir);
|
|
2936
|
-
// flair#693
|
|
3007
|
+
// flair#693 + flair#966: a pre-flair#693 install registered under
|
|
2937
3008
|
// the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
|
|
2938
3009
|
// content below (it has the current ports/creds in hand), so
|
|
2939
3010
|
// migration here is just "clean up the old registration" —
|
|
2940
3011
|
// unload + remove it BEFORE writing the new one, so re-running
|
|
2941
3012
|
// init never leaves two services behind for this data dir.
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
}
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
unlinkSync(legacyPlistPath);
|
|
2951
|
-
}
|
|
2952
|
-
catch { /* best effort */ }
|
|
2953
|
-
console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
|
|
2954
|
-
}
|
|
3013
|
+
//
|
|
3014
|
+
// flair#966: the legacy plist is NOT scoped to this data dir —
|
|
3015
|
+
// it is a single global label. cleanupLegacyLaunchdPlist reads
|
|
3016
|
+
// ROOTPATH to establish ownership before touching it.
|
|
3017
|
+
cleanupLegacyLaunchdPlist(dataDir, plistDir, (cmd) => {
|
|
3018
|
+
const { execSync } = require("node:child_process");
|
|
3019
|
+
execSync(cmd, { stdio: "pipe" });
|
|
3020
|
+
});
|
|
2955
3021
|
const opsSocket = join(dataDir, "operations-server");
|
|
2956
3022
|
// authorizeLocal: false (flair#654) — same posture as the initial spawn
|
|
2957
3023
|
// above; the launchd-managed process must not diverge from it.
|
|
@@ -9741,20 +9807,28 @@ program
|
|
|
9741
9807
|
* Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
|
|
9742
9808
|
* extracted ROOTPATH path ever reaches a message.
|
|
9743
9809
|
*/
|
|
9744
|
-
|
|
9745
|
-
|
|
9810
|
+
/**
|
|
9811
|
+
* Read the ROOTPATH declared in a launchd plist, or null if it cannot be
|
|
9812
|
+
* determined (file missing, unreadable, or no ROOTPATH key).
|
|
9813
|
+
*
|
|
9814
|
+
* The plist stores this XML-escaped (buildLaunchdPlist), so the returned
|
|
9815
|
+
* value is decoded through unescapeXml before being returned — a data dir
|
|
9816
|
+
* containing `&` is on disk as `&` and this returns the literal `&`.
|
|
9817
|
+
*
|
|
9818
|
+
* Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD.
|
|
9819
|
+
*/
|
|
9820
|
+
export function readPlistRootPath(plistPath) {
|
|
9746
9821
|
try {
|
|
9747
9822
|
const raw = readFileSync(plistPath, "utf-8");
|
|
9748
9823
|
const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
|
|
9749
|
-
|
|
9750
|
-
// containing `&` is on disk as `&`. Decode before comparing, or the
|
|
9751
|
-
// path would never equal itself and this guard would refuse a legitimate
|
|
9752
|
-
// stop/start on any instance whose path contains an escaped character.
|
|
9753
|
-
declared = m ? unescapeXml(m[1]) : null;
|
|
9824
|
+
return m ? unescapeXml(m[1]) : null;
|
|
9754
9825
|
}
|
|
9755
9826
|
catch {
|
|
9756
|
-
return;
|
|
9827
|
+
return null;
|
|
9757
9828
|
}
|
|
9829
|
+
}
|
|
9830
|
+
export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
|
|
9831
|
+
const declared = readPlistRootPath(plistPath);
|
|
9758
9832
|
if (declared === null)
|
|
9759
9833
|
return;
|
|
9760
9834
|
if (resolve(declared) === resolve(dataDir))
|
|
@@ -9835,18 +9909,15 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
9835
9909
|
assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
|
|
9836
9910
|
try {
|
|
9837
9911
|
const { execSync } = await import("node:child_process");
|
|
9838
|
-
//
|
|
9839
|
-
try {
|
|
9840
|
-
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
9841
|
-
}
|
|
9842
|
-
catch { }
|
|
9843
|
-
// Capture the current PID *before* stopping so callers that
|
|
9912
|
+
// Capture the current PID *before* unloading so callers that
|
|
9844
9913
|
// immediately restart can verify exit. Without this, waitForHealth
|
|
9845
9914
|
// can race against the still-shutting-down old process and return
|
|
9846
|
-
// success before
|
|
9915
|
+
// success before the new one comes up.
|
|
9847
9916
|
const oldPid = readHarperPid(dataDir);
|
|
9917
|
+
// unload stops the job AND prevents KeepAlive from respawning it.
|
|
9918
|
+
// launchctl stop alone is insufficient for a KeepAlive job (flair#874).
|
|
9848
9919
|
try {
|
|
9849
|
-
execSync(`launchctl
|
|
9920
|
+
execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
|
|
9850
9921
|
}
|
|
9851
9922
|
catch { }
|
|
9852
9923
|
if (oldPid)
|
|
@@ -14068,6 +14139,19 @@ program
|
|
|
14068
14139
|
console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
|
|
14069
14140
|
process.exit(1);
|
|
14070
14141
|
}
|
|
14142
|
+
// flair#968: `flair backup > file.json` captures the progress report, not
|
|
14143
|
+
// the archive (which goes to --output, defaulting to ~/.flair/backups/...).
|
|
14144
|
+
// The result was exit 0 and a plausible-looking file of a few hundred bytes —
|
|
14145
|
+
// a false success immediately before a destructive upgrade.
|
|
14146
|
+
//
|
|
14147
|
+
// When stdout is not a TTY, route progress output to stderr. The archive
|
|
14148
|
+
// still goes to --output / the default path. This makes `flair backup >
|
|
14149
|
+
// file.json` produce an EMPTY file — unmistakably not a valid archive —
|
|
14150
|
+
// while leaving default-path callers (schedulers, cron) completely
|
|
14151
|
+
// unaffected.
|
|
14152
|
+
const log = process.stdout.isTTY
|
|
14153
|
+
? console.log.bind(console)
|
|
14154
|
+
: console.error.bind(console);
|
|
14071
14155
|
const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
14072
14156
|
async function adminGet(path) {
|
|
14073
14157
|
const res = await fetch(`${baseUrl}${path}`, {
|
|
@@ -14080,11 +14164,11 @@ program
|
|
|
14080
14164
|
}
|
|
14081
14165
|
return res.json();
|
|
14082
14166
|
}
|
|
14083
|
-
|
|
14167
|
+
log("Fetching agents...");
|
|
14084
14168
|
const allAgents = await adminGet("/Agent/");
|
|
14085
14169
|
const filterIds = opts.agents ? opts.agents.split(",").map((s) => s.trim()) : null;
|
|
14086
14170
|
const agents = filterIds ? allAgents.filter((a) => filterIds.includes(a.id)) : allAgents;
|
|
14087
|
-
|
|
14171
|
+
log(`Fetching memories for ${agents.length} agent(s)...`);
|
|
14088
14172
|
const memories = [];
|
|
14089
14173
|
for (const agent of agents) {
|
|
14090
14174
|
try {
|
|
@@ -14096,7 +14180,7 @@ program
|
|
|
14096
14180
|
console.warn(` Warning: could not fetch memories for ${agent.id}: ${err.message}`);
|
|
14097
14181
|
}
|
|
14098
14182
|
}
|
|
14099
|
-
|
|
14183
|
+
log("Fetching souls...");
|
|
14100
14184
|
const souls = [];
|
|
14101
14185
|
for (const agent of agents) {
|
|
14102
14186
|
try {
|
|
@@ -14124,11 +14208,11 @@ program
|
|
|
14124
14208
|
const tmp = outputPath + ".tmp";
|
|
14125
14209
|
writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
|
|
14126
14210
|
renameSync(tmp, outputPath);
|
|
14127
|
-
|
|
14128
|
-
|
|
14129
|
-
|
|
14130
|
-
|
|
14131
|
-
|
|
14211
|
+
log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
|
|
14212
|
+
log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
|
|
14213
|
+
log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
|
|
14214
|
+
log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
|
|
14215
|
+
log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
|
|
14132
14216
|
});
|
|
14133
14217
|
// ─── flair restore ────────────────────────────────────────────────────────────
|
|
14134
14218
|
program
|
|
@@ -15137,4 +15221,4 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
|
|
|
15137
15221
|
// Harper's own config — the per-instance port record (flair#914)
|
|
15138
15222
|
harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
|
|
15139
15223
|
// launchd label (flair#693)
|
|
15140
|
-
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
|
|
15224
|
+
LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
|