@link-assistant/hive-mind 1.78.4 → 1.78.6
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 +12 -0
- package/package.json +1 -1
- package/src/claude.budget-stats.lib.mjs +10 -2
- package/src/codex.lib.mjs +145 -2
- package/src/use-m-bootstrap.lib.mjs +2 -7
- package/src/npm-global-prefix.lib.mjs +0 -160
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 1.78.6
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- cf85feb: Fix Codex sub-session budget display by parsing compact diagnostics and preserving compact-derived sub-session rows.
|
|
8
|
+
|
|
9
|
+
## 1.78.5
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- b3d6588: Remove Hive Mind's npm global prefix workaround now that use-m handles non-writable npm global roots upstream.
|
|
14
|
+
|
|
3
15
|
## 1.78.4
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -548,10 +548,16 @@ export const buildCumulativeInputPhrase = ({ input, cacheWrites, cacheReads, for
|
|
|
548
548
|
*/
|
|
549
549
|
const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
|
|
550
550
|
let result = '';
|
|
551
|
+
let hasEstimatedRows = false;
|
|
551
552
|
for (let i = 0; i < subSessions.length; i++) {
|
|
552
553
|
const sub = subSessions[i];
|
|
554
|
+
if (sub.estimated) hasEstimatedRows = true;
|
|
553
555
|
const subPeakContext = sub.peakContextUsage || 0;
|
|
554
|
-
|
|
556
|
+
const line = formatContextOutputLine(subPeakContext, contextLimit, sub.outputTokens, outputLimit, `${i + 1}. `);
|
|
557
|
+
result += sub.estimated ? line.replace(`\n${i + 1}. `, `\n${i + 1}. ~`) : line;
|
|
558
|
+
}
|
|
559
|
+
if (hasEstimatedRows) {
|
|
560
|
+
result += '\n\n_Sub-session values are estimates from observed compact events; the Total line remains exact._';
|
|
555
561
|
}
|
|
556
562
|
return result;
|
|
557
563
|
};
|
|
@@ -847,6 +853,7 @@ export const buildAgentBudgetStats = (tokenUsage, pricingInfo) => {
|
|
|
847
853
|
const contextLimit = tokenUsage.contextLimit || pricingInfo?.modelInfo?.limit?.context || null;
|
|
848
854
|
const outputLimit = tokenUsage.outputLimit || pricingInfo?.modelInfo?.limit?.output || null;
|
|
849
855
|
const contextFillInputTokens = getExplicitContextFillInputTokens(tokenUsage) ?? getCumulativeContextInputTokens({ inputTokens, cacheWriteTokens });
|
|
856
|
+
const subSessions = Array.isArray(tokenUsage.subSessions) ? tokenUsage.subSessions : [];
|
|
850
857
|
|
|
851
858
|
const modelUsageEntry = {
|
|
852
859
|
inputTokens,
|
|
@@ -862,7 +869,8 @@ export const buildAgentBudgetStats = (tokenUsage, pricingInfo) => {
|
|
|
862
869
|
|
|
863
870
|
return {
|
|
864
871
|
modelUsage: { [modelId]: modelUsageEntry },
|
|
865
|
-
subSessions
|
|
872
|
+
subSessions,
|
|
873
|
+
compactifications: Array.isArray(tokenUsage.compactifications) ? tokenUsage.compactifications : tokenUsage.compactifications || null,
|
|
866
874
|
inputTokens,
|
|
867
875
|
cacheCreationTokens: cacheWriteTokens,
|
|
868
876
|
cacheReadTokens,
|
package/src/codex.lib.mjs
CHANGED
|
@@ -33,8 +33,9 @@ import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
|
|
|
33
33
|
import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
34
34
|
import Decimal from 'decimal.js-light';
|
|
35
35
|
|
|
36
|
-
const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
36
|
+
const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
37
37
|
const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
|
|
38
|
+
const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
|
|
38
39
|
const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
|
|
39
40
|
const CODEX_MODEL_DIAGNOSTIC_PATHS = [
|
|
40
41
|
['model', data => data?.model],
|
|
@@ -76,7 +77,139 @@ const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => has
|
|
|
76
77
|
|
|
77
78
|
const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
|
|
78
79
|
const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
|
|
79
|
-
const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
80
|
+
const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
81
|
+
|
|
82
|
+
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
83
|
+
|
|
84
|
+
const getCodexDiagnosticValue = (line, key) => {
|
|
85
|
+
const match = line.match(new RegExp(`${escapeRegExp(key)}=(?:"([^"]*)"|([^\\s")]+))`));
|
|
86
|
+
return match?.[1] ?? match?.[2] ?? null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const getCodexDiagnosticInteger = (line, key) => {
|
|
90
|
+
const value = getCodexDiagnosticValue(line, key);
|
|
91
|
+
if (value === null) return null;
|
|
92
|
+
const parsed = Number.parseInt(value, 10);
|
|
93
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const getCodexDiagnosticTimestamp = line => {
|
|
97
|
+
const eventTimestamp = getCodexDiagnosticValue(line, 'event.timestamp');
|
|
98
|
+
if (eventTimestamp) return eventTimestamp;
|
|
99
|
+
const logPrefixMatch = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+Z)\]/u);
|
|
100
|
+
return logPrefixMatch?.[1] ?? null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const isSuccessfulCodexCompactRequestLine = line => {
|
|
104
|
+
if (!line.includes('codex_otel.log_only:')) return false;
|
|
105
|
+
if (!line.includes('event.name="codex.api_request"')) return false;
|
|
106
|
+
if (!line.includes(`endpoint="${CODEX_COMPACT_API_ENDPOINT}"`)) return false;
|
|
107
|
+
const statusCode = getCodexDiagnosticInteger(line, 'http.response.status_code');
|
|
108
|
+
return statusCode === null || (statusCode >= 200 && statusCode < 300);
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const splitTokenCountEvenly = (total, partCount) => {
|
|
112
|
+
const safeTotal = Math.max(0, Math.round(total || 0));
|
|
113
|
+
const safePartCount = Math.max(1, Math.round(partCount || 1));
|
|
114
|
+
const base = Math.floor(safeTotal / safePartCount);
|
|
115
|
+
let remainder = safeTotal % safePartCount;
|
|
116
|
+
return Array.from({ length: safePartCount }, () => {
|
|
117
|
+
const value = base + (remainder > 0 ? 1 : 0);
|
|
118
|
+
if (remainder > 0) remainder--;
|
|
119
|
+
return value;
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const splitCodexSubSessionInputTokens = (total, partCount, autoCompactTokenLimit = null) => {
|
|
124
|
+
const safeTotal = Math.max(0, Math.round(total || 0));
|
|
125
|
+
const safePartCount = Math.max(1, Math.round(partCount || 1));
|
|
126
|
+
const safeLimit = Number.isFinite(autoCompactTokenLimit) && autoCompactTokenLimit > 0 ? Math.round(autoCompactTokenLimit) : null;
|
|
127
|
+
if (safePartCount <= 1) return [safeTotal];
|
|
128
|
+
if (safeLimit && safeTotal > safeLimit * (safePartCount - 1)) {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
let remaining = safeTotal;
|
|
131
|
+
for (let i = 0; i < safePartCount - 1; i++) {
|
|
132
|
+
const chunk = Math.min(safeLimit, remaining);
|
|
133
|
+
chunks.push(chunk);
|
|
134
|
+
remaining -= chunk;
|
|
135
|
+
}
|
|
136
|
+
chunks.push(Math.max(0, remaining));
|
|
137
|
+
return chunks;
|
|
138
|
+
}
|
|
139
|
+
return splitTokenCountEvenly(safeTotal, safePartCount);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const splitTokenCountByWeights = (total, weights) => {
|
|
143
|
+
const safeTotal = Math.max(0, Math.round(total || 0));
|
|
144
|
+
const safeWeights = Array.isArray(weights) && weights.length > 0 ? weights.map(weight => Math.max(0, weight || 0)) : [1];
|
|
145
|
+
const weightTotal = safeWeights.reduce((sum, weight) => sum + weight, 0);
|
|
146
|
+
if (weightTotal <= 0) return splitTokenCountEvenly(safeTotal, safeWeights.length);
|
|
147
|
+
|
|
148
|
+
let allocated = 0;
|
|
149
|
+
return safeWeights.map((weight, index) => {
|
|
150
|
+
if (index === safeWeights.length - 1) return Math.max(0, safeTotal - allocated);
|
|
151
|
+
const value = Math.floor((safeTotal * weight) / weightTotal);
|
|
152
|
+
allocated += value;
|
|
153
|
+
return value;
|
|
154
|
+
});
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const rebuildCodexSubSessionsFromCompactifications = tokenUsage => {
|
|
158
|
+
const compactifications = Array.isArray(tokenUsage.compactifications) ? tokenUsage.compactifications : [];
|
|
159
|
+
if (compactifications.length === 0 || (tokenUsage.stepCount || 0) === 0) {
|
|
160
|
+
tokenUsage.subSessions = Array.isArray(tokenUsage.subSessions) ? tokenUsage.subSessions : [];
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const subSessionCount = compactifications.length + 1;
|
|
165
|
+
const inputChunks = splitCodexSubSessionInputTokens(tokenUsage.inputTokens || 0, subSessionCount, tokenUsage.autoCompactTokenLimit);
|
|
166
|
+
const cacheWriteChunks = splitTokenCountByWeights(tokenUsage.cacheWriteTokens || 0, inputChunks);
|
|
167
|
+
const cacheReadChunks = splitTokenCountByWeights(tokenUsage.cacheReadTokens || 0, inputChunks);
|
|
168
|
+
const outputChunks = splitTokenCountByWeights(tokenUsage.outputTokens || 0, inputChunks);
|
|
169
|
+
|
|
170
|
+
tokenUsage.subSessions = inputChunks.map((inputTokens, index) => {
|
|
171
|
+
const cacheCreationTokens = cacheWriteChunks[index] || 0;
|
|
172
|
+
const outputTokens = outputChunks[index] || 0;
|
|
173
|
+
return {
|
|
174
|
+
inputTokens,
|
|
175
|
+
cacheCreationTokens,
|
|
176
|
+
cacheReadTokens: cacheReadChunks[index] || 0,
|
|
177
|
+
outputTokens,
|
|
178
|
+
messageCount: null,
|
|
179
|
+
peakContextUsage: getCumulativeContextInputTokens({ inputTokens, cacheCreationTokens }),
|
|
180
|
+
peakOutputUsage: outputTokens,
|
|
181
|
+
estimated: true,
|
|
182
|
+
source: 'codex.compact-diagnostics',
|
|
183
|
+
compactBoundaryBefore: index === 0 ? null : compactifications[index - 1] || null,
|
|
184
|
+
};
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const recordCodexCompactification = (line, tokenUsage) => {
|
|
189
|
+
if (!isSuccessfulCodexCompactRequestLine(line)) return;
|
|
190
|
+
const timestamp = getCodexDiagnosticTimestamp(line);
|
|
191
|
+
const conversationId = getCodexDiagnosticValue(line, 'conversation.id');
|
|
192
|
+
const existing = tokenUsage.compactifications.find(compact => compact.timestamp === timestamp && compact.conversationId === conversationId);
|
|
193
|
+
if (existing) return;
|
|
194
|
+
|
|
195
|
+
tokenUsage.compactifications.push({
|
|
196
|
+
timestamp,
|
|
197
|
+
preTokens: null,
|
|
198
|
+
trigger: 'auto',
|
|
199
|
+
source: 'codex.responses.compact',
|
|
200
|
+
conversationId: conversationId || null,
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const parseCodexDiagnosticLine = (line, tokenUsage) => {
|
|
205
|
+
const contextLimit = getCodexDiagnosticInteger(line, 'context_window') ?? getCodexDiagnosticInteger(line, 'model_context_window');
|
|
206
|
+
if (contextLimit !== null) tokenUsage.contextLimit = contextLimit;
|
|
207
|
+
|
|
208
|
+
const autoCompactTokenLimit = getCodexDiagnosticInteger(line, 'auto_compact_token_limit') ?? getCodexDiagnosticInteger(line, 'model_auto_compact_token_limit');
|
|
209
|
+
if (autoCompactTokenLimit !== null) tokenUsage.autoCompactTokenLimit = autoCompactTokenLimit;
|
|
210
|
+
|
|
211
|
+
recordCodexCompactification(line, tokenUsage);
|
|
212
|
+
};
|
|
80
213
|
|
|
81
214
|
export const createCodexTokenUsage = requestedModelId => ({
|
|
82
215
|
inputTokens: 0,
|
|
@@ -90,8 +223,11 @@ export const createCodexTokenUsage = requestedModelId => ({
|
|
|
90
223
|
respondedModelId: requestedModelId || null,
|
|
91
224
|
contextLimit: null,
|
|
92
225
|
outputLimit: null,
|
|
226
|
+
autoCompactTokenLimit: null,
|
|
93
227
|
contextFillInputTokens: 0,
|
|
94
228
|
peakContextUsage: 0,
|
|
229
|
+
subSessions: [],
|
|
230
|
+
compactifications: [],
|
|
95
231
|
tokenFieldAvailability: createCodexTokenFieldAvailability(),
|
|
96
232
|
});
|
|
97
233
|
|
|
@@ -285,12 +421,17 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
285
421
|
};
|
|
286
422
|
|
|
287
423
|
nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
|
|
424
|
+
if (!Array.isArray(nextState.tokenUsage.subSessions)) nextState.tokenUsage.subSessions = [];
|
|
425
|
+
if (!Array.isArray(nextState.tokenUsage.compactifications)) nextState.tokenUsage.compactifications = [];
|
|
426
|
+
nextState.tokenUsage.autoCompactTokenLimit ??= null;
|
|
288
427
|
const observedModelPaths = new Set(nextState.observedModelDiagnosticPaths);
|
|
289
428
|
|
|
290
429
|
for (const rawLine of output.split('\n')) {
|
|
291
430
|
const line = rawLine.trim();
|
|
292
431
|
if (!line) continue;
|
|
293
432
|
|
|
433
|
+
parseCodexDiagnosticLine(line, nextState.tokenUsage);
|
|
434
|
+
|
|
294
435
|
let data;
|
|
295
436
|
try {
|
|
296
437
|
data = sanitizeObjectStrings(JSON.parse(line));
|
|
@@ -405,6 +546,7 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
405
546
|
}
|
|
406
547
|
}
|
|
407
548
|
|
|
549
|
+
rebuildCodexSubSessionsFromCompactifications(nextState.tokenUsage);
|
|
408
550
|
nextState.observedModelDiagnosticPaths = [...observedModelPaths];
|
|
409
551
|
return nextState;
|
|
410
552
|
};
|
|
@@ -901,6 +1043,7 @@ export const executeCodexCommand = async params => {
|
|
|
901
1043
|
if (errorOutput && argv.verbose) {
|
|
902
1044
|
await log(errorOutput, { stream: 'stderr' });
|
|
903
1045
|
}
|
|
1046
|
+
codexJsonState = parseCodexExecJsonOutput(errorOutput, codexJsonState, mappedModel);
|
|
904
1047
|
} else if (chunk.type === 'exit') {
|
|
905
1048
|
exitCode = chunk.code;
|
|
906
1049
|
}
|
|
@@ -1,21 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { ensureWritableNpmGlobalPrefix } from './npm-global-prefix.lib.mjs';
|
|
4
|
-
|
|
5
3
|
const defaultFetchUseMCode = async () => (await fetch('https://unpkg.com/use-m/use.js')).text();
|
|
6
4
|
|
|
7
5
|
/**
|
|
8
|
-
* Load the use-m bootstrap
|
|
9
|
-
* use-m's Node resolver.
|
|
6
|
+
* Load the shared use-m bootstrap.
|
|
10
7
|
*
|
|
11
8
|
* @param {object} [options]
|
|
12
|
-
* @param {(message: string) => void} [options.log]
|
|
13
9
|
* @param {() => Promise<string>} [options.fetchUseMCode]
|
|
14
10
|
* @returns {Promise<Function>} The global use-m `use` function.
|
|
15
11
|
*/
|
|
16
12
|
export const ensureUseM = async (options = {}) => {
|
|
17
|
-
const {
|
|
18
|
-
await ensureWritableNpmGlobalPrefix({ log });
|
|
13
|
+
const { fetchUseMCode = defaultFetchUseMCode } = options;
|
|
19
14
|
if (typeof globalThis.use === 'undefined') {
|
|
20
15
|
globalThis.use = (await eval(await fetchUseMCode())).use;
|
|
21
16
|
}
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Ensure npm's global install directory is writable before `use-m` runs.
|
|
5
|
-
*
|
|
6
|
-
* Issue #1897: `use-m` loads runtime dependencies (command-stream, getenv,
|
|
7
|
-
* yargs, …) by shelling out to `npm install -g <alias>@npm:<pkg>@latest`.
|
|
8
|
-
* npm installs into the global prefix reported by `npm root -g`. When the
|
|
9
|
-
* CLI is launched with a system-wide Node.js whose global `node_modules`
|
|
10
|
-
* directory is owned by root (e.g. `/opt/node-v24.16.0-linux-x64/lib/node_modules`),
|
|
11
|
-
* the install fails with `EACCES: permission denied` and the whole process
|
|
12
|
-
* crashes at the very first `use()` call with an unhandled error:
|
|
13
|
-
*
|
|
14
|
-
* Error: Failed to install command-stream@latest globally.
|
|
15
|
-
* ... npm error code EACCES
|
|
16
|
-
* ... npm error syscall rename
|
|
17
|
-
* ... npm error path /opt/node-.../lib/node_modules/command-stream-v-latest
|
|
18
|
-
*
|
|
19
|
-
* This commonly happens when the package was installed with one runtime
|
|
20
|
-
* (e.g. `bun add -g`, which writes to a user-owned `~/.bun/...`) but launched
|
|
21
|
-
* under a system Node whose global prefix needs root.
|
|
22
|
-
*
|
|
23
|
-
* The fix mirrors npm's own documented recommendation for EACCES errors:
|
|
24
|
-
* point the global prefix at a user-writable directory. We detect the
|
|
25
|
-
* non-writable prefix up front and redirect `npm_config_prefix` (which both
|
|
26
|
-
* `npm install -g` and `npm root -g` honour) to `~/.npm-global`, so use-m's
|
|
27
|
-
* install succeeds without sudo. When the prefix is already writable we do
|
|
28
|
-
* nothing — the common case stays a no-op with no extra `npm` spawn.
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import { access, mkdir } from 'node:fs/promises';
|
|
32
|
-
import { constants as fsConstants } from 'node:fs';
|
|
33
|
-
import { dirname, join } from 'node:path';
|
|
34
|
-
import { homedir } from 'node:os';
|
|
35
|
-
import { exec } from 'node:child_process';
|
|
36
|
-
import { promisify } from 'node:util';
|
|
37
|
-
|
|
38
|
-
const execAsync = promisify(exec);
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* Derive the likely global `node_modules` path from the Node binary location
|
|
42
|
-
* without spawning npm. For a standard POSIX layout the node binary lives at
|
|
43
|
-
* `<prefix>/bin/node` and global packages at `<prefix>/lib/node_modules`.
|
|
44
|
-
*
|
|
45
|
-
* @param {string} execPath - Absolute path to the running node binary.
|
|
46
|
-
* @returns {string} Candidate global `node_modules` directory.
|
|
47
|
-
*/
|
|
48
|
-
export const deriveGlobalNodeModules = execPath => join(dirname(dirname(execPath)), 'lib', 'node_modules');
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Check whether a directory is writable, walking up to the nearest existing
|
|
52
|
-
* ancestor when the leaf does not yet exist (npm would create it on install).
|
|
53
|
-
*
|
|
54
|
-
* @param {string} startDir - Directory to test.
|
|
55
|
-
* @param {(path: string, mode: number) => Promise<void>} accessFn - fs.access.
|
|
56
|
-
* @returns {Promise<boolean>}
|
|
57
|
-
*/
|
|
58
|
-
export const isPathWritable = async (startDir, accessFn = access) => {
|
|
59
|
-
let current = startDir;
|
|
60
|
-
for (;;) {
|
|
61
|
-
try {
|
|
62
|
-
await accessFn(current, fsConstants.W_OK);
|
|
63
|
-
return true;
|
|
64
|
-
} catch (error) {
|
|
65
|
-
if (error && error.code === 'ENOENT') {
|
|
66
|
-
const parent = dirname(current);
|
|
67
|
-
if (parent === current) return false; // reached filesystem root
|
|
68
|
-
current = parent;
|
|
69
|
-
continue;
|
|
70
|
-
}
|
|
71
|
-
// EACCES / EPERM / anything else → treat as not writable.
|
|
72
|
-
return false;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const queryNpmRoot = async runner => {
|
|
78
|
-
try {
|
|
79
|
-
const { stdout } = await runner('npm root -g');
|
|
80
|
-
const value = String(stdout).trim();
|
|
81
|
-
return value || null;
|
|
82
|
-
} catch {
|
|
83
|
-
return null;
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Detect a non-writable npm global prefix and, if found, redirect global
|
|
89
|
-
* installs to a user-writable directory by setting `npm_config_prefix`.
|
|
90
|
-
*
|
|
91
|
-
* Idempotent and dependency-injectable for tests. Returns an object describing
|
|
92
|
-
* what happened: `{ changed: boolean, reason: string, prefix?, previousRoot? }`.
|
|
93
|
-
*
|
|
94
|
-
* @param {object} [options]
|
|
95
|
-
* @param {Record<string, string>} [options.env=process.env] - Environment to mutate.
|
|
96
|
-
* @param {string} [options.execPath=process.execPath] - Running node binary path.
|
|
97
|
-
* @param {string} [options.platform=process.platform] - OS platform.
|
|
98
|
-
* @param {string} [options.home] - User home directory.
|
|
99
|
-
* @param {Function} [options.accessFn=fs.access]
|
|
100
|
-
* @param {Function} [options.mkdirFn=fs.mkdir]
|
|
101
|
-
* @param {Function} [options.runner=execAsync] - Runs a shell command, returns {stdout}.
|
|
102
|
-
* @param {(message: string) => void} [options.log] - Informational logger.
|
|
103
|
-
* @returns {Promise<{changed: boolean, reason: string, prefix?: string, previousRoot?: string, error?: Error}>}
|
|
104
|
-
*/
|
|
105
|
-
export const ensureWritableNpmGlobalPrefix = async (options = {}) => {
|
|
106
|
-
const { env = process.env, execPath = process.execPath, platform = process.platform, home = homedir(), accessFn = access, mkdirFn = mkdir, runner = execAsync, log = () => {}, isBunRuntime = typeof Bun !== 'undefined', isDenoRuntime = typeof Deno !== 'undefined' } = options;
|
|
107
|
-
|
|
108
|
-
// Windows' global layout differs (`<prefix>/node_modules`, AppData), and the
|
|
109
|
-
// EACCES scenario this guards against is POSIX-specific. Skip to avoid false
|
|
110
|
-
// positives that would needlessly relocate the prefix.
|
|
111
|
-
if (platform === 'win32') return { changed: false, reason: 'win32' };
|
|
112
|
-
|
|
113
|
-
// This workaround only protects use-m's Node/npm resolver. Bun and Deno use
|
|
114
|
-
// different install paths and should not have npm configuration changed.
|
|
115
|
-
if (isBunRuntime) return { changed: false, reason: 'bun-runtime' };
|
|
116
|
-
if (isDenoRuntime) return { changed: false, reason: 'deno-runtime' };
|
|
117
|
-
|
|
118
|
-
// Respect an explicitly configured prefix — the user (or a parent process)
|
|
119
|
-
// already chose where global installs go.
|
|
120
|
-
if (env.npm_config_prefix || env.NPM_CONFIG_PREFIX) return { changed: false, reason: 'preset' };
|
|
121
|
-
|
|
122
|
-
// Fast path: derive the likely global node_modules from the node binary and
|
|
123
|
-
// check writability without spawning npm. Most installs land here.
|
|
124
|
-
const derived = deriveGlobalNodeModules(execPath);
|
|
125
|
-
if (await isPathWritable(derived, accessFn)) {
|
|
126
|
-
return { changed: false, reason: 'writable' };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// The cheap heuristic says non-writable. Confirm against the authoritative
|
|
130
|
-
// path npm/use-m actually use before changing anything (handles custom prefixes).
|
|
131
|
-
const authoritative = (await queryNpmRoot(runner)) || derived;
|
|
132
|
-
if (await isPathWritable(authoritative, accessFn)) {
|
|
133
|
-
return { changed: false, reason: 'writable' };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Global prefix is genuinely not writable by the current user (issue #1897).
|
|
137
|
-
if (!home) {
|
|
138
|
-
return { changed: false, reason: 'no-home' };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const prefix = join(home, '.npm-global');
|
|
142
|
-
try {
|
|
143
|
-
// npm installs into `<prefix>/lib/node_modules`; create it so the very
|
|
144
|
-
// first `npm install -g` does not have to.
|
|
145
|
-
await mkdirFn(join(prefix, 'lib', 'node_modules'), { recursive: true });
|
|
146
|
-
} catch (error) {
|
|
147
|
-
return { changed: false, reason: 'mkdir-failed', error };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
env.npm_config_prefix = prefix;
|
|
151
|
-
// Make globally-installed binaries from the new prefix resolvable too.
|
|
152
|
-
const binDir = join(prefix, 'bin');
|
|
153
|
-
const pathParts = String(env.PATH || '').split(':');
|
|
154
|
-
if (!pathParts.includes(binDir)) {
|
|
155
|
-
env.PATH = env.PATH ? `${binDir}:${env.PATH}` : binDir;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
log(`ℹ️ npm global directory (${authoritative}) is not writable; redirecting global installs to ${prefix} (issue #1897).`);
|
|
159
|
-
return { changed: true, reason: 'redirected', prefix, previousRoot: authoritative };
|
|
160
|
-
};
|