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 CHANGED
@@ -16,14 +16,14 @@ Sprag 은 기본적으로 로컬에서만 동작합니다. 팀 기능은 켜야
16
16
  | 모델 계열 비중 | 위임 실행 중 모델 계열별 비율 |
17
17
  | Sprag 버전 | 설치된 버전 |
18
18
  | 에이전트 | `claude` 또는 `codex` |
19
- | 표시 이름 | 참여할 때 정한 이름이며, 기본값은 OS 사용자 이름 |
20
- | 운영체제 | OS 이름과 버전(예: macOS 26, Windows 11, Ubuntu 24.04), CPU 아키텍처(arm64, x64)입니다. 참여한 직후 한 번 보내고, 바뀔 때만 다시 보냅니다. 호스트 이름, 사용자 이름, 경로처럼 컴퓨터를 이름으로 알아볼 수 있는 값은 보내지 않습니다 |
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` 은 표시 이름이며 기본값은 OS 사용자 이름입니다. 터미널이 없는 셸에서는 `--yes` 를 붙여 확인을 대신합니다. 토큰을 생략하면 `SPRAG_TEAM_TOKEN` 을 읽습니다.
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 your OS user name |
20
- | Operating system | OS name and version (for example macOS 26, Windows 11, Ubuntu 24.04) and CPU architecture (arm64 or x64); sent once after joining and again only if it changes. Nothing that identifies the machine by name: no hostname, no user name, no path |
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) and `member_set_sync` (the upload-switch status call, see "The upload switch"); `sprag team create` also calls `team_create` with your sign-in session.
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: your OS user name). In a shell without a terminal, pass `--yes` to confirm. If you leave the token out, Sprag reads `SPRAG_TEAM_TOKEN`.
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.1",
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": {
@@ -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
- let osName = 'my';
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
- let display = 'member';
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) { try { name = userInfo().username; } catch { name = 'member'; } }
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;
@@ -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 { userInfo, release as osRelease } from 'node:os';
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, OS version and CPU architecture. Nothing that names the
334
- // machine (no hostname, username or path) is read or sent.
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, arch = process.arch, release = osRelease(),
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: String(arch).slice(0, 40) };
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() || 'member';
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
- let name = 'member';
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) {
@@ -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
- const res = await fetch(`https://registry.npmjs.org/${CANONICAL_NAME}/latest`, {
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
  });