@tpsdev-ai/flair 0.45.0 → 0.47.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/config.yaml +35 -2
- package/dist/build-info.json +6 -0
- package/dist/cli.js +847 -168
- package/dist/doctor-client.js +358 -11
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/install/global-bin-path.js +234 -0
- package/dist/lib/entity-vocab-cli.js +113 -0
- package/dist/lib/mcp-enable.js +71 -21
- package/dist/lib/scheduler-platform.js +363 -1
- package/dist/postinstall.cjs +88 -0
- package/dist/rem/runner.js +177 -10
- package/dist/rem/scheduler.js +126 -20
- package/dist/resources/AttentionQuery.js +5 -3
- package/dist/resources/AutoPromoteCandidates.js +18 -12
- package/dist/resources/Federation.js +49 -5
- package/dist/resources/Memory.js +36 -2
- package/dist/resources/MemoryBootstrap.js +118 -7
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/MemoryReflect.js +70 -5
- package/dist/resources/auto-promote-lib.js +46 -0
- package/dist/resources/build-info.js +50 -0
- package/dist/resources/entity-vocab.js +25 -1
- package/dist/resources/health.js +25 -5
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/mcp-tools.js +53 -3
- package/dist/resources/memory-reflect-lib.js +201 -4
- package/dist/src/lib/scheduler-platform.js +363 -1
- package/dist/src/rem/scheduler.js +126 -20
- package/docs/deepseek-harness.md +110 -0
- package/docs/entity-vocabulary.md +15 -0
- package/docs/integrations.md +1 -0
- package/docs/mcp-clients.md +4 -0
- package/docs/notes/mcp-oauth-model2.md +52 -3
- package/package.json +5 -4
- package/schemas/memory.graphql +12 -0
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -306,7 +306,6 @@ export function buildMcpOAuthConfigBlock(params) {
|
|
|
306
306
|
const provider = params.idpProvider;
|
|
307
307
|
const envPrefix = `OAUTH_${provider.toUpperCase()}`;
|
|
308
308
|
const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
|
|
309
|
-
const enabled = params.enabled ?? true;
|
|
310
309
|
return {
|
|
311
310
|
"@harperfast/oauth": {
|
|
312
311
|
package: "@harperfast/oauth",
|
|
@@ -317,9 +316,29 @@ export function buildMcpOAuthConfigBlock(params) {
|
|
|
317
316
|
},
|
|
318
317
|
},
|
|
319
318
|
mcp: {
|
|
320
|
-
|
|
319
|
+
// flair#1152: whole-token env reference — same flag flair's in-process
|
|
320
|
+
// route gates on. ASYMMETRY (load-bearing, measured on oauth 2.5.0):
|
|
321
|
+
// the component's coerceConfigBoolean accepts ONLY "true"/"false" and
|
|
322
|
+
// DELETES any other string (unresolved placeholder, "1", "yes",
|
|
323
|
+
// garbage) so its disabled default applies; flair's mcpOAuthEnabled()
|
|
324
|
+
// (resources/mcp-oauth-flag.ts) accepts 1/true/yes/on. So "true" is
|
|
325
|
+
// the one value that enables BOTH; "1"/"yes"/"on" flip flair's /mcp
|
|
326
|
+
// handler on while the component AS stays off (fail-closed broken-on:
|
|
327
|
+
// all 401, no AS advertised); garbage/unset disable both. On oauth
|
|
328
|
+
// <2.5.0 there is NO normalization and an unresolved placeholder is a
|
|
329
|
+
// truthy string (fail-open) — which is why the resolved-version
|
|
330
|
+
// assertion in mcp-oauth-boot-safety.test.ts exists. If component
|
|
331
|
+
// `enabled` semantics ever change, or it ever drives flair handler
|
|
332
|
+
// registration directly, re-derive this table before shipping.
|
|
333
|
+
enabled: "${FLAIR_MCP_OAUTH}",
|
|
321
334
|
issuer: "${FLAIR_MCP_ISSUER}",
|
|
322
|
-
|
|
335
|
+
// flair#1180: NO `resource` key — the component's resolveResource()
|
|
336
|
+
// derives `<issuer>/mcp` at request time when it is absent, identical
|
|
337
|
+
// to flair's in-process derivation. The old composite
|
|
338
|
+
// "${FLAIR_MCP_ISSUER}/mcp" never interpolated (whole-token-only
|
|
339
|
+
// expansion) and failed every connect with invalid_target. Escape
|
|
340
|
+
// hatch: an operator needing a non-standard resource sets an explicit
|
|
341
|
+
// LITERAL absolute URL in config.yaml (never a composite).
|
|
323
342
|
accessTokenTtl: REQUIRED_ACCESS_TOKEN_TTL,
|
|
324
343
|
// Explicit fail-closed disable — see the doc comment above and the
|
|
325
344
|
// module header for why an omitted block is NOT equivalent to this.
|
|
@@ -336,14 +355,24 @@ export function buildMcpOAuthConfigBlock(params) {
|
|
|
336
355
|
};
|
|
337
356
|
}
|
|
338
357
|
// ─── Local config.yaml update (flair#1136) ──────────────────────────────────
|
|
358
|
+
/** The whole-token env reference `flair mcp enable` writes as mcp.enabled
|
|
359
|
+
* (flair#1152). The on/off choice lives in the environment (the secrets
|
|
360
|
+
* bundle stages FLAIR_MCP_OAUTH=true — see buildSecretsBundle for why it
|
|
361
|
+
* must be "true"), never as a literal in the config file. */
|
|
362
|
+
export const MCP_ENABLED_ENV_REFERENCE = "${FLAIR_MCP_OAUTH}";
|
|
339
363
|
/**
|
|
340
|
-
*
|
|
341
|
-
* `{ ok: false }` with a reason when the file can't be
|
|
364
|
+
* Set mcp.enabled in a local component config.yaml to the flair#1152 shape.
|
|
365
|
+
* Best-effort: returns `{ ok: false }` with a reason when the file can't be
|
|
366
|
+
* found or parsed.
|
|
367
|
+
*
|
|
368
|
+
* `enabled: true` writes the WHOLE-TOKEN env reference ${FLAIR_MCP_OAUTH}
|
|
369
|
+
* (never a literal `true` — the env var, staged to `true` by the secrets bundle,
|
|
370
|
+
* carries the choice; a legacy literal `true` found in the file is normalized
|
|
371
|
+
* to the reference). `enabled: false` writes literal `false` — decisively off
|
|
372
|
+
* regardless of environment.
|
|
342
373
|
*
|
|
343
374
|
* Looks for config.yaml at `explicitPath`, then `./config.yaml`, then
|
|
344
|
-
* `~/.flair/config.yaml`.
|
|
345
|
-
* with `mcp:\n enabled: true` (exact string match — avoids a YAML parser
|
|
346
|
-
* dependency for a single boolean flip).
|
|
375
|
+
* `~/.flair/config.yaml`.
|
|
347
376
|
*/
|
|
348
377
|
export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
|
|
349
378
|
const candidates = explicitPath
|
|
@@ -356,11 +385,15 @@ export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
|
|
|
356
385
|
break;
|
|
357
386
|
}
|
|
358
387
|
}
|
|
388
|
+
// The value the file should carry for this call (flair#1152): the env
|
|
389
|
+
// reference when enabling, literal false when disabling.
|
|
390
|
+
const target = enabled ? MCP_ENABLED_ENV_REFERENCE : false;
|
|
391
|
+
const targetLabel = enabled ? `${MCP_ENABLED_ENV_REFERENCE} (env-referenced)` : "false";
|
|
359
392
|
if (!configPath) {
|
|
360
393
|
return {
|
|
361
394
|
ok: false,
|
|
362
395
|
detail: `local config.yaml not found (tried: ${candidates.join(", ")}). ` +
|
|
363
|
-
`Set mcp.enabled: ${
|
|
396
|
+
`Set mcp.enabled: ${targetLabel} in your component config.yaml manually, then restart.`,
|
|
364
397
|
};
|
|
365
398
|
}
|
|
366
399
|
let raw;
|
|
@@ -388,7 +421,7 @@ export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
|
|
|
388
421
|
return {
|
|
389
422
|
ok: false,
|
|
390
423
|
detail: `@harperfast/oauth block not found in ${configPath}. ` +
|
|
391
|
-
`Ensure the component block is present with mcp.enabled: ${
|
|
424
|
+
`Ensure the component block is present with mcp.enabled: ${targetLabel}.`,
|
|
392
425
|
};
|
|
393
426
|
}
|
|
394
427
|
const mcp = oauth.mcp;
|
|
@@ -396,15 +429,15 @@ export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
|
|
|
396
429
|
return {
|
|
397
430
|
ok: false,
|
|
398
431
|
detail: `mcp key not found under @harperfast/oauth in ${configPath}. ` +
|
|
399
|
-
`Ensure the mcp block is present with enabled: ${
|
|
432
|
+
`Ensure the mcp block is present with enabled: ${targetLabel}.`,
|
|
400
433
|
};
|
|
401
434
|
}
|
|
402
435
|
const current = mcp.enabled;
|
|
403
|
-
if (current ===
|
|
404
|
-
return { ok: true, detail: `mcp.enabled already ${
|
|
436
|
+
if (current === target) {
|
|
437
|
+
return { ok: true, detail: `mcp.enabled already ${targetLabel} in ${configPath}` };
|
|
405
438
|
}
|
|
406
439
|
// Mutate the parsed document and re-emit.
|
|
407
|
-
mcp.enabled =
|
|
440
|
+
mcp.enabled = target;
|
|
408
441
|
const updated = yaml.dump(doc, { lineWidth: -1, noCompatMode: true });
|
|
409
442
|
try {
|
|
410
443
|
writeFileSync(configPath, updated, { encoding: "utf-8" });
|
|
@@ -412,7 +445,7 @@ export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
|
|
|
412
445
|
catch (err) {
|
|
413
446
|
return { ok: false, detail: `cannot write ${configPath}: ${err.message}` };
|
|
414
447
|
}
|
|
415
|
-
return { ok: true, detail: `mcp.enabled set to ${
|
|
448
|
+
return { ok: true, detail: `mcp.enabled set to ${targetLabel} in ${configPath}` };
|
|
416
449
|
}
|
|
417
450
|
/** The exact callback URL to hand the operator when they create the IdP
|
|
418
451
|
* OAuth app ("with the exact GitHub callback URL printed"). */
|
|
@@ -425,7 +458,14 @@ export function idpCallbackUrl(issuer, idpProvider) {
|
|
|
425
458
|
export function buildSecretsBundle(params) {
|
|
426
459
|
const envPrefix = `OAUTH_${params.idpProvider.toUpperCase()}`;
|
|
427
460
|
return {
|
|
428
|
-
|
|
461
|
+
// "true" is the ONLY value both readers of this flag accept (flair#1152,
|
|
462
|
+
// measured against oauth 2.5.0): flair's strict mcpOAuthEnabled() takes
|
|
463
|
+
// 1/true/yes/on, but the component's coerceConfigBoolean takes ONLY
|
|
464
|
+
// "true"/"false" and DELETES anything else (disabled default applies).
|
|
465
|
+
// Staging "1" here would flip flair's /mcp handler ON while the
|
|
466
|
+
// component's AS stays OFF — fail-closed but broken-on (every request
|
|
467
|
+
// 401s, no AS is advertised). Keep this "true".
|
|
468
|
+
FLAIR_MCP_OAUTH: "true",
|
|
429
469
|
FLAIR_MCP_ISSUER: params.issuer.replace(/\/+$/, ""),
|
|
430
470
|
FLAIR_MCP_SIGNING_KEY_PEM: params.signingKeyPem,
|
|
431
471
|
[`${envPrefix}_CLIENT_ID`]: params.idpClientId,
|
|
@@ -1024,8 +1064,18 @@ export async function enableMcp(params, deps = {}) {
|
|
|
1024
1064
|
idpProvider,
|
|
1025
1065
|
idpSubject: params.idpSubject,
|
|
1026
1066
|
}, { fetchImpl: deps.fetchImpl, now: deps.now });
|
|
1027
|
-
|
|
1028
|
-
|
|
1067
|
+
// flair#1280 — provisioning legibility: distinct identities are the
|
|
1068
|
+
// DEFAULT (a connector sub is not your CLI agent unless you link them),
|
|
1069
|
+
// and the one silent failure mode this surface has is discovering that
|
|
1070
|
+
// via an empty bootstrap. So the step that creates the mapping states it
|
|
1071
|
+
// plainly, names the link remedy, and points at the runtime diagnostic.
|
|
1072
|
+
push(true, `connector identity: sub '${params.idpSubject}' (provider '${idpProvider}') resolves to Agent '${principal}' — ` +
|
|
1073
|
+
`every /mcp call reads and writes AS '${principal}'. ` +
|
|
1074
|
+
`principal ${mapping.principalCreated ? "created" : "already existed"}; ` +
|
|
1075
|
+
`Credential(kind:idp) ${mapping.credentialReused ? "re-pointed" : "created"} (${mapping.credentialId}). ` +
|
|
1076
|
+
`If your CLI signs as a DIFFERENT agent id, the connector sees that agent's DISTINCT memory scope (by design) — ` +
|
|
1077
|
+
`re-run with --principal <your-agent-id> to link them. ` +
|
|
1078
|
+
`Diagnostic: the bootstrap tool's agentId/scope fields always say who the server resolved you to.`);
|
|
1029
1079
|
// ── Gate: confirm the staged secrets are actually live before restarting ─
|
|
1030
1080
|
let confirmed = Boolean(params.confirmSecretsApplied);
|
|
1031
1081
|
if (!confirmed && deps.confirmPrompt) {
|
|
@@ -1052,9 +1102,9 @@ export async function enableMcp(params, deps = {}) {
|
|
|
1052
1102
|
currentStep = "fabric-operator-deploy";
|
|
1053
1103
|
const msg = [
|
|
1054
1104
|
`Fabric deployment detected (${new URL(params.instance).hostname}).`,
|
|
1055
|
-
`The @harperfast/oauth block ships in config.yaml with mcp.enabled:
|
|
1056
|
-
`To activate:
|
|
1057
|
-
`
|
|
1105
|
+
`The @harperfast/oauth block ships in config.yaml with mcp.enabled: \${FLAIR_MCP_OAUTH} (env-referenced, flair#1152) — no config edit is needed.`,
|
|
1106
|
+
`To activate: apply the staged secrets (FLAIR_MCP_OAUTH=true among them) to the instance's environment (Fabric env), then restart the instance.`,
|
|
1107
|
+
`Deploys can no longer revert the choice — it lives in the environment, not the packed file.`,
|
|
1058
1108
|
`Then re-run \`flair mcp enable\` — earlier steps are idempotent and will be reused.`,
|
|
1059
1109
|
].join(" ");
|
|
1060
1110
|
push(false, msg);
|
|
@@ -13,9 +13,15 @@
|
|
|
13
13
|
*
|
|
14
14
|
* `interpretActiveResult()` in particular encodes a production lesson
|
|
15
15
|
* (flair#850) that took a real outage to learn. It must not be re-derived.
|
|
16
|
+
* flair#1231 extended it one layer deeper: a load command exiting 0 proves the
|
|
17
|
+
* service manager ACCEPTED the job, not that the job can RUN — two fleet
|
|
18
|
+
* incidents (a stripped exec bit, a missing log directory) both passed the
|
|
19
|
+
* load check and died on the first real run, invisibly. The rule now encoded
|
|
20
|
+
* in `verifyFirstRun()`: success may not be claimed until the thing the
|
|
21
|
+
* operator asked for has been observed to happen once.
|
|
16
22
|
*/
|
|
17
23
|
import { existsSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
|
|
18
|
-
import { resolve, dirname } from "node:path";
|
|
24
|
+
import { resolve, dirname, isAbsolute } from "node:path";
|
|
19
25
|
import { platform } from "node:os";
|
|
20
26
|
import { spawnSync } from "node:child_process";
|
|
21
27
|
/**
|
|
@@ -126,3 +132,359 @@ export function writeFileWithDir(path, contents, mode = 0o600) {
|
|
|
126
132
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
127
133
|
writeFileSync(path, contents, { mode });
|
|
128
134
|
}
|
|
135
|
+
// ─── node binary resolution (flair#1231) ────────────────────────────────────
|
|
136
|
+
/**
|
|
137
|
+
* Resolves the ABSOLUTE path to the node binary at enable time, so the shim
|
|
138
|
+
* can `exec "<node>" "<script>"` with ZERO PATH lookups at run time.
|
|
139
|
+
*
|
|
140
|
+
* Why this exists: the shims switched from `exec "{{FLAIR_BIN}}"` (which
|
|
141
|
+
* required an exec bit that tarball extraction strips — the #1231 regression)
|
|
142
|
+
* to running the CLI under node, which needs read permission only. But a bare
|
|
143
|
+
* `exec node …` would introduce a run-time PATH lookup the old absolute-path
|
|
144
|
+
* form never had: whatever PATH the service manager's environment carries
|
|
145
|
+
* would pick the `node` that runs with the operator's credentials. So the
|
|
146
|
+
* node path is resolved HERE, once, from the enabling process's own
|
|
147
|
+
* environment, and baked into the shim — symmetric with how FLAIR_BIN is
|
|
148
|
+
* already handled.
|
|
149
|
+
*
|
|
150
|
+
* Resolution order:
|
|
151
|
+
* 1. `explicit` — caller/test override.
|
|
152
|
+
* 2. `process.execPath` when the enabling runtime IS node (the published
|
|
153
|
+
* CLI's case): absolute, known-good, already trusted to run this code.
|
|
154
|
+
* 3. `command -v node` in the enabling shell environment (dev/test under
|
|
155
|
+
* bun): the one deliberate PATH consultation, made at enable time by the
|
|
156
|
+
* operator's own session, never later by the service manager.
|
|
157
|
+
* Nothing resolvable ⇒ throw — enable must fail loudly rather than bake a
|
|
158
|
+
* run-time lookup into the shim.
|
|
159
|
+
*/
|
|
160
|
+
export function resolveNodeBin(explicit) {
|
|
161
|
+
if (explicit)
|
|
162
|
+
return explicit;
|
|
163
|
+
if (!process.versions.bun && process.execPath && isAbsolute(process.execPath)) {
|
|
164
|
+
return process.execPath;
|
|
165
|
+
}
|
|
166
|
+
const r = spawnReport(["/bin/sh", "-c", "command -v node"], STATUS_CHECK_TIMEOUT_MS);
|
|
167
|
+
const found = r.stdout.trim().split("\n")[0]?.trim() ?? "";
|
|
168
|
+
if (r.code === 0 && found && isAbsolute(found) && existsSync(found))
|
|
169
|
+
return found;
|
|
170
|
+
throw new Error("unable to resolve an absolute path to a node binary (not running under node, and `command -v node` " +
|
|
171
|
+
"found nothing). The scheduler shim runs `<node> <flair-script>` with the node path baked in at " +
|
|
172
|
+
"enable time — refusing to install a shim that would resolve `node` from the service manager's PATH " +
|
|
173
|
+
"at run time. Install node (or put it on PATH for this shell) and re-run enable.");
|
|
174
|
+
}
|
|
175
|
+
// ─── first-run verification (flair#1231) ────────────────────────────────────
|
|
176
|
+
// A load/bootstrap command exiting 0 proves the service manager accepted the
|
|
177
|
+
// job — not that the job can run. The only vantage that exercises the real
|
|
178
|
+
// failure modes (launchd spawn error 209 from a missing log dir, exit 126
|
|
179
|
+
// from a stripped exec bit) is the service manager itself, so the first run
|
|
180
|
+
// is triggered and observed THROUGH it, never via a bare spawn of the shim.
|
|
181
|
+
/** Poll cadence for darwin `launchctl print` first-run polling. */
|
|
182
|
+
export const FIRST_RUN_POLL_INTERVAL_MS = 150;
|
|
183
|
+
/** Total budget for first-run verification on both platforms. */
|
|
184
|
+
export const FIRST_RUN_BUDGET_MS = 12_000;
|
|
185
|
+
/** Synchronous sleep without spawning anything. */
|
|
186
|
+
function sleepSync(ms) {
|
|
187
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Parses `launchctl print <domain>/<label>` for run state. `last exit code`
|
|
191
|
+
* is absent (or "(never exited)") until a run has completed, and `pid =` /
|
|
192
|
+
* `state = running` are present only while one is in flight.
|
|
193
|
+
*/
|
|
194
|
+
export function parseLaunchdPrintExit(output) {
|
|
195
|
+
const running = /^\s*state\s*=\s*(?:running|spawn)/m.test(output) || /^\s*pid\s*=\s*\d+/m.test(output);
|
|
196
|
+
const m = /last exit (?:code|status)\s*=\s*(-?\d+)/.exec(output);
|
|
197
|
+
return { running, lastExitCode: m ? Number(m[1]) : null };
|
|
198
|
+
}
|
|
199
|
+
/** Parses `systemctl --user show <unit> --property=ExecMainStatus,Result`. */
|
|
200
|
+
export function parseSystemdShowExit(output) {
|
|
201
|
+
const m = /^ExecMainStatus=(-?\d+)\s*$/m.exec(output);
|
|
202
|
+
const r = /^Result=(\S+)\s*$/m.exec(output);
|
|
203
|
+
return { execMainStatus: m ? Number(m[1]) : null, result: r ? r[1] : null };
|
|
204
|
+
}
|
|
205
|
+
/** Reads the last lines of a log file for failure diagnostics. Never throws. */
|
|
206
|
+
export function readLogTail(path, maxLines = 12, maxChars = 1500) {
|
|
207
|
+
let text;
|
|
208
|
+
try {
|
|
209
|
+
text = readFileSync(path, "utf-8");
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return { exists: false, empty: false, tail: "" };
|
|
213
|
+
}
|
|
214
|
+
const trimmed = text.trimEnd();
|
|
215
|
+
if (!trimmed)
|
|
216
|
+
return { exists: true, empty: true, tail: "" };
|
|
217
|
+
let tail = trimmed.split("\n").slice(-maxLines).join("\n");
|
|
218
|
+
if (tail.length > maxChars)
|
|
219
|
+
tail = tail.slice(-maxChars);
|
|
220
|
+
return { exists: true, empty: false, tail };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Names the failure class for a recorded exit status, so the report can lead
|
|
224
|
+
* with actor+state instead of a bare number.
|
|
225
|
+
*/
|
|
226
|
+
export function describeExitCode(code) {
|
|
227
|
+
if (code === null)
|
|
228
|
+
return "no exit status recorded";
|
|
229
|
+
if (code === 126)
|
|
230
|
+
return "exit 126 — found but not runnable (permission denied / exec format)";
|
|
231
|
+
if (code === 127)
|
|
232
|
+
return "exit 127 — command not found";
|
|
233
|
+
if (code === 209)
|
|
234
|
+
return "exit 209 — launchd could not spawn the job (a missing/unwritable log directory produces this)";
|
|
235
|
+
return `exit ${code}`;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Reads how the job's most recent run ended, from the only vantage that
|
|
239
|
+
* knows: the service manager itself.
|
|
240
|
+
*
|
|
241
|
+
* darwin: `launchctl print` carries `last exit code = N` once a run has
|
|
242
|
+
* completed (parseLaunchdPrintExit). linux: `systemctl --user show` on the
|
|
243
|
+
* service unit — with one trap encoded here rather than in every caller: a
|
|
244
|
+
* unit that has NEVER completed a run still reports `ExecMainStatus=0,
|
|
245
|
+
* Result=success` (systemd property defaults), so the exit properties are
|
|
246
|
+
* only believed when `ExecMainExitTimestampMonotonic` proves a run actually
|
|
247
|
+
* finished. Without that check, "never ran" renders as "last run succeeded"
|
|
248
|
+
* — the exact skipped-check-looks-like-a-pass shape this feature exists to
|
|
249
|
+
* kill.
|
|
250
|
+
*/
|
|
251
|
+
export function queryLastExitStatus(opts) {
|
|
252
|
+
const run = opts.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
|
|
253
|
+
if (opts.plat === "darwin") {
|
|
254
|
+
const target = opts.darwinTarget;
|
|
255
|
+
if (!target)
|
|
256
|
+
throw new Error("queryLastExitStatus: darwinTarget is required on darwin");
|
|
257
|
+
const printCmd = ["launchctl", "print", target];
|
|
258
|
+
const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
259
|
+
if (spawnedNothing(r)) {
|
|
260
|
+
return { state: "unavailable", exitCode: null, detail: `launchctl could not be run (${printCmd.join(" ")})` };
|
|
261
|
+
}
|
|
262
|
+
if (r.code !== 0) {
|
|
263
|
+
return { state: "unavailable", exitCode: null, detail: `${printCmd.join(" ")} → code ${r.code} (job not loaded — no run record to read)` };
|
|
264
|
+
}
|
|
265
|
+
const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
|
|
266
|
+
if (running) {
|
|
267
|
+
return { state: "running", exitCode: null, detail: `${printCmd.join(" ")} → a run is in flight` };
|
|
268
|
+
}
|
|
269
|
+
if (lastExitCode === null) {
|
|
270
|
+
return { state: "never-ran", exitCode: null, detail: `${printCmd.join(" ")} → no completed run recorded` };
|
|
271
|
+
}
|
|
272
|
+
return { state: "recorded", exitCode: lastExitCode, detail: `${printCmd.join(" ")} → last exit code = ${lastExitCode}` };
|
|
273
|
+
}
|
|
274
|
+
const unit = opts.linuxServiceUnit;
|
|
275
|
+
if (!unit)
|
|
276
|
+
throw new Error("queryLastExitStatus: linuxServiceUnit is required on linux");
|
|
277
|
+
const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result,ExecMainExitTimestampMonotonic"];
|
|
278
|
+
const r = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
279
|
+
if (spawnedNothing(r)) {
|
|
280
|
+
return { state: "unavailable", exitCode: null, detail: `systemctl could not be run (${showCmd.join(" ")})` };
|
|
281
|
+
}
|
|
282
|
+
if (/failed to connect to bus/i.test(r.stderr)) {
|
|
283
|
+
return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → ${r.stderr.trim()}` };
|
|
284
|
+
}
|
|
285
|
+
if (r.code !== 0) {
|
|
286
|
+
return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → code ${r.code}${r.stderr.trim() ? `: ${r.stderr.trim()}` : ""}` };
|
|
287
|
+
}
|
|
288
|
+
// Believe the exit properties only when a run has actually finished — see
|
|
289
|
+
// the doc comment above for why this must be checked FIRST.
|
|
290
|
+
const ts = /^ExecMainExitTimestampMonotonic=(\d+)\s*$/m.exec(r.stdout);
|
|
291
|
+
if (ts && Number(ts[1]) === 0) {
|
|
292
|
+
return { state: "never-ran", exitCode: null, detail: `${showCmd.join(" ")} → no completed run recorded` };
|
|
293
|
+
}
|
|
294
|
+
const parsed = parseSystemdShowExit(r.stdout);
|
|
295
|
+
if (parsed.execMainStatus === null) {
|
|
296
|
+
return { state: "unavailable", exitCode: null, detail: `${showCmd.join(" ")} → no ExecMainStatus in the reply` };
|
|
297
|
+
}
|
|
298
|
+
const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
|
|
299
|
+
return {
|
|
300
|
+
state: "recorded",
|
|
301
|
+
exitCode: parsed.execMainStatus,
|
|
302
|
+
detail: `${showCmd.join(" ")} → ExecMainStatus=${parsed.execMainStatus}${resultTxt}`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Pure decision logic for `flair doctor`'s "Scheduled drivers" section
|
|
307
|
+
* (flair#1278) — extracted so it is unit-testable without spawning
|
|
308
|
+
* launchctl/systemctl, same idiom as formatEnableReport/assessDriver in the
|
|
309
|
+
* scheduler modules and summarizeDoctorRun in the CLI.
|
|
310
|
+
*
|
|
311
|
+
* The three load-bearing rules:
|
|
312
|
+
* - not-enabled is a CHOICE, not a defect: informational marker, never the
|
|
313
|
+
* pass marker, never the fail marker, never an issue (a skipped check
|
|
314
|
+
* must not look like a pass — flair#970's rule applied to schedulers).
|
|
315
|
+
* - a last-run failure IS a defect, reported loud with actor+state+remedy
|
|
316
|
+
* (embed-verify style): the service manager is firing the job, the runs
|
|
317
|
+
* themselves are dying, so the schedule looks alive while nothing is
|
|
318
|
+
* delivered — the #1231 incident shape.
|
|
319
|
+
* - "could not read" is UNVERIFIED, never a pass and never a hard failure
|
|
320
|
+
* — the same discipline as doctor's audit-log and embeddings probes.
|
|
321
|
+
*/
|
|
322
|
+
export function describeScheduledDriverFinding(f) {
|
|
323
|
+
if (!f.installed) {
|
|
324
|
+
return {
|
|
325
|
+
state: "not-enabled",
|
|
326
|
+
icon: "info",
|
|
327
|
+
isIssue: false,
|
|
328
|
+
message: `${f.label}: not enabled`,
|
|
329
|
+
detail: [`Opt-in — enable: ${f.enableCommand}`],
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (f.active === false) {
|
|
333
|
+
return {
|
|
334
|
+
state: "degraded",
|
|
335
|
+
icon: "error",
|
|
336
|
+
isIssue: true,
|
|
337
|
+
message: `${f.label}: INSTALLED BUT NOT LOADED — nothing will run it`,
|
|
338
|
+
detail: [
|
|
339
|
+
`The unit files are on disk, but the service manager does not have the job loaded, so it never fires.`,
|
|
340
|
+
`Fix: ${f.enableCommand} # then check: ${f.statusCommand}`,
|
|
341
|
+
],
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
if (f.active === null) {
|
|
345
|
+
return {
|
|
346
|
+
state: "unverified",
|
|
347
|
+
icon: "warn",
|
|
348
|
+
isIssue: false,
|
|
349
|
+
message: `${f.label}: UNVERIFIED — installed, but whether it is loaded could not be read`,
|
|
350
|
+
detail: [`Querying the service manager was inconclusive. Check: ${f.statusCommand}`],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
// Loaded from here down.
|
|
354
|
+
const le = f.lastExit;
|
|
355
|
+
if (!le || le.state === "unavailable") {
|
|
356
|
+
return {
|
|
357
|
+
state: "unverified",
|
|
358
|
+
icon: "warn",
|
|
359
|
+
isIssue: false,
|
|
360
|
+
message: `${f.label}: loaded, but its last-run status could not be read`,
|
|
361
|
+
detail: [...(le ? [le.detail] : []), `Check: ${f.statusCommand}`],
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
if (le.state === "recorded" && le.exitCode !== 0) {
|
|
365
|
+
return {
|
|
366
|
+
state: "degraded",
|
|
367
|
+
icon: "error",
|
|
368
|
+
isIssue: true,
|
|
369
|
+
message: `${f.label} DEGRADED — loaded, but its last run failed (${describeExitCode(le.exitCode)})`,
|
|
370
|
+
detail: [
|
|
371
|
+
`The service manager has the job loaded and is firing it; the runs themselves are failing, so the schedule looks alive while nothing is delivered.`,
|
|
372
|
+
`Check ${f.stderrLogPath}, then: ${f.statusCommand}`,
|
|
373
|
+
],
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
if (le.state === "running") {
|
|
377
|
+
return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (a run is in flight now)`, detail: [] };
|
|
378
|
+
}
|
|
379
|
+
if (le.state === "never-ran") {
|
|
380
|
+
return {
|
|
381
|
+
state: "healthy",
|
|
382
|
+
icon: "ok",
|
|
383
|
+
isIssue: false,
|
|
384
|
+
message: `${f.label}: loaded (no completed run on record yet)`,
|
|
385
|
+
detail: [`Installed and loaded; the service manager has not recorded a completed run since it last (re)loaded the job.`],
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
return { state: "healthy", icon: "ok", isIssue: false, message: `${f.label}: loaded (last run: exit 0)`, detail: [] };
|
|
389
|
+
}
|
|
390
|
+
function spawnedNothing(r) {
|
|
391
|
+
return r.code === null && !r.stdout.trim() && !r.stderr.trim();
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Triggers the job's first run through the service manager and reads back how
|
|
395
|
+
* it ended (flair#1231). Call ONLY after the load/bootstrap command exited 0 —
|
|
396
|
+
* a load failure is its own failure mode with its own remedy, and layering a
|
|
397
|
+
* kickstart on top of it would blur which actor failed.
|
|
398
|
+
*
|
|
399
|
+
* darwin: `launchctl kickstart -k` returns immediately (it does NOT block for
|
|
400
|
+
* exit), so the recorded exit status is POLLED out of `launchctl print` until
|
|
401
|
+
* a completed run is visible or the budget lapses. linux: `systemctl --user
|
|
402
|
+
* start` on a oneshot blocks until the run exits, so a single
|
|
403
|
+
* `systemctl --user show` read afterwards suffices.
|
|
404
|
+
*
|
|
405
|
+
* "Can't tell" is its own state: a missing/unreachable service manager yields
|
|
406
|
+
* outcome "manager-unavailable", distinct from "run-failed" — the remedy
|
|
407
|
+
* points at the service manager, not at the job.
|
|
408
|
+
*/
|
|
409
|
+
export function verifyFirstRun(opts) {
|
|
410
|
+
const run = opts.hooks?.run ?? ((cmd, timeoutMs) => spawnReport(cmd, timeoutMs));
|
|
411
|
+
const sleep = opts.hooks?.sleep ?? sleepSync;
|
|
412
|
+
const now = opts.hooks?.now ?? Date.now;
|
|
413
|
+
const pollIntervalMs = opts.pollIntervalMs ?? FIRST_RUN_POLL_INTERVAL_MS;
|
|
414
|
+
const budgetMs = opts.budgetMs ?? FIRST_RUN_BUDGET_MS;
|
|
415
|
+
const finish = (outcome, exitCode, detail) => {
|
|
416
|
+
const log = outcome === "success"
|
|
417
|
+
? { exists: false, empty: false, tail: "" } // no diagnostics needed on success
|
|
418
|
+
: readLogTail(opts.stderrLogPath);
|
|
419
|
+
return {
|
|
420
|
+
verified: outcome === "success",
|
|
421
|
+
outcome,
|
|
422
|
+
exitCode,
|
|
423
|
+
detail,
|
|
424
|
+
logPath: opts.stderrLogPath,
|
|
425
|
+
stderrTail: log.tail,
|
|
426
|
+
logEmpty: log.exists && log.empty,
|
|
427
|
+
budgetMs,
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
if (opts.plat === "darwin") {
|
|
431
|
+
const target = opts.darwinTarget;
|
|
432
|
+
if (!target)
|
|
433
|
+
throw new Error("verifyFirstRun: darwinTarget is required on darwin");
|
|
434
|
+
const kickCmd = ["launchctl", "kickstart", "-k", target];
|
|
435
|
+
const kick = run(kickCmd, SPAWN_TIMEOUT_MS);
|
|
436
|
+
if (spawnedNothing(kick)) {
|
|
437
|
+
return finish("manager-unavailable", null, `launchctl could not be run (${kickCmd.join(" ")})`);
|
|
438
|
+
}
|
|
439
|
+
if (kick.code !== 0) {
|
|
440
|
+
return finish("start-failed", null, `${kickCmd.join(" ")} → code ${kick.code}${kick.stderr.trim() ? `: ${kick.stderr.trim()}` : ""}`);
|
|
441
|
+
}
|
|
442
|
+
const deadline = now() + budgetMs;
|
|
443
|
+
// Poll: kickstart returned immediately, so watch `launchctl print` until a
|
|
444
|
+
// COMPLETED run (not running + a recorded exit code) is visible.
|
|
445
|
+
for (;;) {
|
|
446
|
+
const printCmd = ["launchctl", "print", target];
|
|
447
|
+
const r = run(printCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
448
|
+
if (spawnedNothing(r)) {
|
|
449
|
+
return finish("manager-unavailable", null, `launchctl could not be run (${printCmd.join(" ")})`);
|
|
450
|
+
}
|
|
451
|
+
if (r.code === 0) {
|
|
452
|
+
const { running, lastExitCode } = parseLaunchdPrintExit(r.stdout);
|
|
453
|
+
if (!running && lastExitCode !== null) {
|
|
454
|
+
return lastExitCode === 0
|
|
455
|
+
? finish("success", 0, `${printCmd.join(" ")} → last exit code = 0`)
|
|
456
|
+
: finish("run-failed", lastExitCode, `${printCmd.join(" ")} → last exit code = ${lastExitCode}`);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
if (now() >= deadline) {
|
|
460
|
+
return finish("timeout", null, `no completed run visible in ${printCmd.join(" ")} within ${Math.round(budgetMs / 1000)}s`);
|
|
461
|
+
}
|
|
462
|
+
sleep(pollIntervalMs);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// linux
|
|
466
|
+
const unit = opts.linuxServiceUnit;
|
|
467
|
+
if (!unit)
|
|
468
|
+
throw new Error("verifyFirstRun: linuxServiceUnit is required on linux");
|
|
469
|
+
const startCmd = ["systemctl", "--user", "start", unit];
|
|
470
|
+
const start = run(startCmd, budgetMs);
|
|
471
|
+
if (spawnedNothing(start)) {
|
|
472
|
+
return finish("manager-unavailable", null, `systemctl could not be run (${startCmd.join(" ")})`);
|
|
473
|
+
}
|
|
474
|
+
if (/failed to connect to bus/i.test(start.stderr)) {
|
|
475
|
+
return finish("manager-unavailable", null, `${startCmd.join(" ")} → ${start.stderr.trim()}`);
|
|
476
|
+
}
|
|
477
|
+
if (start.code === null) {
|
|
478
|
+
return finish("timeout", null, `${startCmd.join(" ")} did not return within ${Math.round(budgetMs / 1000)}s`);
|
|
479
|
+
}
|
|
480
|
+
const showCmd = ["systemctl", "--user", "show", unit, "--property=ExecMainStatus,Result"];
|
|
481
|
+
const show = run(showCmd, STATUS_CHECK_TIMEOUT_MS);
|
|
482
|
+
const parsed = parseSystemdShowExit(show.stdout);
|
|
483
|
+
if (start.code === 0) {
|
|
484
|
+
// A blocking start of a oneshot exits 0 only when the run succeeded; the
|
|
485
|
+
// show read supplies the recorded status for the report.
|
|
486
|
+
return finish("success", parsed.execMainStatus ?? 0, `${startCmd.join(" ")} → ok`);
|
|
487
|
+
}
|
|
488
|
+
const resultTxt = parsed.result ? `, Result=${parsed.result}` : "";
|
|
489
|
+
return finish("run-failed", parsed.execMainStatus, `${startCmd.join(" ")} → code ${start.code}${resultTxt}${start.stderr.trim() ? `: ${start.stderr.trim()}` : ""}`);
|
|
490
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* postinstall.cts — `npm install -g` PATH check (flair#1134).
|
|
4
|
+
*
|
|
5
|
+
* THIS IS THE POSTINSTALL ENTRY (`package.json` "postinstall" requires
|
|
6
|
+
* dist/postinstall.cjs). It exists for exactly one case: a user-prefix
|
|
7
|
+
* global install (prefix = ~/.npm-global or similar) where npm links the
|
|
8
|
+
* `flair` bin into a directory that is not on PATH. The install "succeeds",
|
|
9
|
+
* then `flair` is command-not-found, and the docs' one-command install claim
|
|
10
|
+
* is a lie. This is the only surface that can reach the user at the moment
|
|
11
|
+
* that happens — a first-run banner can never run, because the bin the user
|
|
12
|
+
* would run is precisely what's unreachable.
|
|
13
|
+
*
|
|
14
|
+
* History note (#1078/#1008): the previous postinstall was removed because it
|
|
15
|
+
* was a NO-OP (chmod +x on bins npm already marks executable) that cost an
|
|
16
|
+
* install-script approval line. This one is not that: it does work no other
|
|
17
|
+
* surface can, it is read-only (env + one existsSync — no network, no
|
|
18
|
+
* writes), and it NEVER fails the install (every path swallows errors and
|
|
19
|
+
* exits 0). Where lifecycle scripts are suppressed — `--ignore-scripts`,
|
|
20
|
+
* bun without trustedDependencies, the fleet's tar-swap deploys (the #1078
|
|
21
|
+
* path, which manages PATH itself) — `flair doctor` runs the same check.
|
|
22
|
+
*
|
|
23
|
+
* Delivery: npm ≥8 hides lifecycle-script output on success (it only shows
|
|
24
|
+
* with --foreground-scripts or on failure), so printing to stderr alone
|
|
25
|
+
* would be invisible exactly where it matters. We write to /dev/tty first —
|
|
26
|
+
* that bypasses npm's captured pipes and lands on the interactive terminal —
|
|
27
|
+
* and fall back to stderr (visible under --foreground-scripts, bun, older
|
|
28
|
+
* npm; harmlessly buffered otherwise). No TTY (CI) ⇒ the fallback is the
|
|
29
|
+
* only path, which is the right amount of noise for CI: none visible.
|
|
30
|
+
*
|
|
31
|
+
* Like cli-shim.cts this is CommonJS on purpose: it parses and runs on any
|
|
32
|
+
* Node a user could have, and the ESM helper is loaded via dynamic import()
|
|
33
|
+
* with every failure swallowed — a postinstall must never be the thing that
|
|
34
|
+
* breaks an install.
|
|
35
|
+
*/
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
function writeToTty(text) {
|
|
38
|
+
// npm captures the script's stdout/stderr pipes; the controlling terminal
|
|
39
|
+
// does not go through them. Best-effort, sync, closed either way.
|
|
40
|
+
//
|
|
41
|
+
// FLAIR_POSTINSTALL_NO_TTY: unit tests spawn this entry and assert on the
|
|
42
|
+
// stderr fallback; without the knob, a dev running the suite from a real
|
|
43
|
+
// terminal would have /dev/tty succeed — scribbling the banner over their
|
|
44
|
+
// screen and blanking the stderr the test asserts on (flaky by
|
|
45
|
+
// environment). The tty path's own coverage is the manual pty
|
|
46
|
+
// verification recorded in the flair#1134 PR (`script`-wrapped npm i -g).
|
|
47
|
+
if (process.env.FLAIR_POSTINSTALL_NO_TTY)
|
|
48
|
+
return false;
|
|
49
|
+
try {
|
|
50
|
+
var fs = require("node:fs");
|
|
51
|
+
var fd = fs.openSync("/dev/tty", "w");
|
|
52
|
+
try {
|
|
53
|
+
fs.writeSync(fd, text);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
fs.closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
import("./install/global-bin-path.js")
|
|
65
|
+
.then(function (mod) {
|
|
66
|
+
try {
|
|
67
|
+
var path = require("node:path");
|
|
68
|
+
var message = mod.postinstallWarning({
|
|
69
|
+
npmConfigGlobal: process.env.npm_config_global,
|
|
70
|
+
npmConfigPrefix: process.env.npm_config_prefix,
|
|
71
|
+
pathEnv: process.env.PATH,
|
|
72
|
+
shell: process.env.SHELL,
|
|
73
|
+
// __dirname is <pkg>/dist — the package root is one up.
|
|
74
|
+
packageDir: path.resolve(__dirname, ".."),
|
|
75
|
+
});
|
|
76
|
+
if (message) {
|
|
77
|
+
var banner = "\n@tpsdev-ai/flair postinstall:\n\n" + message + "\n";
|
|
78
|
+
if (!writeToTty(banner))
|
|
79
|
+
console.error(banner);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
// Never fail the install over a diagnostic.
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
.catch(function () {
|
|
87
|
+
// Helper unloadable (ancient Node, partial install) — never fail the install.
|
|
88
|
+
});
|