@tpsdev-ai/flair 0.54.1 → 0.54.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.54.1",
3
- "commit": "0d841ae948846c95b1498d67330eaa3c5ddec84c",
4
- "builtAt": "2026-09-14T17:20:36.218Z",
2
+ "version": "0.54.2",
3
+ "commit": "842bd1a5903663aabf48e6629b3b5ba17f0884b8",
4
+ "builtAt": "2026-09-15T18:02:12.387Z",
5
5
  "builder": "tsc"
6
6
  }
@@ -12,7 +12,8 @@ import { opsApiBindFinding } from "../lib/ops-api-bind.js";
12
12
  import { flairCliVersion, unpinnedSpecWarning } from "../lib/mcp-spec.js";
13
13
  import { staleSessionStartHookPins } from "../lib/owned-pins.js";
14
14
  import * as render from "../render.js";
15
- import { checkVersion, formatVersionNudge, probeInstanceVersion } from "../version-check.js";
15
+ import { checkVersion, formatVersionNudge, probeInstanceVersion, FLAIR_PKG_NAME } from "../version-check.js";
16
+ import { resolveRegistryNotice } from "../lib/npm-registry.js";
16
17
  import { existsSync, readFileSync, statSync } from "node:fs";
17
18
  import { homedir } from "node:os";
18
19
  import { dirname, join } from "node:path";
@@ -187,6 +188,16 @@ export function register(program) {
187
188
  `Commands run through the CLI; the instance serves the data.`)}`);
188
189
  }
189
190
  }
191
+ // flair#1692: name the registry (and where it came from) on every doctor
192
+ // check, so a redirected mirror is visible to the operator.
193
+ const registryNotice = await resolveRegistryNotice(FLAIR_PKG_NAME);
194
+ if (registryNotice.line) {
195
+ console.log(` ${render.icons.info} ${render.wrap(render.c.dim, registryNotice.line)}`);
196
+ }
197
+ if (registryNotice.error) {
198
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, registryNotice.error)}`);
199
+ issues++;
200
+ }
190
201
  // 0.5 npm global bin dir on PATH (flair#1134) — a user-prefix
191
202
  // `npm i -g` succeeds and then `flair` is command-not-found because
192
203
  // <prefix>/bin never made it into PATH. postinstall warns at install
@@ -1,7 +1,8 @@
1
1
  import { resolveAdminUser } from "../lib/auth-resolve.js";
2
2
  import { opsApiBindFinding } from "../lib/ops-api-bind.js";
3
3
  import * as render from "../render.js";
4
- import { checkVersion, formatVersionNudge } from "../version-check.js";
4
+ import { checkVersion, formatVersionNudge, FLAIR_PKG_NAME } from "../version-check.js";
5
+ import { resolveRegistryNotice } from "../lib/npm-registry.js";
5
6
  let cli;
6
7
  /** Bind the cli-locals this module depends on. */
