@tpsdev-ai/flair 0.53.0 → 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.
Files changed (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -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
+ }