sprag-cli 3.75.1 → 3.76.0
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 +13 -10
- package/docs/TEAM.md +13 -10
- package/package.json +1 -1
- package/src/commands/team.js +17 -9
- package/src/member-id.js +64 -0
- package/src/team-sync.js +21 -11
- package/src/update-check.js +54 -2
package/docs/TEAM.ko.md
CHANGED
|
@@ -16,31 +16,32 @@ Sprag 은 기본적으로 로컬에서만 동작합니다. 팀 기능은 켜야
|
|
|
16
16
|
| 모델 계열 비중 | 위임 실행 중 모델 계열별 비율 |
|
|
17
17
|
| Sprag 버전 | 설치된 버전 |
|
|
18
18
|
| 에이전트 | `claude` 또는 `codex` |
|
|
19
|
-
| 표시 이름 | 참여할 때 정한 이름이며, 기본값은
|
|
20
|
-
|
|
|
19
|
+
| 표시 이름 | 참여할 때 정한 이름이며, 기본값은 컴퓨터마다 한 번 만드는 `member-3f9a2c71` 같은 무작위 구성원 ID |
|
|
20
|
+
| 기기 ID | 위와 같은 무작위 구성원 ID입니다. 표시 이름을 바꿔도 참여할 때 함께 보내며, 같은 컴퓨터가 다시 참여하면 조직 안에서 기존 좌석을 그대로 쓰게 하는 데 씁니다 |
|
|
21
|
+
| 운영체제 | OS 이름과 버전(예: macOS 26, Windows 11, Ubuntu 24.04)입니다. 참여한 직후 한 번 보내고, 바뀔 때만 다시 보냅니다. 호스트 이름, 사용자 이름, 경로처럼 컴퓨터를 이름으로 알아볼 수 있는 값은 보내지 않습니다 |
|
|
21
22
|
|
|
22
23
|
보내지 않는 것은 프롬프트, 대화 기록, 파일 경로, 프로젝트 이름, 룰 내용, API 키입니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 옵트인 프롬프트 업로드만 예외이며 "프롬프트 사용량 (엔터프라이즈)" 절에 적었습니다. 룰은 직접 `sprag team propose` 를 실행할 때만 밖으로 나갑니다.
|
|
23
24
|
|
|
24
25
|
하루 단위 집계 업로드는 마지막 성공 이후 6시간에 한 번 이하로 실행됩니다. SessionStart 훅(그리고 작은 로컬 파일만 읽는 프롬프트 훅)이 띄운 분리된 자식 프로세스가 보냅니다. 네트워크가 끊기는 등 동기화가 실패하면 6시간을 기다리지 않고 15분, 30분, 60분 뒤에 다시 시도합니다(최대 60분 간격). 마지막으로 올린 날짜를 기억해 두었다가 그 전날부터 최대 30일 치를 다시 보내므로, 며칠 꺼져 있던 컴퓨터도 처음 성공하는 동기화에서 빠진 날을 따라잡습니다. 하루 단위 행은 덮어쓰기라 다시 보내도 안전합니다. 프롬프트 수집을 켠 엔터프라이즈 팀의 프롬프트 업로드는 따로 30분 타이머로 움직입니다("프롬프트 사용량 (엔터프라이즈)" 절 참고). statusline 을 그리는 경로에서는 절대 실행되지 않습니다.
|
|
25
26
|
|
|
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`(업로드 스위치 상태 호출, "업로드 스위치" 참고) 입니다.
|
|
27
|
+
목적지는 `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
28
|
|
|
28
29
|
## CLI에서 팀 만들기
|
|
29
30
|
|
|
30
|
-
웹 대시보드
|
|
31
|
+
웹 대시보드 없이도, 내가 오너나 어드민인 조직 안에 팀을 만들 수 있습니다. 조직 ID는 대시보드에 있고, 조직 개설은 Sprag가 합니다("조직과 좌석" 절 참고). 로그인은 메일로 받는 6자리 코드(15분간 유효)로 하며, Sprag 은 로그인 세션을 저장하지 않습니다.
|
|
31
32
|
|
|
32
33
|
터미널에서는 명령 하나를 실행하고 묻는 대로 답하면 됩니다 (이메일, 코드, 팀 이름).
|
|
33
34
|
|
|
34
35
|
```bash
|
|
35
|
-
sprag team create
|
|
36
|
+
sprag team create --org <조직 ID>
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
에이전트나 터미널이 없는 셸에서는 두 단계로 나눕니다.
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
sprag team create --email you@example.com --name "Acme"
|
|
42
|
+
sprag team create --org <조직 ID> --email you@example.com --name "Acme"
|
|
42
43
|
# 메일로 코드가 오면 이어서 실행합니다
|
|
43
|
-
sprag team create --email you@example.com --name "Acme" --code 123456
|
|
44
|
+
sprag team create --org <조직 ID> --email you@example.com --name "Acme" --code 123456
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
첫 단계는 코드만 보냅니다. 두 번째 단계에서 코드를 확인하고 팀을 만든 뒤 초대 토큰, 멤버용 명령(`npm i -g sprag-cli@latest && sprag team join <토큰>`), 조직용 환경 변수(`export SPRAG_TEAM_TOKEN=<토큰>`), 대시보드 주소를 출력합니다. 같은 이메일로 https://sprag.io/team/ 에 로그인하면 팀을 관리할 수 있습니다.
|
|
@@ -55,7 +56,7 @@ sprag team create --email you@example.com --name "Acme" --code 123456
|
|
|
55
56
|
sprag team join <토큰> --name "미나"
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
Sprag 이 올라갈 항목 목록을 보여 주고 확인을 묻습니다. `--name` 은 표시 이름이며 기본값은
|
|
59
|
+
Sprag 이 올라갈 항목 목록을 보여 주고 확인을 묻습니다. `--name` 은 표시 이름이며 기본값은 `member-3f9a2c71` 같은 무작위 구성원 ID입니다. 이 ID는 컴퓨터마다 한 번 만들어 사용자 데이터 디렉터리에 보관하며, `--name` 으로 바꿀 수 있습니다. 이 변경 전에 참여한 구성원은 서버에 이미 저장된 이름을 그대로 씁니다. 터미널이 없는 셸에서는 `--yes` 를 붙여 확인을 대신합니다. 토큰을 생략하면 `SPRAG_TEAM_TOKEN` 을 읽습니다.
|
|
59
60
|
|
|
60
61
|
결과는 `sprag team status` 로 확인합니다.
|
|
61
62
|
|
|
@@ -99,14 +100,16 @@ Sprag 은 룰을 `~/.claude/ratchet-team.md`(Codex 는 `<CODEX_HOME>/ratchet-tea
|
|
|
99
100
|
|
|
100
101
|
## 조직과 좌석
|
|
101
102
|
|
|
102
|
-
모든 팀은 조직에 속하고, 좌석은 팀이 아니라 조직 단위로 구매합니다. 조직을
|
|
103
|
+
모든 팀은 조직에 속하고, 좌석은 팀이 아니라 조직 단위로 구매합니다. 조직은 Sprag만 개설합니다. sales@sprag.io 로 문의하면 슈퍼유저가 sprag.io/ops/ 에서 조직을 열고 첫 오너를 지정하며, 그다음부터 오너와 어드민이 그 안에 팀을 만듭니다.
|
|
104
|
+
|
|
105
|
+
컴퓨터 한 대는 조직마다 좌석 하나를 씁니다. `sprag team leave` 뒤에 다시 참여하거나, 재설치하거나, 같은 조직의 다른 팀 초대로 참여해도 서버가 기기 ID로 예전 행을 찾아 초대받은 팀으로 옮기고 멤버 키만 새로 발급합니다. 행도 좌석도 두 개가 되지 않습니다. 다른 조직에 참여하면 그 조직의 좌석을 따로 씁니다.
|
|
103
106
|
|
|
104
107
|
- **좌석이란.** 팀을 떠나지 않았고, 최근 30일 안에 무엇이든 보냈거나 업로드를 멈춘 상태인 컴퓨터 한 대(멤버 키 하나)입니다. 멈추지 않았는데 30일 넘게 연락이 없는 컴퓨터는 좌석을 차지하지 않습니다. 떠난 멤버도 세지 않습니다.
|
|
105
108
|
- **두 번째 컴퓨터.** 같은 사람이 컴퓨터 두 대를 쓰면 오너나 어드민이 두 번째 컴퓨터를 첫 번째에 합쳐 좌석 하나를 같이 쓰게 할 수 있습니다. 대시보드의 멤버 행에서 "좌석 합치기"를 누르고, "합치기 해제"를 누르면 다시 따로 셉니다. 합쳐진 행에는 "OO님과 자리를 같이 씁니다"가 보입니다.
|
|
106
109
|
- **한도.** 조직마다 좌석 한도가 있고, 한도를 넘는 참여는 거절됩니다. `sprag team join` 은 "이 조직에 남은 좌석이 없습니다. 조직 오너나 어드민에게 좌석 추가를 요청한 뒤 다시 참여하세요"를 출력하고 종료 코드 1로 끝납니다. 환경 변수로 하는 자동 참여는 조용히 넘어갔다가 다음 세션 시작 때 다시 시도합니다. 한도를 현재 사용량보다 낮춰도 기존 멤버는 그대로 쓰고, Sprag가 다시 올릴 때까지 새 참여만 거절됩니다.
|
|
107
110
|
- **역할.** 권한 단계는 세 가지입니다. 슈퍼유저는 Sprag 직원이며 요금제와 좌석 한도를 정하고, 조직을 정지하고, 오너를 추가하고, sprag.io/ops/ 에서 모든 조직을 봅니다. 오너는 회사의 최상위 단계로, 어드민을 지정하거나 해제하고 어드민이 하는 일을 모두 합니다. 어드민은 회사의 관리 단계로, 팀을 만들고 구성원과 룰과 좌석을 관리합니다. 팀을 만든 사람에게도 특별한 권한이 없으며, 팀은 조직의 오너와 어드민, 그리고 슈퍼유저만 관리합니다. 오너와 어드민은 대시보드에서 좌석 한도를 볼 수 있습니다. 좌석을 늘리려면 sales@sprag.io 로 문의합니다.
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
관리하는 조직 안에 팀을 만들려면 `sprag team create --org <조직 ID>` 를 씁니다. 조직은 Sprag만 개설하므로 `--org` 없이 실행하면 서버가 거절합니다. 명령은 조직 이름과 사용 중인 좌석 수를 함께 출력하고, `sprag team status` 는 조직 이름을 보여 줍니다.
|
|
110
113
|
|
|
111
114
|
## 업로드 스위치
|
|
112
115
|
|
package/docs/TEAM.md
CHANGED
|
@@ -16,31 +16,32 @@ 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
|
-
|
|
|
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
|
+
| Machine ID | the same random member ID, sent at join even when you choose another display name, so that a machine joining again reuses its seat in the organization instead of taking a second one |
|
|
21
|
+
| 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
22
|
|
|
22
23
|
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
24
|
|
|
24
25
|
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
26
|
|
|
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)
|
|
27
|
+
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
28
|
|
|
28
29
|
## Create a team from the CLI
|
|
29
30
|
|
|
30
|
-
You can set up a team without the web dashboard. Signing in uses a 6-digit code emailed to you (valid 15 minutes). Sprag never stores the sign-in session.
|
|
31
|
+
You can set up a team without the web dashboard, inside an organization you own or administer (the organization id is in the dashboard; Sprag opens organizations, see "Organizations and seats"). Signing in uses a 6-digit code emailed to you (valid 15 minutes). Sprag never stores the sign-in session.
|
|
31
32
|
|
|
32
33
|
In a terminal, run one command and answer the prompts (email, code, team name):
|
|
33
34
|
|
|
34
35
|
```bash
|
|
35
|
-
sprag team create
|
|
36
|
+
sprag team create --org <org-id>
|
|
36
37
|
```
|
|
37
38
|
|
|
38
39
|
From an agent or any shell without a terminal, it takes two steps:
|
|
39
40
|
|
|
40
41
|
```bash
|
|
41
|
-
sprag team create --email you@example.com --name "Acme"
|
|
42
|
+
sprag team create --org <org-id> --email you@example.com --name "Acme"
|
|
42
43
|
# the code arrives by email, then:
|
|
43
|
-
sprag team create --email you@example.com --name "Acme" --code 123456
|
|
44
|
+
sprag team create --org <org-id> --email you@example.com --name "Acme" --code 123456
|
|
44
45
|
```
|
|
45
46
|
|
|
46
47
|
The first step only sends the code. The second verifies it and creates the team. It then prints the invite token, the member command (`npm i -g sprag-cli@latest && sprag team join <token>`), the organization line (`export SPRAG_TEAM_TOKEN=<token>`) and the dashboard address. Sign in to https://sprag.io/team/ with the same email to manage the team.
|
|
@@ -55,7 +56,7 @@ An owner or admin gives you an invite token. Then:
|
|
|
55
56
|
sprag team join <token> --name "Mina"
|
|
56
57
|
```
|
|
57
58
|
|
|
58
|
-
Sprag prints the list of what will be uploaded and asks for confirmation. `--name` sets the display name (default:
|
|
59
|
+
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
60
|
|
|
60
61
|
Check the result with `sprag team status`.
|
|
61
62
|
|
|
@@ -99,7 +100,9 @@ Sprag writes the rules to `~/.claude/ratchet-team.md` (Codex: `<CODEX_HOME>/ratc
|
|
|
99
100
|
|
|
100
101
|
## Organizations and seats
|
|
101
102
|
|
|
102
|
-
Every team belongs to an organization, and seats are bought per organization, not per team.
|
|
103
|
+
Every team belongs to an organization, and seats are bought per organization, not per team. Only Sprag opens organizations: write to sales@sprag.io, and the superuser opens one at sprag.io/ops/ and names its first owner. Owners and admins then create teams inside it.
|
|
104
|
+
|
|
105
|
+
A machine holds one seat per organization. When it joins again, with `sprag team join` after `sprag team leave`, after a reinstall, or with the invite of another team in the same organization, the server finds its earlier row by the machine ID, moves it to the invited team and issues a new member key; no second row and no second seat appear. Joins to a different organization take a seat there.
|
|
103
106
|
|
|
104
107
|
- **What a seat is.** One machine (one member key) that has not left and either sent anything in the last 30 days or is paused. A machine that has been silent for 30 days or more without being paused holds no seat. Members who left are not counted.
|
|
105
108
|
- **Second machine.** When the same person uses two machines, an owner or admin can merge the second one into the first so the two share one seat. In the dashboard, use "Merge seat" on the member row; "Unmerge" gives it its own seat back. A merged row shows "shares a seat with <name>".
|
|
@@ -107,7 +110,7 @@ Every team belongs to an organization, and seats are bought per organization, no
|
|
|
107
110
|
- **Roles.** There are three permission levels. The superuser is Sprag staff: it sets the plan and the seat limit, suspends organizations, adds owners, and sees every organization at sprag.io/ops/. An owner is a company's top level: it appoints and removes admins, and does everything an admin does. An admin is a company's management level: it creates teams and manages members, rules and seats. The person who created a team has no special rights over it; only the organization's owners and admins and the superuser manage teams. Owners and admins see the seat limit in the dashboard. To add seats, write to sales@sprag.io.
|
|
108
111
|
- **The plan.** Sprag sets the plan (`pro` or `enterprise`) on the organization; owners and admins cannot change it. Prompt capture can be switched on only while the organization is on Enterprise. To change the plan, contact us.
|
|
109
112
|
|
|
110
|
-
Create a team inside an
|
|
113
|
+
Create a team inside an organization you administer from the CLI with `sprag team create --org <org-id>`; without `--org` the server refuses, because only Sprag opens organizations. The command prints the organization name and how many seats are used. `sprag team status` shows the organization name.
|
|
111
114
|
|
|
112
115
|
## The upload switch
|
|
113
116
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sprag-cli",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.76.0",
|
|
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,
|
|
@@ -33,6 +33,7 @@ const T = {
|
|
|
33
33
|
].join('\n'),
|
|
34
34
|
willSend: 'Joining uploads daily aggregates only, per local day:\n - spend (USD)\n - savings (USD)\n - delegation run counts\n - model-family share\nNo prompts, file paths or transcripts are sent.',
|
|
35
35
|
asName: (n) => `Display name: ${n}`,
|
|
36
|
+
asDevice: (d) => `Machine ID: ${d} (lets the organization count this machine once if it joins again)`,
|
|
36
37
|
ask: 'Join? [y/N] ',
|
|
37
38
|
needYes: 'Not a terminal. Re-run with --yes to confirm: sprag team join <token> --yes',
|
|
38
39
|
noToken: 'Missing invite token. Pass it as an argument or set SPRAG_TEAM_TOKEN.',
|
|
@@ -43,6 +44,7 @@ const T = {
|
|
|
43
44
|
seats: (u, l) => `${u}/${l} seats used`,
|
|
44
45
|
orgCreated: (n, u, l) => `Organization: ${n}${u != null && l != null ? ` (${u}/${l} seats used)` : ''}`,
|
|
45
46
|
badOrg: '--org needs an organization id.',
|
|
47
|
+
noOrgCreate: 'Only Sprag opens new organizations. Pass --org <id> for an organization you administer (the id is on the dashboard), or write to sales@sprag.io to open one.',
|
|
46
48
|
left: 'Left the team. Nothing is uploaded any more.',
|
|
47
49
|
notJoined: 'Not in a team. Join with: sprag team join <token>',
|
|
48
50
|
team: 'Team', plan: 'Plan', capture: 'Prompt capture', name: 'Display name',
|
|
@@ -96,6 +98,7 @@ const T = {
|
|
|
96
98
|
].join('\n'),
|
|
97
99
|
willSend: '참여하면 로컬 날짜별 하루 단위 집계만 올라갑니다.\n - 지출(USD)\n - 절감(USD)\n - 위임 실행 횟수\n - 모델 계열 비중\n프롬프트, 파일 경로, 대화 기록은 보내지 않습니다.',
|
|
98
100
|
asName: (n) => `표시 이름: ${n}`,
|
|
101
|
+
asDevice: (d) => `기기 ID: ${d} (다시 참여해도 조직이 이 컴퓨터를 한 좌석으로 셉니다)`,
|
|
99
102
|
ask: '참여할까요? [y/N] ',
|
|
100
103
|
needYes: '터미널이 아닙니다. 확인하려면 --yes 를 붙여 다시 실행하세요: sprag team join <토큰> --yes',
|
|
101
104
|
noToken: '초대 토큰이 없습니다. 인자로 넘기거나 SPRAG_TEAM_TOKEN 을 설정하세요.',
|
|
@@ -106,6 +109,7 @@ const T = {
|
|
|
106
109
|
seats: (u, l) => `좌석 ${u}/${l} 사용 중`,
|
|
107
110
|
orgCreated: (n, u, l) => `조직: ${n}${u != null && l != null ? ` (좌석 ${u}/${l} 사용 중)` : ''}`,
|
|
108
111
|
badOrg: '--org 에는 조직 ID 가 필요합니다',
|
|
112
|
+
noOrgCreate: '새 조직은 Sprag가 개설합니다. 관리하는 조직이 있으면 --org <ID> 를 붙여 주세요(ID 는 대시보드에 있습니다). 조직 개설은 sales@sprag.io 로 문의해 주세요.',
|
|
109
113
|
left: '팀에서 나왔습니다. 이제 아무것도 업로드하지 않습니다.',
|
|
110
114
|
notJoined: '참여한 팀이 없습니다. 참여하려면: sprag team join <토큰>',
|
|
111
115
|
team: '팀', plan: '플랜', capture: '프롬프트 수집', name: '표시 이름',
|
|
@@ -191,9 +195,7 @@ async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive
|
|
|
191
195
|
if (!email) { console.error(m.needEmail); return process.exit(2); }
|
|
192
196
|
if (!EMAIL_RE.test(email)) { console.error(m.badEmail); return process.exit(2); }
|
|
193
197
|
|
|
194
|
-
|
|
195
|
-
try { osName = userInfo().username || 'my'; } catch { /* no passwd entry */ }
|
|
196
|
-
const defaultName = `${osName}'s team`;
|
|
198
|
+
const defaultName = `${memberId()}'s team`;
|
|
197
199
|
|
|
198
200
|
let useCode = code;
|
|
199
201
|
if (!useCode) {
|
|
@@ -212,7 +214,13 @@ async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive
|
|
|
212
214
|
if (!name) { console.error(m.needName); return process.exit(2); }
|
|
213
215
|
|
|
214
216
|
const { accessToken, userId } = await verifySignInCode({ email, code: useCode, fetch });
|
|
215
|
-
|
|
217
|
+
let team;
|
|
218
|
+
try {
|
|
219
|
+
team = await createTeam({ name, accessToken, userId, orgId: org || null, fetch });
|
|
220
|
+
} catch (e) {
|
|
221
|
+
if (/only Sprag operators/i.test(String(e && e.message))) { console.error(m.noOrgCreate); return process.exit(1); }
|
|
222
|
+
throw e;
|
|
223
|
+
}
|
|
216
224
|
out(m.created(team.name || name));
|
|
217
225
|
if (team.orgName && noJoin) out(m.orgCreated(team.orgName, null, null));
|
|
218
226
|
out(`${m.inviteToken}: ${team.inviteToken}`);
|
|
@@ -221,8 +229,7 @@ async function createFlow({ m, out, hasFlag, getArg, version, fetch, interactive
|
|
|
221
229
|
out(m.dashboard(email));
|
|
222
230
|
|
|
223
231
|
if (noJoin) { out(m.noJoinNote); return undefined; }
|
|
224
|
-
|
|
225
|
-
try { display = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
232
|
+
const display = memberId();
|
|
226
233
|
const joined = await joinTeam({ token: team.inviteToken, displayName: display, fetch, version });
|
|
227
234
|
if (joined.orgName && joined.seatsUsed != null && joined.seatLimit != null) out(m.orgCreated(joined.orgName, joined.seatsUsed, joined.seatLimit));
|
|
228
235
|
if (!teamSyncDisabled()) {
|
|
@@ -248,14 +255,15 @@ export async function run({ args, hasFlag, getArg, version, fetch, interactive,
|
|
|
248
255
|
const token = (args[2] && !args[2].startsWith('--') ? args[2] : null) || process.env.SPRAG_TEAM_TOKEN;
|
|
249
256
|
if (!token) { console.error(m.noToken); return process.exit(2); }
|
|
250
257
|
let name = getArg('--name');
|
|
251
|
-
if (!name)
|
|
258
|
+
if (!name) name = memberId();
|
|
252
259
|
out(m.willSend);
|
|
253
260
|
out(m.asName(name));
|
|
261
|
+
out(m.asDevice(memberId()));
|
|
254
262
|
if (!hasFlag('--yes')) {
|
|
255
263
|
if (!process.stdin.isTTY || !process.stdout.isTTY) { console.error(m.needYes); return process.exit(2); }
|
|
256
264
|
if (!(await confirm(m.ask))) { out(m.aborted); return undefined; }
|
|
257
265
|
}
|
|
258
|
-
const st = await joinTeam({ token, displayName: name, version });
|
|
266
|
+
const st = await joinTeam({ token, displayName: name, fetch, version });
|
|
259
267
|
out(m.joined(st.teamName));
|
|
260
268
|
if (st.orgName && st.seatsUsed != null && st.seatLimit != null) out(m.orgCreated(st.orgName, st.seatsUsed, st.seatLimit));
|
|
261
269
|
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,12 +390,20 @@ 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
|
|
393
|
+
const device = memberId({ dir });
|
|
394
|
+
const name = String(displayName || '').trim() || device;
|
|
395
|
+
const params = { invite_token: token, display_name: name, agent, sprag_version: version || '', auto_joined: !!autoJoined };
|
|
391
396
|
let r;
|
|
392
397
|
try {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
398
|
+
// device_id lets the server find this machine's earlier row in the organization and reuse its seat
|
|
399
|
+
// instead of adding a second one. A server older than that answers "Could not find the function";
|
|
400
|
+
// the join is then repeated without it.
|
|
401
|
+
try {
|
|
402
|
+
r = await rpc('team_join', { ...params, device_id: device }, { fetch });
|
|
403
|
+
} catch (e) {
|
|
404
|
+
if (!/device_id|Could not find the function|PGRST202/i.test(String(e && e.message))) throw e;
|
|
405
|
+
r = await rpc('team_join', params, { fetch });
|
|
406
|
+
}
|
|
396
407
|
} catch (e) {
|
|
397
408
|
if (isSeatLimitError(e)) throw new SeatLimitError(e.message);
|
|
398
409
|
throw e;
|
|
@@ -604,8 +615,7 @@ export async function autoJoin({ env = process.env, fetch, dir = userDataDir(),
|
|
|
604
615
|
// A state with `left: true` never auto-rejoins: leaving must beat the
|
|
605
616
|
// environment, or `sprag team leave` would be undone by the next session.
|
|
606
617
|
if (isJoined(state) || state.left) return null;
|
|
607
|
-
|
|
608
|
-
try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
|
|
618
|
+
const name = memberId({ dir });
|
|
609
619
|
try {
|
|
610
620
|
return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now, env });
|
|
611
621
|
} 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
|
});
|