@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.
- package/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1665 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +931 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1628 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/engine-version.js +12 -4
- package/dist/fabric-npm-install.js +87 -0
- package/dist/fabric-upgrade.js +30 -15
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/npm-registry.js +578 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +48 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/resources/tool-descriptors/index.js +669 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/dist/version-check.js +29 -8
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +20 -6
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/package.json +4 -4
- 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
|
+
}
|