7
8
  export function bindCli(fns) {
@@ -193,6 +194,10 @@ export function register(program) {
193
194
  // fact is current (flair#1341). Independent of Harper health; runs either way.
194
195
  const versionCheckResult = await checkVersion(__pkgVersion);
195
196
  const versionNudge = formatVersionNudge(versionCheckResult);
197
+ // flair#1692: name the registry (and where it came from) on every status
198
+ // check, so a redirected mirror is never silent — including when the
199
+ // version answer came from cache and no network request was made.
200
+ const registryNotice = await resolveRegistryNotice(FLAIR_PKG_NAME);
196
201
  if (opts.json) {
197
202
  const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
198
203
  if (localWarnings.length > 0) {
@@ -205,6 +210,10 @@ export function register(program) {
205
210
  out.discoveredPort = discoveredPort;
206
211
  if (versionCheckResult.latest)
207
212
  out.latestVersion = versionCheckResult.latest;
213
+ if (registryNotice.line)
214
+ out.registry = registryNotice.line;
215
+ if (registryNotice.error)
216
+ out.registryError = registryNotice.error;
208
217
  console.log(JSON.stringify(out, null, 2));
209
218
  if (!healthy)
210
219
  process.exit(1);
@@ -213,6 +222,10 @@ export function register(program) {
213
222
  if (!healthy) {
214
223
  console.log(`Flair v${__pkgVersion} — 🔴 unreachable`);
215
224
  console.log(` URL: ${baseUrl}`);
225
+ if (registryNotice.line)
226
+ console.log(` ${registryNotice.line}`);
227
+ if (registryNotice.error)
228
+ console.log(` ⚠ ${registryNotice.error}`);
216
229
  if (discoveredPort != null) {
217
230
  const altUrl = `http://127.0.0.1:${discoveredPort}`;
218
231
  console.log(`\n ⚠ Found a Flair daemon listening on port ${discoveredPort} (URL: ${altUrl}).`);
@@ -288,6 +301,10 @@ export function register(program) {
288
301
  const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
289
302
  console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
290
303
  console.log(render.kv("URL", baseUrl));
304
+ if (registryNotice.line)
305
+ console.log(render.kv("Registry", registryNotice.line.replace(/^registry:\s*/, "")));
306
+ if (registryNotice.error)
307
+ console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, registryNotice.error)}`);
291
308
  if (versionNudge) {
292
309
  const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
293
310
  console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
@@ -7,6 +7,7 @@ import { defaultKeysDir } from "../lib/auth-resolve.js";
7
7
  import { renderVerifiedSummary } from "../lib/doctor-run.js";
8
8
  import { isDetached, renderDetachedWarning } from "../lib/launchd-management.js";
9
9
  import { FLAIR_MCP_PACKAGE, clearFlairCliVersionCache } from "../lib/mcp-spec.js";
10
+ import { createRegistryNoticePrinter, fetchLatestVersion, isStrictSemver } from "../lib/npm-registry.js";
10
11
  import { ownedPinRefreshShouldReport, refreshOwnedPins } from "../lib/owned-pins.js";
11
12
  import { extractSnapshotSafely, validateSnapshotArchive } from "../lib/safe-snapshot-extract.js";
12
13
  import { collectUpgradeExecPathWarning, findFlairPackageDir, resolveNpmGlobalFlairPackage, resolveServingFlairPackage } from "../lib/upgrade-exec-path.js";
@@ -836,17 +837,36 @@ export function register(program) {
836
837
  },
837
838
  ];
838
839
  const findings = [];
840
+ // flair#1692: name the registry (and where it came from) the moment it is
841
+ // resolved, so a redirected registry is visible to the operator before
842
+ // anything is fetched or installed. One line per distinct registry.
843
+ const noticeRegistry = createRegistryNoticePrinter();
839
844
  for (const { name, probe, kind, transitive } of packages) {
840
845
  if (transitive && !showAll)
841
846
  continue;
842
847
  try {
843
848
  let registryLatest = null;
844
849
  try {
845
- const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
846
- if (res.ok) {
847
- const data = await res.json();
848
- registryLatest = typeof data.version === "string" && data.version ? data.version : null;
850
+ // flair#1688: resolve the registry npm is configured to use for this
851
+ // package (scope mapping + .npmrc + env) instead of a hardcoded host.
852
+ // flair#1692: print it, refuse disallowed schemes, disable redirects,
853
+ // and validate the returned value as strict semver before it can be
854
+ // used as an `npm install` spec.
855
+ const lookup = await fetchLatestVersion(name, {
856
+ timeoutMs: 5000,
857
+ onRegistry: noticeRegistry,
858
+ });
859
+ if (lookup.kind === "ok") {
860
+ registryLatest = lookup.version;
861
+ }
862
+ else if (lookup.kind === "invalid") {
863
+ console.error(` ⚠ ${name}: registry returned a non-semver "latest" (${JSON.stringify(lookup.value)}) ` +
864
+ `from ${lookup.registry.url} — refusing to use it as an install spec.`);
849
865
  }
866
+ else if (lookup.kind === "refused") {
867
+ console.error(lookup.message);
868
+ }
869
+ // kind === "unavailable": offline/timed out — the pin path must still work.
850
870
  }
851
871
  catch { /* /latest timed out or failed — pin path must still work */ }
852
872
  let latest;
@@ -867,7 +887,14 @@ export function register(program) {
867
887
  continue;
868
888
  latest = registryLatest;
869
889
  }
870
- if (name === FLAIR_PKG_NAME && latest !== "unknown") {
890
+ // flair#1692: a non-semver target must never reach an install spec
891
+ // (npm treats `pkg@<url>` as a remote tarball). This also covers the
892
+ // operator pin on the plain-tree lane.
893
+ if (!isStrictSemver(latest)) {
894
+ console.error(` ⚠ ${name}: refusing non-semver install target ${JSON.stringify(latest)} — expected a version like 1.2.3.`);
895
+ continue;
896
+ }
897
+ if (name === FLAIR_PKG_NAME) {
871
898
  try {
872
899
  primeVersionCheckCache(latest);
873
900
  }
@@ -1225,6 +1252,15 @@ export function register(program) {
1225
1252
  let flairInstallFailed = false;
1226
1253
  for (const { pkg, latest } of npmUpgrades) {
1227
1254
  try {
1255
+ // flair#1692 backstop: the listing validated this, but the install is
1256
+ // the point of no return. npm accepts `pkg@<url>` as a remote-tarball
1257
+ // spec, so a non-semver target must never reach this argv.
1258
+ if (!isStrictSemver(latest)) {
1259
+ console.error(` ❌ ${pkg} upgrade skipped: non-semver target ${JSON.stringify(latest)}`);
1260
+ if (pkg === FLAIR_PKG_NAME)
1261
+ flairInstallFailed = true;
1262
+ continue;
1263
+ }
1228
1264
  if (treePlan && pkg === FLAIR_PKG_NAME) {
1229
1265
  console.log(` Fetching ${pkg}@${latest} (npm pack) and swapping ${treePlan.treeDir}...`);
1230
1266
  await applyPlainTreeUpgrade(treePlan);
@@ -19,6 +19,7 @@
19
19
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
20
20
  import { join, resolve } from "node:path";
21
21
  import { homedir } from "node:os";
22
+ import { fetchDeclaredDependencies } from "./lib/npm-registry.js";
22
23
  /** Filename of the engine-version stamp inside the data directory. */
23
24
  export const ENGINE_VERSION_STAMP = "engine-version.txt";
24
25
  /** Root directory for pre-upgrade snapshots (~/.flair/upgrade-snapshots). */
@@ -47,11 +48,18 @@ export function readInstalledHarperVersion(packageRoot) {
47
48
  */
48
49
  export async function fetchDeclaredHarperVersion(flairVersion) {
49
50
  try {
50
- const res = await fetch(`https://registry.npmjs.org/@tpsdev-ai/flair/${flairVersion}`, { signal: AbortSignal.timeout(5000) });
51
- if (!res.ok)
51
+ // flair#1688: resolve the configured registry rather than assuming npmjs —
52
+ // the engine-version decision must look at the same package the upgrade
53
+ // will actually install. flair#1692: the same scheme allowlist, redirect
54
+ // refusal, npm transport, and non-default-registry handling apply.
55
+ const result = await fetchDeclaredDependencies("@tpsdev-ai/flair", flairVersion, { timeoutMs: 5000 });
56
+ if (result.kind === "refused") {
57
+ console.error(result.message);
52
58
  return null;
53
- const data = await res.json();
54
- return data.dependencies?.harper ?? data.dependencies?.["@harperfast/harper"] ?? null;
59
+ }
60
+ if (result.kind !== "ok")
61
+ return null;
62
+ return result.dependencies?.harper ?? result.dependencies?.["@harperfast/harper"] ?? null;
55
63
  }
56
64
  catch {
57
65
  return null;
@@ -30,6 +30,7 @@ import { mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "no
30
30
  import { join } from "node:path";
31
31
  import { tmpdir } from "node:os";
32
32
  import { createRequire } from "node:module";
33
+ import { createRegistryNoticePrinter, fetchDeclaredDependencies, fetchLatestVersion } from "./lib/npm-registry.js";
33
34
  /**
34
35
  * Minimum harper version whose component packager works when the
35
36
  * package root is under node_modules (flair#513 — the empty-tarball fix landed
@@ -154,30 +155,44 @@ export function buildDeployablePackageJson(flairVersion, pin) {
154
155
  return pkg;
155
156
  }
156
157
  // ─── Default (real) dependency implementations ──────────────────────────────
157
- const REGISTRY = "https://registry.npmjs.org";
158
+ // flair#1692: one printer per process, so a Fabric upgrade names the registry
159
+ // (and source) it stages from without repeating the line per lookup.
160
+ const noticeRegistry = createRegistryNoticePrinter();
158
161
  async function defaultFetchLatestFlairVersion() {
159
- const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/latest`, {
160
- signal: AbortSignal.timeout(10_000),
161
- });
162
- if (!res.ok) {
163
- throw new Error(`npm registry returned ${res.status} for ${FLAIR_PKG}/latest`);
162
+ // flair#1688: the configured registry (scoped `@tpsdev-ai:registry`,
163
+ // project/user/global .npmrc, env `npm_config_registry`), not a hardcoded
164
+ // host — a Fabric upgrade must stage the package from the mirror the
165
+ // operator configured, or fail rather than silently use the public one.
166
+ // flair#1692: the value is validated as strict semver before it is used as
167
+ // `dependencies` in the staged package.json (npm accepts `pkg@<url>` as a
168
+ // remote-tarball spec, so an unvalidated value is an arbitrary-install
169
+ // primitive).
170
+ const result = await fetchLatestVersion(FLAIR_PKG, { timeoutMs: 10_000, onRegistry: noticeRegistry });
171
+ if (result.kind === "ok")
172
+ return result.version;
173
+ if (result.kind === "refused")
174
+ throw new Error(result.message);
175
+ if (result.kind === "invalid") {
176
+ throw new Error(`npm registry returned a non-semver latest version (${JSON.stringify(result.value)}) for ${FLAIR_PKG} ` +
177
+ `from ${result.registry.url} (source: ${result.registry.source}) — refusing to use it`);
164
178
  }
165
- const data = (await res.json());
166
- if (!data.version)
167
- throw new Error(`No version in registry response for ${FLAIR_PKG}`);
168
- return data.version;
179
+ throw new Error(`Could not determine ${FLAIR_PKG}/latest: ${result.message}`);
169
180
  }
170
181
  async function defaultFetchDeclaredHarperVersion(flairVersion) {
171
- const res = await fetch(`${REGISTRY}/${FLAIR_PKG}/${flairVersion}`, {
172
- signal: AbortSignal.timeout(10_000),
182
+ const result = await fetchDeclaredDependencies(FLAIR_PKG, flairVersion, {
183
+ timeoutMs: 10_000,
184
+ onRegistry: noticeRegistry,
173
185
  });
174
- if (!res.ok)
186
+ if (result.kind === "refused") {
187
+ console.error(result.message);
188
+ return null;
189
+ }
190
+ if (result.kind !== "ok")
175
191
  return null;
176
- const data = (await res.json());
177
192
  // Either package name (flair#870) — pre-rename flair versions declare the
178
193
  // scoped one, and those are precisely the versions that may need an override.
179
194
  for (const name of HARPER_PKG_NAMES) {
180
- const declared = data.dependencies?.[name];
195
+ const declared = result.dependencies?.[name];
181
196
  if (declared)
182
197
  return declared;
183
198
  }
@@ -0,0 +1,578 @@
1
+ /**
2
+ * npm-registry.ts — resolve the npm registry to query for a package, the way
3
+ * npm itself would, and fetch from it with npm's security boundaries applied
4
+ * (flair#1688, security review flair#1692).
5
+ *
6
+ * WHY THIS EXISTS. `flair upgrade`'s update check and Fabric version lookups
7
+ * used to fetch `https://registry.npmjs.org/<pkg>/latest` with a HARDCODED
8
+ * host. A user on a private mirror, an air-gapped registry, or a vetted
9
+ * internal proxy configured through npm never influenced those fetches: the
10
+ * upgrade path compared against the public registry's `latest` (reporting
11
+ * "you are current" when the mirror had a different/newer release) and, in CI,
12
+ * defeated the scoped `@tpsdev-ai:registry` config the macOS launchd lane sets.
13
+ * A supply-chain control (route all installs through the internal mirror) was
14
+ * bypassed by the update check itself.
15
+ *
16
+ * WHAT IT DOES. Given a package name, returns the registry base URL npm would
17
+ * use for it, honouring the same configuration npm does:
18
+ *
19
+ * 1. the `@<scope>:registry` mapping for a scoped package (scope-specific
20
+ * config beats the default for that scope),
21
+ * 2. the default `registry`,
22
+ * 3. npm's own precedence: env `npm_config_<key>` > project `.npmrc` > user
23
+ * `.npmrc` > global `.npmrc` > npm's builtin default.
24
+ *
25
+ * Rather than reimplement npm's ini parsing and precedence (which would surely
26
+ * drift from npm), we ask npm: `npm config list` already resolves every layer
27
+ * in the right order, and — unlike `npm config get` — its per-section headers
28
+ * let us name the layer a value came from, so the operator can SEE a
29
+ * redirected registry (`registry: … (source: user .npmrc …)`). The env layer
30
+ * is read directly too, as a fast path so tests (and scripts that export
31
+ * `npm_config_*`) never need a subprocess.
32
+ *
33
+ * SECURITY BOUNDARIES (flair#1692). This module is where every version
34
+ * decision flows through, so the resolver is the right chokepoint:
35
+ *
36
+ * - SCHEME ALLOWLIST. Only `https:` is fetched by default. `http:` is
37
+ * allowed ONLY for a loopback host (127.0.0.1 / localhost / ::1) — the CI
38
+ * lane's registry — or when the operator explicitly opts in with
39
+ * `FLAIR_ALLOW_INSECURE_REGISTRY=1`, in which case the resolution is
40
+ * reported as INSECURE. `file:`/`ftp:`/anything else is refused outright.
41
+ * A refusal names the actor, the state, and the remedy; it is never silent.
42
+ *
43
+ * - SEMVER VALIDATION. A registry may return any string as `latest`, and npm
44
+ * accepts `pkg@https://attacker/x.tgz` as a remote-tarball install spec.
45
+ * So a fetched version is validated as strict semver BEFORE it can be used
46
+ * as an install spec (see `isStrictSemver` / `fetchLatestVersion`). A
47
+ * non-semver value is refused and printed, never installed.
48
+ *
49
+ * - REDIRECTS. The version fetch passes `redirect: "error"`: a 302 from an
50
+ * allowed registry cannot silently land on a disallowed host, which would
51
+ * otherwise bypass the scheme allowlist.
52
+ *
53
+ * - TRANSPORT. A bare `fetch()` cannot honour npm's `strict-ssl` / `cafile`
54
+ * / `_authToken`. When the configured registry is a NON-default mirror
55
+ * (i.e. not the public npmjs default) AND npm has transport config we
56
+ * cannot replicate, the lookup is delegated to
57
+ * `npm view <pkg> version --json --registry <url>`, which gets URL, TLS
58
+ * trust, and auth from npm in one step. This is the reviewer's preferred
59
+ * transport (flair#1692 item 4). For the public default npmjs registry we
60
+ * fetch anonymously — public reads need no trust config — and the residual
61
+ * gap is: a custom CA/auth token set for a registry OTHER than the
62
+ * configured one is not carried. `_authToken` cannot be read back anyway:
63
+ * npm marks auth options "protected" and refuses to print them.
64
+ *
65
+ * DEFAULT. When nothing is configured — and when npm is absent or errors —
66
+ * this returns npm's public default, `https://registry.npmjs.org`. A user with
67
+ * no registry configured must see exactly the behaviour they saw before this
68
+ * module existed.
69
+ */
70
+ import { execFile } from "node:child_process";
71
+ /** npm's public default, and the pre-flair#1688 behaviour. No trailing slash. */
72
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
73
+ /**
74
+ * Opt-in for a deliberately plain-http registry that is not loopback. Any
75
+ * other non-https scheme is refused even with this set (flair#1692).
76
+ */
77
+ export const INSECURE_REGISTRY_ENV = "FLAIR_ALLOW_INSECURE_REGISTRY";
78
+ /**
79
+ * Thrown when the configured registry is not allowed. The message already
80
+ * contains actor + state + remedy, so callers only have to print it.
81
+ */
82
+ export class RegistryRefusalError extends Error {
83
+ rawUrl;
84
+ source;
85
+ constructor(message, rawUrl, source) {
86
+ super(message);
87
+ this.name = "RegistryRefusalError";
88
+ this.rawUrl = rawUrl;
89
+ this.source = source;
90
+ }
91
+ }
92
+ // ─── npm config: value + source ─────────────────────────────────────────────
93
+ /**
94
+ * npm represents "unset" as the literal string `undefined` from
95
+ * `npm config get`, and may pad with whitespace. Normalise all of that, plus a
96
+ * trailing slash, to a clean base URL (or null).
97
+ */
98
+ function normalizeRegistryValue(raw) {
99
+ if (raw == null)
100
+ return null;
101
+ const trimmed = raw.trim();
102
+ if (trimmed === "" || trimmed === "undefined" || trimmed === "null")
103
+ return null;
104
+ return trimmed.replace(/\/+$/, "");
105
+ }
106
+ /**
107
+ * The `@scope` of a package name, or null for unscoped names.
108
+ * `@tpsdev-ai/flair` → `@tpsdev-ai`; `flair` → null.
109
+ *
110
+ * The scope is restricted to npm's legal scope characters. The result is
111
+ * interpolated into an npm config key (and, on Windows, a shell command), so
112
+ * a name that is not a real npm scope must never reach that path.
113
+ */
114
+ export function packageScope(packageName) {
115
+ if (!packageName.startsWith("@"))
116
+ return null;
117
+ const slash = packageName.indexOf("/");
118
+ if (slash <= 1)
119
+ return null;
120
+ const scope = packageName.slice(0, slash);
121
+ return /^@[A-Za-z0-9._~-]+$/.test(scope) ? scope : null;
122
+ }
123
+ function describeConfigLayer(layer, from) {
124
+ switch (layer) {
125
+ case "env":
126
+ return "env";
127
+ case "cli":
128
+ return "command line";
129
+ case "project":
130
+ return `project .npmrc (${from})`;
131
+ case "user":
132
+ return `user .npmrc (${from})`;
133
+ case "global":
134
+ return `global .npmrc (${from})`;
135
+ case "builtin":
136
+ return "npm builtin";
137
+ default:
138
+ return from || "npm config";
139
+ }
140
+ }
141
+ /**
142
+ * Parse the human output of `npm config list` into key → {value, source}.
143
+ *
144
+ * Only non-default config is printed, grouped under section headers such as
145
+ * `; "project" config from /path/.npmrc`. Values already overridden by a
146
+ * higher layer are printed COMMENTED OUT (`; registry = … ; overridden by …`),
147
+ * so the single active line per key wins — which is exactly npm's precedence.
148
+ * `publishConfig` values are publish-time only and are ignored.
149
+ *
150
+ * Exported for the unit tests that pin the parse without spawning npm.
151
+ */
152
+ export function parseNpmConfigList(stdout) {
153
+ const entries = new Map();
154
+ let layer = "unknown";
155
+ let source = "npm config";
156
+ let ignore = false;
157
+ for (const rawLine of String(stdout).split(/\r?\n/)) {
158
+ const line = rawLine.trim();
159
+ if (line === "")
160
+ continue;
161
+ const header = line.match(/^;\s*"([^"]+)"(?:\s+config)?\s+from\s+(.+)$/);
162
+ if (header) {
163
+ const name = header[1];
164
+ const from = header[2].trim();
165
+ if (name === "publishConfig") {
166
+ ignore = true;
167
+ layer = "publish";
168
+ source = from;
169
+ continue;
170
+ }
171
+ ignore = false;
172
+ const knownLayers = ["env", "cli", "project", "user", "global", "builtin"];
173
+ layer = knownLayers.includes(name)
174
+ ? name
175
+ : "unknown";
176
+ source = describeConfigLayer(layer, from);
177
+ continue;
178
+ }
179
+ // Comments (including overridden values) and the trailing node-version
180
+ // footer are not config we can use.
181
+ if (line.startsWith(";"))
182
+ continue;
183
+ if (ignore)
184
+ continue;
185
+ const m = line.match(/^([^=]+?)\s*=\s*(.*)$/);
186
+ if (!m)
187
+ continue;
188
+ const key = m[1].trim();
189
+ let value = m[2].trim();
190
+ // npm redacts auth in `npm config list`; presence is all we need.
191
+ if (value === "(protected)")
192
+ value = "";
193
+ else if (value.startsWith('"') && value.endsWith('"')) {
194
+ try {
195
+ value = JSON.parse(value);
196
+ }
197
+ catch {
198
+ value = value.slice(1, -1);
199
+ }
200
+ }
201
+ entries.set(key, { value, layer, source });
202
+ }
203
+ return entries;
204
+ }
205
+ // ─── Default readers: ask npm, memoised per process ─────────────────────────
206
+ let entriesCache = null;
207
+ function runNpmConfigList() {
208
+ return new Promise((resolve) => {
209
+ execFile(process.platform === "win32" ? "npm.cmd" : "npm", ["config", "list"], { timeout: 5000, encoding: "utf-8", shell: process.platform === "win32" }, (err, stdout) => {
210
+ // npm missing, timed out, or errored — treat as "cannot determine".
211
+ // The caller falls back to the public default, never a broken URL.
212
+ if (err)
213
+ return resolve(new Map());
214
+ try {
215
+ resolve(parseNpmConfigList(String(stdout)));
216
+ }
217
+ catch {
218
+ resolve(new Map());
219
+ }
220
+ });
221
+ });
222
+ }
223
+ /**
224
+ * Every explicitly-set npm config value, resolved once per process (the config
225
+ * does not change under a running command).
226
+ */
227
+ export function defaultNpmConfigEntries() {
228
+ if (!entriesCache)
229
+ entriesCache = runNpmConfigList();
230
+ return entriesCache;
231
+ }
232
+ /** Default `NpmConfigEntryReader` — one key from the memoised config map. */
233
+ export async function defaultNpmConfigEntryReader(key) {
234
+ const entries = await defaultNpmConfigEntries();
235
+ return entries.get(key) ?? null;
236
+ }
237
+ /** Drop the memoised npm answers — tests that change registry env between runs. */
238
+ export function clearNpmRegistryCache() {
239
+ entriesCache = null;
240
+ }
241
+ // ─── Scheme allowlist ───────────────────────────────────────────────────────
242
+ /** The loopback hosts `http:` is allowed for without an opt-in (CI lane). */
243
+ export function isLoopbackHost(hostname) {
244
+ const h = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
245
+ return h === "127.0.0.1" || h === "localhost" || h === "::1";
246
+ }
247
+ function insecureOptedIn(env) {
248
+ const raw = (env[INSECURE_REGISTRY_ENV] ?? "").trim().toLowerCase();
249
+ return raw === "1" || raw === "true" || raw === "yes";
250
+ }
251
+ function buildRegistryRefusal(rawUrl, source, problem, scheme) {
252
+ const actor = `the npm registry is configured as ${JSON.stringify(rawUrl)} (source: ${source})`;
253
+ const state = scheme === "http:"
254
+ ? "Flair only queries registries over https; plain http is allowed only for a loopback host (127.0.0.1, localhost, ::1)."
255
+ : `Flair refuses the "${scheme ?? "unknown"}" scheme for a registry — only http(s) is considered at all.`;
256
+ const remedy = scheme === "http:"
257
+ ? `Point \`registry\` / \`@scope:registry\` at an https URL, or set ${INSECURE_REGISTRY_ENV}=1 to allow this plain-http registry deliberately (file:/ftp: are never allowed).`
258
+ : "Point `registry` / `@scope:registry` at an https URL.";
259
+ return `Refusing npm registry: ${problem}.\n actor: ${actor}\n state: ${state}\n remedy: ${remedy}`;
260
+ }
261
+ /**
262
+ * Validate a normalized registry URL against the scheme allowlist.
263
+ *
264
+ * Returns the resolution on success (with `insecure` set when the explicit
265
+ * opt-in permitted a non-loopback `http:` registry) and throws a
266
+ * `RegistryRefusalError` whose message carries actor + state + remedy.
267
+ */
268
+ export function validateRegistryUrl(rawUrl, source, env = process.env) {
269
+ let parsed;
270
+ try {
271
+ parsed = new URL(rawUrl);
272
+ }
273
+ catch {
274
+ throw new RegistryRefusalError(buildRegistryRefusal(rawUrl, source, "it is not a valid URL", null), rawUrl, source);
275
+ }
276
+ const scheme = parsed.protocol.toLowerCase();
277
+ if (scheme === "https:") {
278
+ return { url: rawUrl, source, insecure: false };
279
+ }
280
+ if (scheme === "http:") {
281
+ if (isLoopbackHost(parsed.hostname)) {
282
+ return { url: rawUrl, source, insecure: false };
283
+ }
284
+ if (insecureOptedIn(env)) {
285
+ return { url: rawUrl, source, insecure: true };
286
+ }
287
+ throw new RegistryRefusalError(buildRegistryRefusal(rawUrl, source, "plain http is not allowed for a non-loopback host", scheme), rawUrl, source);
288
+ }
289
+ throw new RegistryRefusalError(buildRegistryRefusal(rawUrl, source, `the "${scheme}" scheme is never fetched`, scheme), rawUrl, source);
290
+ }
291
+ async function readEntry(readConfig, key) {
292
+ try {
293
+ const entry = await readConfig(key);
294
+ if (!entry)
295
+ return null;
296
+ const value = normalizeRegistryValue(entry.value);
297
+ if (!value)
298
+ return null;
299
+ return { ...entry, value };
300
+ }
301
+ catch {
302
+ // A custom reader must never break version resolution — treat a throw as
303
+ // "unset" and let the fallback (public default) apply.
304
+ return null;
305
+ }
306
+ }
307
+ /**
308
+ * Resolve the registry for `packageName` together with HOW it was chosen, and
309
+ * validate its scheme. Throws `RegistryRefusalError` for a disallowed scheme.
310
+ */
311
+ export async function resolveNpmRegistryDetailed(packageName, deps = {}) {
312
+ const env = deps.env ?? process.env;
313
+ const readConfig = deps.readConfig ?? defaultNpmConfigEntryReader;
314
+ // Scope mapping first: for a scoped package a configured `@scope:registry`
315
+ // beats the default `registry`, and npm resolves project/user/global files.
316
+ const scope = packageScope(packageName);
317
+ if (scope) {
318
+ const envScoped = normalizeRegistryValue(env[`npm_config_${scope}:registry`]);
319
+ if (envScoped) {
320
+ return validateRegistryUrl(envScoped, `env npm_config_${scope}:registry`, env);
321
+ }
322
+ const scoped = await readEntry(readConfig, `${scope}:registry`);
323
+ if (scoped) {
324
+ return validateRegistryUrl(scoped.value, `${scope}:registry (${scoped.source})`, env);
325
+ }
326
+ }
327
+ // env `npm_config_registry` beats any .npmrc default, so read it before npm.
328
+ const envDefault = normalizeRegistryValue(env.npm_config_registry);
329
+ if (envDefault) {
330
+ return validateRegistryUrl(envDefault, "env npm_config_registry", env);
331
+ }
332
+ const configured = await readEntry(readConfig, "registry");
333
+ if (configured) {
334
+ return validateRegistryUrl(configured.value, `registry (${configured.source})`, env);
335
+ }
336
+ return validateRegistryUrl(DEFAULT_NPM_REGISTRY, "default npm public registry", env);
337
+ }
338
+ /**
339
+ * Resolve the registry base URL for `packageName` the way npm would, without
340
+ * the source metadata. Throws `RegistryRefusalError` for a disallowed scheme.
341
+ */
342
+ export async function resolveNpmRegistry(packageName, deps = {}) {
343
+ return (await resolveNpmRegistryDetailed(packageName, deps)).url;
344
+ }
345
+ /** One operator-facing line naming the registry and where it came from. */
346
+ export function formatRegistryLine(res) {
347
+ const flag = res.insecure ? " [INSECURE]" : "";
348
+ return `registry: ${res.url} (source: ${res.source})${flag}`;
349
+ }
350
+ /**
351
+ * A printer that emits each distinct registry line once per process, so a
352
+ * listing that resolves the same registry for N packages prints one line.
353
+ */
354
+ export function createRegistryNoticePrinter(sink = (line) => console.log(line)) {
355
+ const seen = new Set();
356
+ return (res) => {
357
+ const line = formatRegistryLine(res);
358
+ if (seen.has(line))
359
+ return;
360
+ seen.add(line);
361
+ sink(line);
362
+ };
363
+ }
364
+ /**
365
+ * Resolve + validate for a caller that only wants to REPORT the registry
366
+ * (status/doctor). Never throws: a refusal comes back as `error`.
367
+ */
368
+ export async function resolveRegistryNotice(packageName, deps = {}) {
369
+ try {
370
+ const res = await resolveNpmRegistryDetailed(packageName, deps);
371
+ return { line: formatRegistryLine(res), error: null };
372
+ }
373
+ catch (err) {
374
+ if (err instanceof RegistryRefusalError)
375
+ return { line: null, error: err.message };
376
+ return { line: null, error: err instanceof Error ? err.message : String(err) };
377
+ }
378
+ }
379
+ // ─── Strict semver ──────────────────────────────────────────────────────────
380
+ /**
381
+ * The semver.org regex, exact. A registry value must match this before it can
382
+ * be used as an `npm install` spec: npm accepts `pkg@<url>` as a remote-tarball
383
+ * spec, so a hostile/compromised registry returning a URL as `latest` would
384
+ * otherwise turn the update check into an arbitrary install (flair#1692).
385
+ */
386
+ const STRICT_SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
387
+ /** True when `value` is a strict semver version (not a range, tag, or URL). */
388
+ export function isStrictSemver(value) {
389
+ return typeof value === "string" && STRICT_SEMVER.test(value.trim());
390
+ }
391
+ function boolValue(value) {
392
+ return value == null ? true : !/^(false|0|no)$/i.test(value.trim());
393
+ }
394
+ /**
395
+ * The npm auth-config key that applies to `registryUrl`, or null. npm stores a
396
+ * registry token as `//host/path/:_authToken` (longest matching prefix wins),
397
+ * plus a legacy unscoped `_authToken`. npm refuses to print the VALUE; we only
398
+ * need to know one exists so we can delegate to `npm view` (flair#1692 item 4).
399
+ */
400
+ export function registryAuthTokenKey(registryUrl, entries) {
401
+ if (entries.has("_authToken"))
402
+ return "_authToken";
403
+ let parsed;
404
+ try {
405
+ parsed = new URL(registryUrl);
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ const hostPath = `//${parsed.host}${parsed.pathname.replace(/\/+$/, "")}/`;
411
+ let best = null;
412
+ for (const key of entries.keys()) {
413
+ if (!key.endsWith(":_authToken"))
414
+ continue;
415
+ const prefix = key.slice(0, -":_authToken".length);
416
+ if (!prefix.endsWith("/"))
417
+ continue;
418
+ if (!hostPath.startsWith(prefix) && !prefix.startsWith(hostPath))
419
+ continue;
420
+ if (best === null || prefix.length > best.length - ":_authToken".length)
421
+ best = key;
422
+ }
423
+ return best;
424
+ }
425
+ /** Read npm's transport trust config (TLS + auth) for the fetch decision. */
426
+ export async function readRegistryTransport(registryUrl, deps = {}) {
427
+ const readConfig = deps.readConfig ?? defaultNpmConfigEntryReader;
428
+ const readMap = deps.readConfigMap ?? defaultNpmConfigEntries;
429
+ const strictSsl = boolValue((await readEntry(readConfig, "strict-ssl"))?.value);
430
+ const cafile = (await readEntry(readConfig, "cafile"))?.value ?? null;
431
+ const ca = (await readEntry(readConfig, "ca"))?.value ?? null;
432
+ let authTokenKey = null;
433
+ try {
434
+ authTokenKey = registryAuthTokenKey(registryUrl, await readMap());
435
+ }
436
+ catch {
437
+ authTokenKey = null;
438
+ }
439
+ return { strictSsl, cafile, ca, authTokenKey };
440
+ }
441
+ /**
442
+ * True when the transport config cannot be honoured by a bare `fetch()` and
443
+ * the lookup must go through npm (`npm view`). Covers a custom CA, a disabled
444
+ * TLS check, and a configured registry auth token.
445
+ */
446
+ export function registryNeedsNpmTransport(config) {
447
+ return !config.strictSsl || config.cafile != null || config.ca != null || config.authTokenKey != null;
448
+ }
449
+ /**
450
+ * GET a registry URL as JSON with redirects REFUSED. `redirect: "error"` is
451
+ * load-bearing: without it a 302 from an allow-listed registry would silently
452
+ * land on a disallowed host (flair#1692 item 5).
453
+ */
454
+ async function fetchRegistryJson(url, timeoutMs, fetchImpl) {
455
+ const doFetch = fetchImpl ?? fetch;
456
+ try {
457
+ const res = await doFetch(url, {
458
+ redirect: "error",
459
+ headers: { accept: "application/json" },
460
+ signal: AbortSignal.timeout(timeoutMs),
461
+ });
462
+ if (!res.ok)
463
+ return { ok: false, message: `registry returned HTTP ${res.status}` };
464
+ return { ok: true, data: await res.json(), message: "" };
465
+ }
466
+ catch (err) {
467
+ const message = err instanceof Error && err.message ? err.message : "registry fetch failed";
468
+ return { ok: false, message };
469
+ }
470
+ }
471
+ function npmBin() {
472
+ return process.platform === "win32" ? "npm.cmd" : "npm";
473
+ }
474
+ /**
475
+ * `npm view <spec> <field> --json`. Used when npm transport config (CA, TLS
476
+ * override, auth token) cannot be replicated by `fetch()`.
477
+ */
478
+ function runNpmViewJson(spec, field, registryUrl, timeoutMs) {
479
+ return new Promise((resolve) => {
480
+ execFile(npmBin(), ["view", spec, field, "--json", "--registry", registryUrl], { timeout: timeoutMs, encoding: "utf-8", shell: process.platform === "win32" }, (err, stdout, stderr) => {
481
+ if (err) {
482
+ const message = String(stderr || err.message || "npm view failed").trim();
483
+ return resolve({ ok: false, message });
484
+ }
485
+ try {
486
+ resolve({ ok: true, data: JSON.parse(String(stdout).trim()), message: "" });
487
+ }
488
+ catch {
489
+ resolve({ ok: false, message: "npm view returned non-JSON output" });
490
+ }
491
+ });
492
+ });
493
+ }
494
+ /** True when this registry should be queried through npm rather than fetch(). */
495
+ async function useNpmTransport(registry, deps) {
496
+ if (registry.url === DEFAULT_NPM_REGISTRY)
497
+ return false;
498
+ const transport = await readRegistryTransport(registry.url, deps);
499
+ return registryNeedsNpmTransport(transport);
500
+ }
501
+ /**
502
+ * Resolve the registry for `packageName` and fetch its `latest` dist-tag,
503
+ * validating the result as strict semver before returning it. Never throws for
504
+ * a refusal or a network failure — those come back as discriminated results so
505
+ * every caller can decide whether to skip, warn, or abort.
506
+ */
507
+ export async function fetchLatestVersion(packageName, deps = {}) {
508
+ let registry;
509
+ try {
510
+ registry = await resolveNpmRegistryDetailed(packageName, deps);
511
+ }
512
+ catch (err) {
513
+ if (err instanceof RegistryRefusalError)
514
+ return { kind: "refused", message: err.message };
515
+ return { kind: "refused", message: err instanceof Error ? err.message : String(err) };
516
+ }
517
+ deps.onRegistry?.(registry);
518
+ const timeoutMs = deps.timeoutMs ?? 5000;
519
+ let raw = null;
520
+ if (await useNpmTransport(registry, deps)) {
521
+ const out = await runNpmViewJson(packageName, "version", registry.url, timeoutMs);
522
+ if (!out.ok)
523
+ return { kind: "unavailable", message: out.message, registry };
524
+ if (typeof out.data === "string")
525
+ raw = out.data;
526
+ }
527
+ else {
528
+ const out = await fetchRegistryJson(`${registry.url}/${packageName}/latest`, timeoutMs, deps.fetchImpl);
529
+ if (!out.ok)
530
+ return { kind: "unavailable", message: out.message, registry };
531
+ const data = out.data;
532
+ if (data && typeof data.version === "string")
533
+ raw = data.version;
534
+ }
535
+ if (raw == null || raw.trim() === "") {
536
+ return { kind: "unavailable", message: "registry response had no version", registry };
537
+ }
538
+ const value = raw.trim();
539
+ if (!isStrictSemver(value))
540
+ return { kind: "invalid", value, registry };
541
+ return { kind: "ok", version: value, registry };
542
+ }
543
+ /**
544
+ * Resolve the registry for `packageName` and fetch the declared `dependencies`
545
+ * map for `version`. Used to decide the Harper engine version a target flair
546
+ * release declares, so the same registry/auth/TLS path is used.
547
+ */
548
+ export async function fetchDeclaredDependencies(packageName, version, deps = {}) {
549
+ let registry;
550
+ try {
551
+ registry = await resolveNpmRegistryDetailed(packageName, deps);
552
+ }
553
+ catch (err) {
554
+ if (err instanceof RegistryRefusalError)
555
+ return { kind: "refused", message: err.message };
556
+ return { kind: "refused", message: err instanceof Error ? err.message : String(err) };
557
+ }
558
+ deps.onRegistry?.(registry);
559
+ const timeoutMs = deps.timeoutMs ?? 5000;
560
+ let data;
561
+ if (await useNpmTransport(registry, deps)) {
562
+ const out = await runNpmViewJson(`${packageName}@${version}`, "dependencies", registry.url, timeoutMs);
563
+ if (!out.ok)
564
+ return { kind: "unavailable", message: out.message, registry };
565
+ data = out.data;
566
+ }
567
+ else {
568
+ const out = await fetchRegistryJson(`${registry.url}/${packageName}/${version}`, timeoutMs, deps.fetchImpl);
569
+ if (!out.ok)
570
+ return { kind: "unavailable", message: out.message, registry };
571
+ data = out.data;
572
+ }
573
+ const depsField = data?.dependencies;
574
+ const dependencies = depsField && typeof depsField === "object" && !Array.isArray(depsField)
575
+ ? depsField
576
+ : null;
577
+ return { kind: "ok", dependencies, registry };
578
+ }
@@ -26,8 +26,13 @@
26
26
  * the resolved agent, never from the tool arguments — an agent can only act as
27
27
  * itself (no forging of agentId / authorId in the body).
28
28
  *
29
- * NOTE (flair#1580): MCP-facing metadata (name, description, inputSchema,
30
- * output shape) lives in `@tpsdev-ai/flair-tool-descriptors`. This registry
29
+ * NOTE (flair#1580, vendored by flair#1683): MCP-facing metadata (name,
30
+ * description, inputSchema, output shape) lives in the descriptor module
31
+ * vendored at build time into `./tool-descriptors/` (source of truth:
32
+ * packages/flair-tool-descriptors/src/index.ts — see
33
+ * scripts/vendor-tool-descriptors.mjs). It is imported by RELATIVE PATH so it
34
+ * resolves inside the packed tarball with no registry dependency on the
35
+ * private descriptor package. This registry
31
36
  * binds each *native* descriptor to its Harper impl. The stdio adapter binds
32
37
  * the same list (stdio surface) to FlairClient HTTP — tool-set drift is
33
38
  * impossible by construction. One-sided tools (`attention`, archive verbs,
@@ -53,7 +58,7 @@ import { resolveVersion } from "./version.js";
53
58
  import { agentContext, adminContext, collectionResource } from "./in-process.js";
54
59
  import { unionUsageMemoryIds } from "./usage-ids.js";
55
60
  import { SKILL_TAG, isSkillWrite } from "./skill-write.js";
56
- import { NATIVE_TOOL_DESCRIPTORS, toMcpToolDef, } from "@tpsdev-ai/flair-tool-descriptors";
61
+ import { NATIVE_TOOL_DESCRIPTORS, toMcpToolDef, } from "./tool-descriptors/index.js";
57
62
  const H = {};
58
63
  const LOADERS = {
59
64
  SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
@@ -1,3 +1,7 @@
1
+ // ⚠️ GENERATED FILE — DO NOT EDIT, DO NOT COMMIT (flair#1683).
2
+ // Verbatim build-time copy of packages/flair-tool-descriptors/src/index.ts, the single source of truth.
3
+ // Regenerated by scripts/vendor-tool-descriptors.mjs (see each package's
4
+ // `prebuild`). Edit the source, not this copy.
1
5
  /**
2
6
  * Transport-agnostic MCP tool descriptors (flair#1580).
3
7
  *
@@ -32,6 +32,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { dirname, join } from "node:path";
34
34
  import { parseSemverCore } from "./fabric-upgrade.js";
35
+ import { fetchLatestVersion } from "./lib/npm-registry.js";
35
36
  export const FLAIR_PKG_NAME = "@tpsdev-ai/flair";
36
37
  export const DEFAULT_CACHE_PATH = join(homedir(), ".flair", ".version-check-cache.json");
37
38
  /** How long a cached "latest" answer is trusted before we re-hit the registry. */
@@ -65,19 +66,39 @@ function writeCacheFile(path, entry) {
65
66
  }
66
67
  async function defaultFetchLatest(timeoutMs) {
67
68
  try {
68
- const res = await fetch(`https://registry.npmjs.org/${FLAIR_PKG_NAME}/latest`, {
69
- signal: AbortSignal.timeout(timeoutMs),
70
- });
71
- if (!res.ok)
69
+ // flair#1688: honour the registry npm is configured to use (scoped
70
+ // `@scope:registry`, project/user/global .npmrc, env `npm_config_registry`)
71
+ // rather than a hardcoded public host — a private mirror must not be
72
+ // silently bypassed by the update check.
73
+ //
74
+ // flair#1692: `fetchLatestVersion` validates the URL scheme, refuses
75
+ // redirects, applies npm's transport where a bare fetch cannot, and —
76
+ // critically — validates the returned value as strict semver before it can
77
+ // ever be used as an install spec.
78
+ const result = await fetchLatestVersion(FLAIR_PKG_NAME, { timeoutMs });
79
+ if (result.kind === "ok")
80
+ return result.version;
81
+ if (result.kind === "invalid") {
82
+ // A hostile/compromised registry can return a URL or tag as `latest`;
83
+ // npm would install `pkg@<url>` as a remote tarball. Refuse, and say so
84
+ // rather than silently treating it as "no update".
85
+ console.error(`flair version check: the configured registry returned a non-semver "latest" (${JSON.stringify(result.value)}) ` +
86
+ `from ${result.registry.url} — refusing to use it. Check \`registry\` / \`@scope:registry\` (source: ${result.registry.source}).`);
72
87
  return null;
73
- const data = (await res.json());
74
- return typeof data?.version === "string" ? data.version : null;
75
- }
76
- catch {
88
+ }
89
+ if (result.kind === "refused") {
90
+ console.error(result.message);
91
+ return null;
92
+ }
77
93
  // Offline, DNS failure, timeout, registry 5xx, bad JSON — all the same:
78
94
  // we couldn't determine "latest" over the network this time.
79
95
  return null;
80
96
  }
97
+ catch {
98
+ // Defense-in-depth: the helper already swallows its failure modes. This
99
+ // guards the status/doctor never-throws contract even if it does not.
100
+ return null;
101
+ }
81
102
  }
82
103
  export function defaultVersionCheckDeps() {
83
104
  return {
package/docs/releasing.md CHANGED
@@ -8,8 +8,7 @@ A maintainer then approves the staged tarballs on npmjs.com with 2FA to make the
8
8
  > `flair-bench` is version-bumped and tagged in lockstep with the other 7, and stages in
9
9
  > its own step in CI for [historical reasons](#flair-bench-bootstrap-one-time-done). That
10
10
  > step is no longer allowed to fail: every already-published package must stage
11
- > for a release to pass. `@tpsdev-ai/flair-tool-descriptors` is the one
12
- > first-publish exception until an npm org owner bootstraps it.
11
+ > for a release to pass.
13
12
 
14
13
  ```
15
14
  merge release PR ──▶ push tag v0.11.0 ──▶ CI stages all packages ──▶ npm staging
@@ -147,17 +146,15 @@ Leave `npm publish` **unchecked** under allowed actions. This structurally preve
147
146
  CI/OIDC identity from publishing anything live directly — the only path to live is the
148
147
  human 2FA approval of a staged package.
149
148
 
150
- Packages: `flair-client`, `flair-tool-descriptors`, `flair-mcp`, `flair`,
149
+ Packages: `flair-client`, `flair-mcp`, `flair`,
151
150
  `openclaw-flair`, `pi-flair`, `n8n-nodes-flair`, `langgraph-flair`, `flair-bench`.
152
151
 
153
- > A package must already exist on npm before a trusted publisher can be added.
154
- > `@tpsdev-ai/flair-tool-descriptors` (flair#1580) is the next first-publish:
155
- > stage it with the dedicated continue-on-error step, then an npm org owner
156
- > publishes once and registers its Trusted Publisher (same playbook as
157
- > `flair-bench` below). Until that exists on the registry, `@tpsdev-ai/flair`
158
- > and `@tpsdev-ai/flair-mcp` tarballs bundle it (`bundleDependencies` +
159
- > `scripts/materialize-bundled-descriptors.mjs`) so `npm install` of those
160
- > tarballs does not 404.
152
+ > `flair-tool-descriptors` is private and never published. Since flair#1683 it is a
153
+ > **build-time source**: `scripts/vendor-tool-descriptors.mjs` copies it into each
154
+ > consumer's own tree at prebuild (`resources/tool-descriptors/` for `flair`,
155
+ > `packages/flair-mcp/src/tool-descriptors/` for `flair-mcp`) and the consumers
156
+ > import it by relative path. Nothing declares or bundles it as a dependency —
157
+ > 0.54.1's `bundleDependencies` broke fresh global installs (flair#1681 → #1683).
161
158
 
162
159
  ### `flair-bench` bootstrap (one-time, done)
163
160
 
@@ -196,6 +193,15 @@ review. Because the release is triggered by a tag push, its deployment policy mu
196
193
  **`v*` tags** (Settings → Environments → `release` → Deployment branches and tags →
197
194
  Selected branches and tags → add tag rule `v*`).
198
195
 
196
+ ### Required status checks (ruleset)
197
+
198
+ Add **`First-publish preflight`** to the main branch ruleset's required status
199
+ checks (repo settings → Rules → main). This is a repo-settings act, not a code
200
+ change: until it is listed, the release-PR job is **advisory only** and a red
201
+ first-publish preflight can be merged past. The tag-triggered
202
+ `release-publish.yml` also runs the same check before staging, so a hazard is
203
+ still stopped on the normal release path either way.
204
+
199
205
  ### Approver 2FA
200
206
 
201
207
  The maintainer who approves staged packages must have 2FA enabled on their npm account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.54.1",
3
+ "version": "0.54.2",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",
@@ -46,11 +46,10 @@
46
46
  ],
47
47
  "scripts": {
48
48
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
49
- "prebuild": "npm run clean && cd packages/flair-tool-descriptors && npm run build",
49
+ "prebuild": "npm run clean && node scripts/vendor-tool-descriptors.mjs",
50
50
  "build": "tsc -p tsconfig.json --noCheck && node scripts/write-build-info.mjs",
51
51
  "build:cli": "tsc -p tsconfig.cli.json --noCheck && node scripts/write-build-info.mjs",
52
- "prepack": "node scripts/materialize-bundled-descriptors.mjs",
53
- "prepublishOnly": "npm run build && npm run build:cli",
52
+ "prepack": "npm run build && npm run build:cli",
54
53
  "test": "bun run test:unit",
55
54
  "test:unit": "bun scripts/test-unit.ts",
56
55
  "test:e2e": "playwright test",
@@ -72,12 +71,8 @@
72
71
  "jose": "6.2.2",
73
72
  "js-yaml": "^4.3.2",
74
73
  "tar": "^7.5.22",
75
- "tweetnacl": "1.0.3",
76
- "@tpsdev-ai/flair-tool-descriptors": "0.54.1"
74
+ "tweetnacl": "1.0.3"
77
75
  },
78
- "bundleDependencies": [
79
- "@tpsdev-ai/flair-tool-descriptors"
80
- ],
81
76
  "overrides": {
82
77
  "react-native-fs": "npm:empty-npm-package@1.0.0",
83
78
  "brace-expansion": "^5.0.9",
@@ -1,19 +0,0 @@
1
- Apache License
2
- Version 2.0, January 2004
3
- http://www.apache.org/licenses/
4
-
5
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
-
7
- Copyright 2026 TPS Dev AI
8
-
9
- Licensed under the Apache License, Version 2.0 (the "License");
10
- you may not use this file except in compliance with the License.
11
- You may obtain a copy of the License at
12
-
13
- http://www.apache.org/licenses/LICENSE-2.0
14
-
15
- Unless required by applicable law or agreed to in writing, software
16
- distributed under the License is distributed on an "AS IS" BASIS,
17
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18
- See the License for the specific language governing permissions and
19
- limitations under the License.
@@ -1,22 +0,0 @@
1
- # @tpsdev-ai/flair-tool-descriptors
2
-
3
- Transport-agnostic MCP tool descriptors for [Flair](https://tps.dev/#flair).
4
-
5
- This package is **pure data and types**: tool name, description, JSON Schema
6
- `inputSchema`, output shape, and reviewed surface flags. It imports neither
7
- Harper nor FlairClient. The Flair server binds each native descriptor to its
8
- Harper implementation; `@tpsdev-ai/flair-mcp` binds each stdio descriptor to a
9
- FlairClient HTTP call. Both tool sets are derived from this list, so a new
10
- descriptor appears on every listed surface with zero hand-wiring (flair#1580).
11
-
12
- ## Install
13
-
14
- ```bash
15
- npm install @tpsdev-ai/flair-tool-descriptors
16
- ```
17
-
18
- ## Surfaces
19
-
20
- `native` and `stdio` default to true. Set either to `false` for a reviewed
21
- one-sided tool (`attention` is native-only; `relationship_store` is
22
- stdio-only). The #1578 exemption list is derived from those flags.
@@ -1,70 +0,0 @@
1
- /**
2
- * Transport-agnostic MCP tool descriptors (flair#1580).
3
- *
4
- * Pure data + types: name, description, inputSchema, output shape, and
5
- * reviewed surface flags. No Harper, no FlairClient, no Zod, no HTTP.
6
- *
7
- * The server TOOLS registry binds each native descriptor to its Harper impl.
8
- * The flair-mcp stdio adapter binds each stdio descriptor to a FlairClient
9
- * call. Both tool sets are DERIVED from this list — a new descriptor appears
10
- * on every surface that lists it, with zero hand-wiring.
11
- */
12
- /** JSON Schema object used as MCP tools/list inputSchema. */
13
- export interface JsonSchemaObject {
14
- type: "object";
15
- properties: Record<string, JsonSchemaProperty>;
16
- required?: string[];
17
- }
18
- export interface JsonSchemaProperty {
19
- type?: string;
20
- description?: string;
21
- enum?: string[];
22
- items?: {
23
- type?: string;
24
- };
25
- default?: unknown;
26
- }
27
- /** MCP tool descriptor as returned by tools/list. */
28
- export interface McpToolDef {
29
- name: string;
30
- description: string;
31
- inputSchema: JsonSchemaObject;
32
- annotations?: Record<string, unknown>;
33
- }
34
- /**
35
- * One MCP-facing tool. `native` / `stdio` default true — omit both and the
36
- * tool appears on every surface. Set false for a reviewed one-sided tool
37
- * (the #1578 exemption list is derived from these flags).
38
- */
39
- export interface ToolDescriptor {
40
- name: string;
41
- description: string;
42
- inputSchema: JsonSchemaObject;
43
- /** One-line output shape (MCP metadata). Native conformance contracts pin this as `summary`. */
44
- outputShape: string;
45
- annotations?: Record<string, unknown>;
46
- /** When false, native /mcp does not bind this tool. Default true. */
47
- native?: boolean;
48
- /** When false, the stdio adapter does not bind this tool. Default true. */
49
- stdio?: boolean;
50
- /** Stdio-only description when the HTTP path differs from native /mcp policy. */
51
- stdioDescription?: string;
52
- /** Properties advertised on native /mcp only (reviewed, e.g. flair#1579). */
53
- stdioOmitProperties?: readonly string[];
54
- /** Properties advertised on the stdio adapter only (reviewed). */
55
- stdioExtraProperties?: Record<string, JsonSchemaProperty>;
56
- }
57
- export declare function isNativeTool(d: ToolDescriptor): boolean;
58
- export declare function isStdioTool(d: ToolDescriptor): boolean;
59
- export declare function toMcpToolDef(d: ToolDescriptor): McpToolDef;
60
- /** Native tools/list def, minus reviewed stdio-only omissions. */
61
- export declare function toStdioMcpToolDef(d: ToolDescriptor): McpToolDef;
62
- export declare function descriptorNames(descriptors: readonly ToolDescriptor[]): string[];
63
- export declare const TOOL_DESCRIPTORS: readonly ToolDescriptor[];
64
- export declare const NATIVE_TOOL_DESCRIPTORS: readonly ToolDescriptor[];
65
- export declare const STDIO_TOOL_DESCRIPTORS: readonly ToolDescriptor[];
66
- /** Derived #1578 exemption list — one-sided by construction, not hand-synced. */
67
- export declare const SURFACE_EXEMPTIONS: {
68
- readonly registryOnly: string[];
69
- readonly adapterOnly: string[];
70
- };
@@ -1,46 +0,0 @@
1
- {
2
- "name": "@tpsdev-ai/flair-tool-descriptors",
3
- "version": "0.54.1",
4
- "description": "Transport-agnostic MCP tool descriptors for Flair — name, description, inputSchema, output shape. No Harper, no FlairClient.",
5
- "type": "module",
6
- "main": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
12
- }
13
- },
14
- "files": [
15
- "dist/",
16
- "LICENSE",
17
- "README.md"
18
- ],
19
- "scripts": {
20
- "build": "tsc --noCheck",
21
- "test": "bun test",
22
- "prepublishOnly": "npm run build"
23
- },
24
- "publishConfig": {
25
- "access": "public"
26
- },
27
- "engines": {
28
- "node": ">=18"
29
- },
30
- "license": "Apache-2.0",
31
- "repository": {
32
- "type": "git",
33
- "url": "git+https://github.com/tpsdev-ai/flair.git",
34
- "directory": "packages/flair-tool-descriptors"
35
- },
36
- "homepage": "https://tps.dev/#flair",
37
- "keywords": [
38
- "flair",
39
- "mcp",
40
- "tools",
41
- "descriptors"
42
- ],
43
- "devDependencies": {
44
- "typescript": "5.9.3"
45
- }
46
- }