sprag-cli 3.75.1 → 3.75.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/TEAM.ko.md +4 -4
- package/docs/TEAM.md +4 -4
- package/package.json +1 -1
- package/src/commands/team.js +5 -8
- package/src/member-id.js +64 -0
- package/src/team-sync.js +10 -8
- package/src/update-check.js +54 -2
package/docs/TEAM.ko.md
CHANGED
|
@@ -16,14 +16,14 @@ Sprag 은 기본적으로 로컬에서만 동작합니다. 팀 기능은 켜야
|
|
|
16
16
|
| 모델 계열 비중 | 위임 실행 중 모델 계열별 비율 |
|
|
17
17
|
| Sprag 버전 | 설치된 버전 |
|
|
18
18
|
| 에이전트 | `claude` 또는 `codex` |
|
|
19
|
-
| 표시 이름 | 참여할 때 정한 이름이며, 기본값은
|
|
20
|
-
| 운영체제 | OS 이름과 버전(예: macOS 26, Windows 11, Ubuntu 24.04)
|
|
19
|
+
| 표시 이름 | 참여할 때 정한 이름이며, 기본값은 컴퓨터마다 한 번 만드는 `member-3f9a2c71` 같은 무작위 구성원 ID |
|
|
20
|
+
| 운영체제 | OS 이름과 버전(예: macOS 26, Windows 11, Ubuntu 24.04)입니다. 참여한 직후 한 번 보내고, 바뀔 때만 다시 보냅니다. 호스트 이름, 사용자 이름, 경로처럼 컴퓨터를 이름으로 알아볼 수 있는 값은 보내지 않습니다 |
|
|
21
21
|
|
|
22
22
|
보내지 않는 것은 프롬프트, 대화 기록, 파일 경로, 프로젝트 이름, 룰 내용, API 키입니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 옵트인 프롬프트 업로드만 예외이며 "프롬프트 사용량 (엔터프라이즈)" 절에 적었습니다. 룰은 직접 `sprag team propose` 를 실행할 때만 밖으로 나갑니다.
|
|
23
23
|
|
|
24
24
|
하루 단위 집계 업로드는 마지막 성공 이후 6시간에 한 번 이하로 실행됩니다. SessionStart 훅(그리고 작은 로컬 파일만 읽는 프롬프트 훅)이 띄운 분리된 자식 프로세스가 보냅니다. 네트워크가 끊기는 등 동기화가 실패하면 6시간을 기다리지 않고 15분, 30분, 60분 뒤에 다시 시도합니다(최대 60분 간격). 마지막으로 올린 날짜를 기억해 두었다가 그 전날부터 최대 30일 치를 다시 보내므로, 며칠 꺼져 있던 컴퓨터도 처음 성공하는 동기화에서 빠진 날을 따라잡습니다. 하루 단위 행은 덮어쓰기라 다시 보내도 안전합니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 프롬프트 업로드는 따로 30분 타이머로 움직입니다("프롬프트 사용량 (엔터프라이즈)" 절 참고). statusline 을 그리는 경로에서는 절대 실행되지 않습니다.
|
|
25
25
|
|
|
26
|
-
목적지는 `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` 입니다(Supabase, ap-northeast-2 리전). 호출하는 RPC 는 `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put`(엔터프라이즈 프롬프트 수집일 때만), `member_set_sync`(업로드 스위치 상태 호출, "업로드 스위치" 참고) 입니다.
|
|
26
|
+
목적지는 `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` 입니다(Supabase, ap-northeast-2 리전). 호출하는 RPC 는 `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put`(엔터프라이즈 프롬프트 수집일 때만), `member_set_sync`(업로드 스위치 상태 호출, "업로드 스위치" 참고), `member_set_info`(OS 이름과 버전, `arch` 인자는 항상 빈 값) 입니다.
|
|
27
27
|
|
|
28
28
|
## CLI에서 팀 만들기
|
|
29
29
|
|
|
@@ -55,7 +55,7 @@ sprag team create --email you@example.com --name "Acme" --code 123456
|
|
|
55
55
|
sprag team join <토큰> --name "미나"
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
Sprag 이 올라갈 항목 목록을 보여 주고 확인을 묻습니다. `--name` 은 표시 이름이며 기본값은
|
|
58
|
+
Sprag 이 올라갈 항목 목록을 보여 주고 확인을 묻습니다. `--name` 은 표시 이름이며 기본값은 `member-3f9a2c71` 같은 무작위 구성원 ID입니다. 이 ID는 컴퓨터마다 한 번 만들어 사용자 데이터 디렉터리에 보관하며, `--name` 으로 바꿀 수 있습니다. 이 변경 전에 참여한 구성원은 서버에 이미 저장된 이름을 그대로 씁니다. 터미널이 없는 셸에서는 `--yes` 를 붙여 확인을 대신합니다. 토큰을 생략하면 `SPRAG_TEAM_TOKEN` 을 읽습니다.
|
|
59
59
|
|
|
60
60
|
결과는 `sprag team status` 로 확인합니다.
|
|
61
61
|
|
package/docs/TEAM.md
CHANGED
|
@@ -16,14 +16,14 @@ After you join, Sprag uploads one row per local calendar day:
|
|
|
16
16
|
| Model-family share | the share of those runs that went to each model family |
|
|
17
17
|
| Sprag version | the installed version |
|
|
18
18
|
| Agent | `claude` or `codex` |
|
|
19
|
-
| Display name | the name you chose at join; the default is
|
|
20
|
-
| Operating system | OS name and version (for example macOS 26, Windows 11, Ubuntu 24.04)
|
|
19
|
+
| Display name | the name you chose at join; the default is a random member ID such as `member-3f9a2c71`, created once per machine |
|
|
20
|
+
| Operating system | OS name and version (for example macOS 26, Windows 11, Ubuntu 24.04); sent once after joining and again only if it changes. Nothing that identifies the machine by name: no hostname, no user name, no path |
|
|
21
21
|
|
|
22
22
|
Not sent: prompts, transcripts, file paths, project names, rule text, API keys. The one exception is the opt-in prompt upload of an Enterprise team with prompt capture on, described under "Prompt usage (Enterprise)". A rule leaves your machine only when you run `sprag team propose` yourself.
|
|
23
23
|
|
|
24
24
|
The daily aggregate upload runs at most once every 6 hours after the last successful one, from a detached child process that the SessionStart hook (and the prompt hook, which only reads a small local file) spawns. If a sync fails, for example because the network is down, it is retried after 15, then 30, then 60 minutes (60 at most) instead of waiting 6 hours. The CLI remembers the last day it uploaded and re-sends from the day before it, up to 30 days, so a machine that was offline for days catches up on its first successful sync. Re-sending a day is safe because each day is an upsert. The prompt upload of an Enterprise team with capture on has its own 30-minute timer (see "Prompt usage (Enterprise)"). It never runs on the statusline path.
|
|
25
25
|
|
|
26
|
-
Destination: `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` (Supabase, region ap-northeast-2). The calls are the RPCs `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put` (Enterprise prompt capture only)
|
|
26
|
+
Destination: `https://hiycfjwswtkixyoqkznm.supabase.co/rest/v1/rpc/*` (Supabase, region ap-northeast-2). The calls are the RPCs `team_join`, `ledger_upsert`, `team_rules_get`, `team_rule_propose`, `team_leave`, `prompt_chunk_put` (Enterprise prompt capture only), `member_set_sync` (the upload-switch status call, see "The upload switch") and `member_set_info` (OS name and version; the `arch` argument is always empty); `sprag team create` also calls `team_create` with your sign-in session.
|
|
27
27
|
|
|
28
28
|
## Create a team from the CLI
|
|
29
29
|
|
|
@@ -55,7 +55,7 @@ An owner or admin gives you an invite token. Then:
|
|
|
55
55
|
sprag team join <token> --name "Mina"
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
Sprag prints the list of what will be uploaded and asks for confirmation. `--name` sets the display name (default:
|
|
58
|
+
Sprag prints the list of what will be uploaded and asks for confirmation. `--name` sets the display name (default: a random member ID like `member-3f9a2c71`, created once per machine and kept in the user data dir, so it is the same on every join from that machine). Members who joined before this change keep the name already stored on the server. In a shell without a terminal, pass `--yes` to confirm. If you leave the token out, Sprag reads `SPRAG_TEAM_TOKEN`.
|
|
59
59
|
|
|
60
60
|
Check the result with `sprag team status`.
|
|
61
61
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.75.
|
|
3
|
+
"version": "3.75.2",
|
|
4
4
|
"description": "Model-delegation router for Claude Code and Codex: lookups, checks and test runs go to cheaper models, documents go in as compact Markdown, and a live statusline shows spend and savings.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/commands/team.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
|
|
11
11
|
import { createInterface } from 'node:readline';
|
|
12
12
|
import { readFileSync } from 'node:fs';
|
|
13
|
-
import { userInfo } from 'node:os';
|
|
14
13
|
import { userLanguage } from '../config.js';
|
|
15
14
|
import { pendingPrompts } from '../team-prompts.js';
|
|
15
|
+
import { memberId } from '../member-id.js';
|
|
16
16
|
import {
|
|
17
17
|
loadTeamState, deviceInfo, isJoined, joinTeam, leaveTeam, syncTeam, autoJoin, aggregateDaily, rpc,
|
|
18
18
|
teamRulesCachePath, teamSyncDisabled, requestSignInCode, verifySignInCode, createTeam, isSeatLimitError,
|
|
@@ -191,9 +191,7 @@ async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive
|
|
|
191
191
|
if (!email) { console.error(m.needEmail); return process.exit(2); }
|
|
192
192
|
if (!EMAIL_RE.test(email)) { console.error(m.badEmail); return process.exit(2); }
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
try { osName = userInfo().username || 'my'; } catch { /* no passwd entry */ }
|
|
196
|
-
const defaultName = `${osName}'s team`;
|
|
194
|
+
const defaultName = `${memberId()}'s team`;
|
|
197
195
|
|
|
198
196
|
let useCode = code;
|
|
199
197
|
if (!useCode) {
|
|
@@ -221,8 +219,7 @@ async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive
|
|
|
221
219
|
out(m.dashboard(email));
|
|
222
220
|
|
|
223
221
|
if (noJoin) { out(m.noJoinNote); return undefined; }
|
|
224
|
-
|
|
225
|
-
try { display = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
222
|
+
const display = memberId();
|
|
226
223
|
const joined = await joinTeam({ token: team.inviteToken, displayName: display, fetch, version });
|
|
227
224
|
if (joined.orgName && joined.seatsUsed != null && joined.seatLimit != null) out(m.orgCreated(joined.orgName, joined.seatsUsed, joined.seatLimit));
|
|
228
225
|
if (!teamSyncDisabled()) {
|
|
@@ -248,14 +245,14 @@ export async function run({ args, hasFlag, getArg, version, fetch, interactive,
|
|
|
248
245
|
const token = (args[2] && !args[2].startsWith('--') ? args[2] : null) || process.env.SPRAG_TEAM_TOKEN;
|
|
249
246
|
if (!token) { console.error(m.noToken); return process.exit(2); }
|
|
250
247
|
let name = getArg('--name');
|
|
251
|
-
if (!name)
|
|
248
|
+
if (!name) name = memberId();
|
|
252
249
|
out(m.willSend);
|
|
253
250
|
out(m.asName(name));
|
|
254
251
|
if (!hasFlag('--yes')) {
|
|
255
252
|
if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error(m.needYes); return process.exit(2); }
|
|
256
253
|
if (!(await confirm(m.ask))) { out(m.aborted); return undefined; }
|
|
257
254
|
}
|
|
258
|
-
const st = await joinTeam({ token, displayName: name, version });
|
|
255
|
+
const st = await joinTeam({ token, displayName: name, fetch, version });
|
|
259
256
|
out(m.joined(st.teamName));
|
|
260
257
|
if (st.orgName && st.seatsUsed != null && st.seatLimit != null) out(m.orgCreated(st.orgName, st.seatsUsed, st.seatLimit));
|
|
261
258
|
return undefined;
|
package/src/member-id.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* member-id — the default display name for the team layer.
|
|
3
|
+
*
|
|
4
|
+
* The OS login name used to fill this slot, which sent a real username to the
|
|
5
|
+
* team server. A random id created once per machine names the member without
|
|
6
|
+
* naming the person. It lives in its own file in the user data dir, so
|
|
7
|
+
* `team leave` (which only touches team.json) keeps it and a rejoin on the same
|
|
8
|
+
* machine shows up under the same name.
|
|
9
|
+
*
|
|
10
|
+
* One id per machine holds even when things go wrong: an id that could not be
|
|
11
|
+
* written is remembered for the life of the process, and a missing file is
|
|
12
|
+
* created exclusively (temp file + hard link), so two processes starting at
|
|
13
|
+
* once end up with the id of whichever linked first.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, linkSync, unlinkSync } from 'node:fs';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
import { randomBytes } from 'node:crypto';
|
|
19
|
+
import { userDataDir } from './paths.js';
|
|
20
|
+
import { writeStateFile } from './state-file.js';
|
|
21
|
+
import { debug } from './debug.js';
|
|
22
|
+
|
|
23
|
+
const ID_RE = /^member-[0-9a-f]{8}$/;
|
|
24
|
+
const cache = new Map();
|
|
25
|
+
|
|
26
|
+
export const memberIdPath = (dir = userDataDir()) => join(dir, 'member-id');
|
|
27
|
+
|
|
28
|
+
function readValid(file) {
|
|
29
|
+
try {
|
|
30
|
+
const text = readFileSync(file, 'utf8').trim();
|
|
31
|
+
return ID_RE.test(text) ? text : null;
|
|
32
|
+
} catch (e) { debug('member-id', e); return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Never throws: a failed write still returns the id it generated, and the
|
|
36
|
+
// cache hands the same one back on the next call.
|
|
37
|
+
export function memberId({ dir = userDataDir(), random = () => randomBytes(4).toString('hex') } = {}) {
|
|
38
|
+
const file = memberIdPath(dir);
|
|
39
|
+
const existing = readValid(file);
|
|
40
|
+
if (existing) { cache.set(file, existing); return existing; }
|
|
41
|
+
let id = cache.get(file) || `member-${random()}`;
|
|
42
|
+
try {
|
|
43
|
+
if (existsSync(file)) {
|
|
44
|
+
writeStateFile(file, `${id}\n`);
|
|
45
|
+
} else {
|
|
46
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
47
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
writeFileSync(tmp, `${id}\n`);
|
|
50
|
+
linkSync(tmp, file);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// A file system without hard links still gets the file, just not exclusively.
|
|
53
|
+
if (e.code !== 'EEXIST') { debug('member-id', e); writeStateFile(file, `${id}\n`); } else {
|
|
54
|
+
const winner = readValid(file); // another process created it first
|
|
55
|
+
if (winner) id = winner;
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
try { unlinkSync(tmp); } catch { /* never created */ }
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (e) { debug('member-id', e); }
|
|
62
|
+
cache.set(file, id);
|
|
63
|
+
return id;
|
|
64
|
+
}
|
package/src/team-sync.js
CHANGED
|
@@ -24,10 +24,11 @@ import { envVar } from './env.js';
|
|
|
24
24
|
import { readFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
|
|
25
25
|
import { execFileSync } from 'node:child_process';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
|
-
import {
|
|
27
|
+
import { release as osRelease } from 'node:os';
|
|
28
28
|
import { createRequire } from 'node:module';
|
|
29
29
|
import { userDataDir } from './paths.js';
|
|
30
30
|
import { writeStateFile } from './state-file.js';
|
|
31
|
+
import { memberId } from './member-id.js';
|
|
31
32
|
import { debug } from './debug.js';
|
|
32
33
|
import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
|
|
33
34
|
import { sessionCost, pricedParts } from './claude-price.js';
|
|
@@ -330,12 +331,14 @@ export async function aggregateDaily({ now = Date.now(), dir = userDataDir(), da
|
|
|
330
331
|
}));
|
|
331
332
|
}
|
|
332
333
|
|
|
333
|
-
// Device info: OS name
|
|
334
|
-
//
|
|
334
|
+
// Device info: OS name and OS version. The CPU architecture is not sent: the
|
|
335
|
+
// `arch` key stays in the payload (the member_set_info RPC takes it as an
|
|
336
|
+
// argument) but is always empty. Nothing that names the machine (no hostname,
|
|
337
|
+
// username or path) is read or sent.
|
|
335
338
|
const DARWIN_TO_MACOS = { 20: '11', 21: '12', 22: '13', 23: '14', 24: '15', 25: '26' };
|
|
336
339
|
|
|
337
340
|
export function deviceInfo({
|
|
338
|
-
platform = process.platform,
|
|
341
|
+
platform = process.platform, release = osRelease(),
|
|
339
342
|
exec = (cmd, args) => execFileSync(cmd, args, { timeout: 2000, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }),
|
|
340
343
|
readText = (f) => readFileSync(f, 'utf8'),
|
|
341
344
|
} = {}) {
|
|
@@ -363,7 +366,7 @@ export function deviceInfo({
|
|
|
363
366
|
} catch (e) { debug('team-sync', e); }
|
|
364
367
|
}
|
|
365
368
|
} catch (e) { debug('team-sync', e); }
|
|
366
|
-
return { os: os.slice(0, 40), os_version: osVersion.slice(0, 40), arch:
|
|
369
|
+
return { os: os.slice(0, 40), os_version: osVersion.slice(0, 40), arch: '' };
|
|
367
370
|
}
|
|
368
371
|
|
|
369
372
|
// Reports device info once after a join and again only when it changes. Fails
|
|
@@ -387,7 +390,7 @@ export async function joinTeam({
|
|
|
387
390
|
token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(), env = process.env,
|
|
388
391
|
} = {}) {
|
|
389
392
|
if (!TOKEN_RE.test(String(token || ''))) throw new Error('invalid invite token (expected spt_ + 32 hex characters)');
|
|
390
|
-
const name = String(displayName || '').trim() ||
|
|
393
|
+
const name = String(displayName || '').trim() || memberId({ dir });
|
|
391
394
|
let r;
|
|
392
395
|
try {
|
|
393
396
|
r = await rpc('team_join', {
|
|
@@ -604,8 +607,7 @@ export async function autoJoin({ env = process.env, fetch, dir = userDataDir(),
|
|
|
604
607
|
// A state with `left: true` never auto-rejoins: leaving must beat the
|
|
605
608
|
// environment, or `sprag team leave` would be undone by the next session.
|
|
606
609
|
if (isJoined(state) || state.left) return null;
|
|
607
|
-
|
|
608
|
-
try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
610
|
+
const name = memberId({ dir });
|
|
609
611
|
try {
|
|
610
612
|
return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now, env });
|
|
611
613
|
} catch (e) {
|
package/src/update-check.js
CHANGED
|
@@ -29,12 +29,19 @@
|
|
|
29
29
|
* once-a-day pass as the version check, and its absence never blocks the
|
|
30
30
|
* notice — a version number alone is still worth saying.
|
|
31
31
|
*
|
|
32
|
+
* The version is asked of the npm registry the user has configured
|
|
33
|
+
* (npm_config_registry, then the `registry=` line of ~/.npmrc, then
|
|
34
|
+
* registry.npmjs.org), so a machine behind a corporate mirror checks the
|
|
35
|
+
* mirror instead of a host it may not be able to reach. A mirror that fails is
|
|
36
|
+
* reported as a failure; there is no silent fallback to npmjs.
|
|
37
|
+
*
|
|
32
38
|
* Opt out with SPRAG_NO_UPDATE_CHECK=1 or NO_UPDATE_NOTIFIER (the de-facto
|
|
33
39
|
* standard env var — anyone who set it for other CLIs meant us too).
|
|
34
40
|
*/
|
|
35
41
|
|
|
36
42
|
import { envVar } from './env.js';
|
|
37
43
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
44
|
+
import { homedir } from 'node:os';
|
|
38
45
|
import { spawn } from 'node:child_process';
|
|
39
46
|
import { join, dirname, sep } from 'node:path';
|
|
40
47
|
import { fileURLToPath } from 'node:url';
|
|
@@ -334,6 +341,48 @@ function releaseTagUrl(version) {
|
|
|
334
341
|
return url;
|
|
335
342
|
}
|
|
336
343
|
|
|
344
|
+
/**
|
|
345
|
+
* The npm registry base URL the user has configured. Order: the
|
|
346
|
+
* npm_config_registry env var (either case), the `registry=` line of
|
|
347
|
+
* ~/.npmrc, then the public registry. `@scope:registry=` lines are ignored:
|
|
348
|
+
* they route other packages, not this one.
|
|
349
|
+
*/
|
|
350
|
+
export function npmRegistry({
|
|
351
|
+
env = process.env, readText = (f) => readFileSync(f, 'utf8'), home = homedir(),
|
|
352
|
+
} = {}) {
|
|
353
|
+
const unquote = (v) => v.trim().replace(/^(["'])(.*)\1$/, '$2');
|
|
354
|
+
const fromEnv = unquote(env.npm_config_registry || env.NPM_CONFIG_REGISTRY || '');
|
|
355
|
+
if (fromEnv) return fromEnv;
|
|
356
|
+
try {
|
|
357
|
+
for (const raw of readText(join(home, '.npmrc')).split(/\r?\n/)) {
|
|
358
|
+
const line = raw.trim();
|
|
359
|
+
if (!line || line.startsWith('#') || line.startsWith(';')) continue;
|
|
360
|
+
const m = line.match(/^registry\s*=\s*(.+)$/);
|
|
361
|
+
if (m && unquote(m[1])) return unquote(m[1]);
|
|
362
|
+
}
|
|
363
|
+
} catch (e) { debug('update-check:npmrc', e); }
|
|
364
|
+
return 'https://registry.npmjs.org/';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The `latest` dist-tag URL under a registry base, or a throw. The base comes
|
|
369
|
+
* from the environment or a config file, so it is checked rather than trusted:
|
|
370
|
+
* https only, and the parsed result must still sit under the base (`new URL`
|
|
371
|
+
* has collapsed any `../` by then, so the prefix test runs on the parsed href).
|
|
372
|
+
*/
|
|
373
|
+
export function latestUrl(registry) {
|
|
374
|
+
const base = new URL(String(registry).endsWith('/') ? String(registry) : `${registry}/`);
|
|
375
|
+
if (base.protocol !== 'https:') {
|
|
376
|
+
// origin has no userinfo; it is "null" for non-http(s) schemes
|
|
377
|
+
throw new Error(`refusing to request ${base.origin === 'null' ? base.protocol : base.origin}: registry must use https`);
|
|
378
|
+
}
|
|
379
|
+
const url = new URL(`${CANONICAL_NAME}/latest`, base);
|
|
380
|
+
if (!url.href.startsWith(base.href)) {
|
|
381
|
+
throw new Error(`refusing to request ${url.origin}${url.pathname}: outside the registry base`);
|
|
382
|
+
}
|
|
383
|
+
return url;
|
|
384
|
+
}
|
|
385
|
+
|
|
337
386
|
/**
|
|
338
387
|
* Fetch the highlights for one version. Returns [] on any failure: the notice
|
|
339
388
|
* is worth showing with a bare version number, so a missing release, a rate
|
|
@@ -370,13 +419,16 @@ export async function fetchHighlights(version, { timeoutMs = FETCH_TIMEOUT_MS, l
|
|
|
370
419
|
* Actually hit the registry and persist the answer. Only the detached child
|
|
371
420
|
* and the explicit `update-check --refresh` command call this.
|
|
372
421
|
*/
|
|
373
|
-
export async function refreshUpdateState(currentVersion) {
|
|
422
|
+
export async function refreshUpdateState(currentVersion, { env, home } = {}) {
|
|
374
423
|
const controller = new AbortController();
|
|
375
424
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
376
425
|
try {
|
|
377
426
|
// The `latest` dist-tag endpoint returns a few hundred bytes, unlike the
|
|
378
427
|
// full packument which is megabytes for a package with this many releases.
|
|
379
|
-
|
|
428
|
+
// The URL is a checked https registry base plus a constant package path.
|
|
429
|
+
// Same scanner false positive as the releases request above.
|
|
430
|
+
// nosemgrep: rules.lgpl.nodejs_scan.javascript-ssrf-rule-node_ssrf
|
|
431
|
+
const res = await fetch(latestUrl(npmRegistry({ env, home })), {
|
|
380
432
|
signal: controller.signal,
|
|
381
433
|
headers: { accept: 'application/json' },
|
|
382
434
|
});
|