sprag-cli 3.68.0 → 3.70.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.
@@ -0,0 +1,176 @@
1
+ /**
2
+ * team-ratchet — rules the user's team approved, kept in a tool-owned file.
3
+ *
4
+ * `sprag team sync` caches the approved rules in team-rules.json. This module
5
+ * only turns that cache into a ratchet file (no network), keeps the CLAUDE.md
6
+ * import in step with the file, and starts the detached sync when it is due.
7
+ * Every function degrades to a no-op for a machine that never joined a team.
8
+ *
9
+ * Claude Code ~/.claude/ratchet-team.md
10
+ * Codex <CODEX_HOME>/ratchet-team.md
11
+ */
12
+
13
+ import * as childProcess from 'node:child_process';
14
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { codexUserDir } from './agent.js';
18
+ import { userLanguage } from './config.js';
19
+ import { debug } from './debug.js';
20
+ import {
21
+ HARNESS_BLOCK_BEGIN,
22
+ HARNESS_BLOCK_END,
23
+ TEAM_RATCHET_IMPORT_RE,
24
+ teamRatchetImportLine,
25
+ } from './harness-templates.js';
26
+ import { userDataDir } from './paths.js';
27
+ import { writeStateFile, writeViaTmp } from './state-file.js';
28
+
29
+ export { TEAM_RATCHET_IMPORT_RE, teamRatchetImportLine };
30
+
31
+ export const TEAM_RATCHET_MARKER = '<!-- sprag:team-ratchet -->';
32
+ const SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
33
+ const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
34
+
35
+ export const teamStatePathFor = (dir = userDataDir()) => join(dir, 'team.json');
36
+ export const teamRulesCachePathFor = (dir = userDataDir()) => join(dir, 'team-rules.json');
37
+
38
+ function readJson(file) {
39
+ try {
40
+ const v = JSON.parse(readFileSync(file, 'utf8'));
41
+ return v && typeof v === 'object' ? v : null;
42
+ } catch { return null; }
43
+ }
44
+
45
+ export function readTeamState(dir = userDataDir()) {
46
+ return readJson(teamStatePathFor(dir)) || {};
47
+ }
48
+
49
+ export function readTeamRulesCache(dir = userDataDir()) {
50
+ return readJson(teamRulesCachePathFor(dir));
51
+ }
52
+
53
+ export function isJoined(state) {
54
+ return !!(state && state.memberKey && state.left !== true);
55
+ }
56
+
57
+ export function teamSyncDue(state, now = Date.now()) {
58
+ if (!isJoined(state)) return false;
59
+ const at = Number(state.lastSyncAt);
60
+ return !Number.isFinite(at) || at <= 0 || now - at > SYNC_INTERVAL_MS;
61
+ }
62
+
63
+ export function autoJoinPending(state, env = process.env) {
64
+ return TOKEN_RE.test(env.SPRAG_TEAM_TOKEN || '') && !isJoined(state) && state?.left !== true;
65
+ }
66
+
67
+ export function teamSyncDisabled(env = process.env) {
68
+ return env.CTS_NO_TEAM_SYNC === '1';
69
+ }
70
+
71
+ export function teamRatchetPath({ agent = 'claude' } = {}) {
72
+ return agent === 'codex'
73
+ ? join(codexUserDir(), 'ratchet-team.md')
74
+ : join(homedir(), '.claude', 'ratchet-team.md');
75
+ }
76
+
77
+ export function renderTeamRatchet(cache, { lang = 'en', agent = 'claude' } = {}) {
78
+ if (!cache || !Array.isArray(cache.rules)) return null;
79
+ const removed = new Set(Array.isArray(cache.removed) ? cache.removed : []);
80
+ const live = cache.rules.filter((r) => r && typeof r.text === 'string' && r.text.trim() && !removed.has(r.id));
81
+ const ratchet = live.filter((r) => (r.scope || 'ratchet') !== 'model');
82
+ const model = live.filter((r) => r.scope === 'model');
83
+ if (!ratchet.length && !model.length) return null;
84
+ const ko = lang === 'ko' && agent !== 'codex';
85
+ const name = cache.teamName || '';
86
+ const head = ko
87
+ ? `# 팀 래칫 룰 (sprag 관리)\n\n팀 "${name}" 이 승인한 룰입니다. sprag 가 세션 시작 때 통째로 다시 쓰므로 직접 고치지 마십시오. 목록: \`sprag team rules\``
88
+ : `# Team Ratchet Rules (managed by sprag)\n\nRules approved by team "${name}". sprag rewrites this file at session start, so do not edit it by hand. List: \`sprag team rules\``;
89
+ const line = (r) => `- ${r.text.trim().replace(/\s*\n\s*/g, ' ')}`;
90
+ let out = `${TEAM_RATCHET_MARKER}\n${head}\n`;
91
+ if (ratchet.length) out += `\n## Rules\n\n${ratchet.map(line).join('\n')}\n`;
92
+ if (model.length) out += `\n## Model rules\n\n${model.map(line).join('\n')}\n`;
93
+ return out;
94
+ }
95
+
96
+ function writeIfChanged(file, text) {
97
+ let before = null;
98
+ try { before = readFileSync(file, 'utf8'); } catch { /* first write */ }
99
+ if (before === text) return false;
100
+ mkdirSync(dirname(file), { recursive: true });
101
+ writeViaTmp(`${file}.tmp-${process.pid}`, file, text);
102
+ return true;
103
+ }
104
+
105
+ function ownedFile(file) {
106
+ try { return readFileSync(file, 'utf8').split('\n', 1)[0].trim() === TEAM_RATCHET_MARKER; } catch { return false; }
107
+ }
108
+
109
+ export function syncTeamRatchet({ agent = 'claude', lang = userLanguage(), dir = userDataDir() } = {}) {
110
+ const file = teamRatchetPath({ agent });
111
+ const state = readTeamState(dir);
112
+ const text = isJoined(state) ? renderTeamRatchet(readTeamRulesCache(dir), { lang, agent }) : null;
113
+ if (text === null) {
114
+ if (existsSync(file) && ownedFile(file)) {
115
+ rmSync(file, { force: true });
116
+ return { file, rules: 0, changed: true, removed: true };
117
+ }
118
+ return { file, rules: 0, changed: false, removed: false };
119
+ }
120
+ const changed = writeIfChanged(file, text);
121
+ return { file, rules: (text.match(/^- /gm) || []).length, changed, removed: false };
122
+ }
123
+
124
+ /**
125
+ * Keep the team import in the global harness block in step with the file.
126
+ * Only the tool's own marked block is edited; a CLAUDE.md without it is left alone.
127
+ */
128
+ export function ensureTeamImport() {
129
+ const file = join(homedir(), '.claude', 'CLAUDE.md');
130
+ let text;
131
+ try { text = readFileSync(file, 'utf8'); } catch { return { file, added: false, removed: false }; }
132
+ const begin = text.indexOf(HARNESS_BLOCK_BEGIN);
133
+ const end = text.indexOf(HARNESS_BLOCK_END);
134
+ if (begin < 0 || end < begin) return { file, added: false, removed: false };
135
+ const block = text.slice(begin, end);
136
+ const has = TEAM_RATCHET_IMPORT_RE.test(block);
137
+ const exists = existsSync(teamRatchetPath());
138
+ if (exists && !has) {
139
+ writeFileSync(file, `${text.slice(0, end)}${teamRatchetImportLine()}\n${text.slice(end)}`);
140
+ return { file, added: true, removed: false };
141
+ }
142
+ if (!exists && has) {
143
+ const cleaned = block.replace(/^@~\/\.claude\/ratchet-team\.md[ \t]*\r?\n?/m, '');
144
+ writeFileSync(file, `${text.slice(0, begin)}${cleaned}${text.slice(end)}`);
145
+ return { file, added: false, removed: true };
146
+ }
147
+ return { file, added: false, removed: false };
148
+ }
149
+
150
+ export function spawnTeamSyncIfDue({
151
+ state = readTeamState(), now = Date.now(), env = process.env,
152
+ spawn = childProcess.spawn, argv = process.argv, execPath = process.execPath,
153
+ } = {}) {
154
+ if (teamSyncDisabled(env)) return false;
155
+ if (!autoJoinPending(state, env) && !teamSyncDue(state, now)) return false;
156
+ try {
157
+ spawn(execPath, [argv[1], 'team', 'sync', '--background'],
158
+ { detached: true, stdio: 'ignore', windowsHide: true }).unref();
159
+ return true;
160
+ } catch (e) {
161
+ debug('team-ratchet:spawn', e);
162
+ return false;
163
+ }
164
+ }
165
+
166
+ export function autoJoinNoticeBlock(dir = userDataDir(), lang = userLanguage()) {
167
+ const file = teamStatePathFor(dir);
168
+ const state = readJson(file);
169
+ if (!state || state.autoJoinNoticePending !== true) return null;
170
+ const name = state.teamName || '';
171
+ try { writeStateFile(file, `${JSON.stringify({ ...state, autoJoinNoticePending: false }, null, 2)}\n`); }
172
+ catch (e) { debug('team-ratchet:notice-clear', e); }
173
+ return lang === 'ko'
174
+ ? `Sprag: 이 PC는 SPRAG_TEAM_TOKEN 으로 팀 "${name}" 에 자동 참여됐습니다. 보내는 것은 하루 단위 집계(지출·절감·위임 횟수)뿐입니다. \`sprag team status --show-payload\` 로 내용을 확인하고 \`sprag team leave\` 로 멈출 수 있습니다.`
175
+ : `Sprag: this machine was enrolled in team "${name}" through SPRAG_TEAM_TOKEN. It sends daily aggregates only (spend, savings, delegation counts). See what is sent with \`sprag team status --show-payload\`; stop with \`sprag team leave\`.`;
176
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * team-sync — the opt-in team layer's client core.
3
+ *
4
+ * sprag is local-only by default. Nothing here runs unless team.json holds a
5
+ * member key, which only `sprag team join` (or SPRAG_TEAM_TOKEN auto-join)
6
+ * writes. Once joined, the CLI uploads DAILY AGGREGATES ONLY — spend, savings,
7
+ * delegation counts and model-family share per local calendar day — and pulls
8
+ * the team's approved rules. No prompts, paths or transcripts leave the machine.
9
+ *
10
+ * Files in the user data dir:
11
+ * team.json membership state (mode 0600; holds the member key)
12
+ * team-rules.json last pulled team rules
13
+ *
14
+ * Opt out of all network traffic with CTS_NO_TEAM_SYNC=1.
15
+ */
16
+
17
+ import { readFileSync, existsSync, rmSync, chmodSync } from 'node:fs';
18
+ import { join } from 'node:path';
19
+ import { userInfo } from 'node:os';
20
+ import { createRequire } from 'node:module';
21
+ import { userDataDir } from './paths.js';
22
+ import { writeStateFile } from './state-file.js';
23
+ import { debug } from './debug.js';
24
+ import { loadLedger, modelFamily, roundUsd } from './savings-ledger.js';
25
+ import { sessionCost, pricedParts } from './claude-price.js';
26
+
27
+ const require = createRequire(import.meta.url);
28
+
29
+ export const TEAM_API_URL = 'https://hiycfjwswtkixyoqkznm.supabase.co';
30
+ // Public anon key (role=anon). Access is gated by the RPCs, not by this value.
31
+ export const TEAM_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImhpeWNmandzd3RraXh5b3Frem5tIiwicm9sZSI6ImFub24iLCJpYXQiOjE3OTIyNTc4MzYsImV4cCI6MjEwNzgzMzgzNn0.gyYcvGD70CEX64aGV-dsmZgmmOBdvhJlzIpx9Qo2l3w';
32
+ export const TEAM_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
33
+ export const TEAM_STATE_VERSION = 1;
34
+
35
+ const TOKEN_RE = /^spt_[0-9a-f]{32}$/;
36
+ const MAX_ROWS = 400;
37
+
38
+ export function teamStatePath(dir = userDataDir()) { return join(dir, 'team.json'); }
39
+ export function teamRulesCachePath(dir = userDataDir()) { return join(dir, 'team-rules.json'); }
40
+
41
+ export function loadTeamState(dir = userDataDir()) {
42
+ try {
43
+ const s = JSON.parse(readFileSync(teamStatePath(dir), 'utf8'));
44
+ return s && typeof s === 'object' && !Array.isArray(s) ? s : {};
45
+ } catch {
46
+ return {};
47
+ }
48
+ }
49
+
50
+ export function saveTeamState(state, dir = userDataDir()) {
51
+ const file = teamStatePath(dir);
52
+ writeStateFile(file, JSON.stringify(state, null, 2) + '\n');
53
+ if (process.platform !== 'win32') {
54
+ try { chmodSync(file, 0o600); } catch (e) { debug('team-sync', e); }
55
+ }
56
+ }
57
+
58
+ export function teamSyncDisabled(env = process.env) { return env.CTS_NO_TEAM_SYNC === '1'; }
59
+ export function isJoined(state) { return !!(state && state.memberKey && !state.left); }
60
+
61
+ export function teamSyncDue(state, now = Date.now()) {
62
+ return isJoined(state) && (!state.lastSyncAt || now - state.lastSyncAt >= TEAM_SYNC_INTERVAL_MS);
63
+ }
64
+
65
+ export async function rpc(fn, params, {
66
+ fetch = globalThis.fetch, timeoutMs = 8000, url = TEAM_API_URL, key = TEAM_ANON_KEY,
67
+ } = {}) {
68
+ const ctl = new AbortController();
69
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
70
+ try {
71
+ const res = await fetch(`${url}/rest/v1/rpc/${fn}`, {
72
+ method: 'POST',
73
+ headers: { apikey: key, Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
74
+ body: JSON.stringify(params || {}),
75
+ signal: ctl.signal,
76
+ });
77
+ let body = null;
78
+ try { body = await res.json(); } catch { /* empty or non-JSON body */ }
79
+ if (!res.ok) {
80
+ const msg = (body && (body.message || body.hint)) || `HTTP ${res.status}`;
81
+ throw new Error(`${fn}: ${msg}`);
82
+ }
83
+ return body;
84
+ } catch (e) {
85
+ if (e && e.name === 'AbortError') throw new Error(`${fn}: timeout`);
86
+ throw e;
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * One JSON request against the Supabase project, for the owner sign-in flow
94
+ * (GoTrue email code + the teams table). Same injectable fetch and 8s timeout
95
+ * as rpc(). Errors carry the server's own message. The access token only ever
96
+ * lives in memory: it is never written to disk or printed.
97
+ */
98
+ async function apiJson(path, { body, accessToken, label, fetch = globalThis.fetch, timeoutMs = 8000, url = TEAM_API_URL, key = TEAM_ANON_KEY, prefer } = {}) {
99
+ const ctl = new AbortController();
100
+ const timer = setTimeout(() => ctl.abort(), timeoutMs);
101
+ try {
102
+ const headers = { apikey: key, 'Content-Type': 'application/json' };
103
+ if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
104
+ if (prefer) headers.Prefer = prefer;
105
+ const res = await fetch(`${url}${path}`, { method: 'POST', headers, body: JSON.stringify(body || {}), signal: ctl.signal });
106
+ let data = null;
107
+ try { data = await res.json(); } catch { /* empty or non-JSON body */ }
108
+ if (!res.ok) {
109
+ const msg = (data && (data.msg || data.error_description || data.message)) || `HTTP ${res.status}`;
110
+ throw new Error(`${label}: ${msg}`);
111
+ }
112
+ return data;
113
+ } catch (e) {
114
+ if (e && e.name === 'AbortError') throw new Error(`${label}: timeout`);
115
+ throw e;
116
+ } finally {
117
+ clearTimeout(timer);
118
+ }
119
+ }
120
+
121
+ /** Email a 6-digit sign-in code (valid 15 minutes). Creates the user if new. */
122
+ export async function requestSignInCode({ email, fetch } = {}) {
123
+ await apiJson('/auth/v1/otp', { body: { email, create_user: true }, label: 'sign-in', fetch });
124
+ }
125
+
126
+ /** Exchange the emailed code for a session. Returns { accessToken, userId }. */
127
+ export async function verifySignInCode({ email, code, fetch } = {}) {
128
+ const r = await apiJson('/auth/v1/verify', { body: { type: 'email', email, token: code }, label: 'verify', fetch });
129
+ if (!r || !r.access_token || !r.user || !r.user.id) throw new Error('verify: no session returned');
130
+ return { accessToken: r.access_token, userId: r.user.id };
131
+ }
132
+
133
+ /** Insert a team owned by the signed-in user (RLS allows the owner insert). */
134
+ export async function createTeam({ name, accessToken, userId, fetch } = {}) {
135
+ const r = await apiJson('/rest/v1/teams', {
136
+ body: { name, owner_uid: userId }, accessToken, prefer: 'return=representation', label: 'create team', fetch,
137
+ });
138
+ const row = Array.isArray(r) ? r[0] : r;
139
+ if (!row || !row.invite_token) throw new Error('create team: no invite token returned');
140
+ return { id: row.id, name: row.name, inviteToken: row.invite_token, plan: row.plan };
141
+ }
142
+
143
+ /** Local calendar day, same key as stats.js. */
144
+ function dayKey(ms) {
145
+ const d = new Date(ms);
146
+ const pad = (n) => String(n).padStart(2, '0');
147
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
148
+ }
149
+
150
+ const round4 = (n) => Math.round(n * 10000) / 10000;
151
+
152
+ /**
153
+ * Per-day rows, oldest first, only days with activity inside the window.
154
+ * saved_usd honours the `sprag saved reset` marks (events before a mark are
155
+ * skipped), the same way the ledger totals do. delegated_errors is always null:
156
+ * the ledger carries no error flag.
157
+ */
158
+ export async function aggregateDaily({ now = Date.now(), dir = userDataDir(), days = 30, sessions = null } = {}) {
159
+ const { readResetMarks, isBeforeReset } = require('./saved-reset.cjs');
160
+ const doc2md = require('./doc2md-ledger.cjs');
161
+ const cutoff = now - days * 24 * 3600 * 1000;
162
+ const byDay = new Map();
163
+ const get = (day) => {
164
+ if (!byDay.has(day)) byDay.set(day, { spent: 0, saved: 0, runs: 0, fam: new Map() });
165
+ return byDay.get(day);
166
+ };
167
+ let marks = {};
168
+ try { marks = readResetMarks(dir) || {}; } catch (e) { debug('team-sync', e); }
169
+
170
+ try {
171
+ for (const e of Object.values(loadLedger().events)) {
172
+ if (!Number.isFinite(e.ts) || e.ts < cutoff || e.ts > now) continue;
173
+ if (isBeforeReset(e.ts, marks.routing)) continue;
174
+ const d = get(dayKey(e.ts));
175
+ const usd = Number(e.usd);
176
+ if (Number.isFinite(usd)) d.saved += usd;
177
+ d.runs += 1;
178
+ const fam = modelFamily(e.to);
179
+ d.fam.set(fam, (d.fam.get(fam) || 0) + 1);
180
+ }
181
+ } catch (e) { debug('team-sync', e); }
182
+
183
+ try {
184
+ for (const e of Object.values(doc2md.loadLedger(dir).events)) {
185
+ if (!Number.isFinite(e.ts) || e.ts < cutoff || e.ts > now) continue;
186
+ if (isBeforeReset(e.ts, marks.doc2md)) continue;
187
+ get(dayKey(e.ts)).saved += Number(e.usd) || 0;
188
+ }
189
+ } catch (e) { debug('team-sync', e); }
190
+
191
+ try {
192
+ let list = sessions;
193
+ if (!list) {
194
+ const { parseAllSessions } = await import('./parser.js');
195
+ list = await parseAllSessions({ days });
196
+ }
197
+ for (const s of list || []) {
198
+ if (!s || !s.startTime || !s.totals) continue;
199
+ const t = new Date(s.startTime).getTime();
200
+ if (!Number.isFinite(t) || t < cutoff || t > now) continue;
201
+ let cost = 0;
202
+ for (const part of pricedParts(s)) {
203
+ try {
204
+ const c = sessionCost(part.totals, part.model);
205
+ if (c) cost += c.actual;
206
+ } catch (e) { debug('team-sync', e); }
207
+ }
208
+ get(dayKey(t)).spent += cost;
209
+ }
210
+ } catch (e) { debug('team-sync', e); }
211
+
212
+ return [...byDay.entries()]
213
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
214
+ .filter(([, d]) => d.spent || d.saved || d.runs)
215
+ .map(([day, d]) => ({
216
+ day,
217
+ spent_usd: round4(d.spent),
218
+ saved_usd: round4(d.saved),
219
+ delegated_runs: d.runs,
220
+ delegated_errors: null,
221
+ family_share: Object.fromEntries(
222
+ [...d.fam.entries()].map(([f, n]) => [f, Math.round((n / d.runs) * 1000) / 1000]),
223
+ ),
224
+ }));
225
+ }
226
+
227
+ export async function joinTeam({
228
+ token, displayName, agent = 'claude', autoJoined = false, fetch, dir = userDataDir(), version, now = Date.now(),
229
+ } = {}) {
230
+ if (!TOKEN_RE.test(String(token || ''))) throw new Error('invalid invite token (expected spt_ + 32 hex characters)');
231
+ const name = String(displayName || '').trim() || 'member';
232
+ const r = await rpc('team_join', {
233
+ invite_token: token, display_name: name, agent, sprag_version: version || '', auto_joined: !!autoJoined,
234
+ }, { fetch });
235
+ if (!r || !r.member_key) throw new Error('team_join: no member key returned');
236
+ const state = {
237
+ version: TEAM_STATE_VERSION,
238
+ teamId: r.team_id,
239
+ teamName: r.team_name,
240
+ memberId: r.member_id,
241
+ memberKey: r.member_key,
242
+ plan: r.plan,
243
+ promptCapture: !!r.prompt_capture,
244
+ displayName: name,
245
+ agent,
246
+ joinedAt: now,
247
+ autoJoined: !!autoJoined,
248
+ autoJoinNoticePending: !!autoJoined,
249
+ lastSyncAt: null,
250
+ lastSyncRows: 0,
251
+ };
252
+ saveTeamState(state, dir);
253
+ return state;
254
+ }
255
+
256
+ export async function leaveTeam({ fetch, dir = userDataDir(), now = Date.now() } = {}) {
257
+ const prev = loadTeamState(dir);
258
+ let lastError;
259
+ if (prev.memberKey) {
260
+ try { await rpc('team_leave', { member_key: prev.memberKey }, { fetch }); }
261
+ catch (e) { debug('team-sync', e); lastError = e.message; }
262
+ }
263
+ const next = { version: TEAM_STATE_VERSION, left: true, leftAt: now, teamName: prev.teamName, displayName: prev.displayName };
264
+ if (lastError) next.lastError = lastError;
265
+ saveTeamState(next, dir);
266
+ try { rmSync(teamRulesCachePath(dir), { force: true }); } catch (e) { debug('team-sync', e); }
267
+ return next;
268
+ }
269
+
270
+ export async function syncTeam({
271
+ now = Date.now(), dir = userDataDir(), fetch, force = false, env = process.env, version, sessions,
272
+ } = {}) {
273
+ if (teamSyncDisabled(env)) return { skipped: 'disabled' };
274
+ const state = loadTeamState(dir);
275
+ if (state.left) return { skipped: 'left' };
276
+ if (!isJoined(state)) return { skipped: 'not-joined' };
277
+ if (!force && !teamSyncDue(state, now)) return { skipped: 'not-due' };
278
+ try {
279
+ const rows = (await aggregateDaily({ now, dir, sessions })).slice(-MAX_ROWS);
280
+ await rpc('ledger_upsert', { member_key: state.memberKey, rows, sprag_version: version || '' }, { fetch });
281
+ const r = (await rpc('team_rules_get', { member_key: state.memberKey }, { fetch })) || {};
282
+ const rules = Array.isArray(r.rules) ? r.rules : [];
283
+ writeStateFile(teamRulesCachePath(dir), JSON.stringify({
284
+ version: 1, fetchedAt: now, teamId: r.team_id, teamName: r.team_name, plan: r.plan,
285
+ promptCapture: !!r.prompt_capture, rules, removed: Array.isArray(r.removed) ? r.removed : [],
286
+ }, null, 2) + '\n');
287
+ const next = { ...state, lastSyncAt: now, lastSyncRows: rows.length };
288
+ if (r.team_name) next.teamName = r.team_name;
289
+ if (r.plan) next.plan = r.plan;
290
+ if (r.prompt_capture !== undefined) next.promptCapture = !!r.prompt_capture;
291
+ delete next.lastError;
292
+ saveTeamState(next, dir);
293
+ return { uploaded: rows.length, rules: rules.length };
294
+ } catch (e) {
295
+ debug('team-sync', e);
296
+ // Stamp lastSyncAt too, so an offline machine backs off for the interval.
297
+ try { saveTeamState({ ...state, lastSyncAt: now, lastError: e.message }, dir); } catch (e2) { debug('team-sync', e2); }
298
+ return { error: e.message };
299
+ }
300
+ }
301
+
302
+ export async function autoJoin({ env = process.env, fetch, dir = userDataDir(), version, now = Date.now() } = {}) {
303
+ const token = env.SPRAG_TEAM_TOKEN;
304
+ if (!token || !TOKEN_RE.test(token)) return null;
305
+ const state = loadTeamState(dir);
306
+ // A state with `left: true` never auto-rejoins: leaving must beat the
307
+ // environment, or `sprag team leave` would be undone by the next session.
308
+ if (isJoined(state) || state.left) return null;
309
+ let name = 'member';
310
+ try { name = userInfo().username || 'member'; } catch { /* no passwd entry */ }
311
+ try {
312
+ return await joinTeam({ token, displayName: name, autoJoined: true, fetch, dir, version, now });
313
+ } catch (e) {
314
+ debug('team-sync', e);
315
+ return null;
316
+ }
317
+ }
318
+
319
+ export function autoJoinNotice(state, lang = 'en') {
320
+ if (!state || !state.autoJoinNoticePending) return null;
321
+ state.autoJoinNoticePending = false;
322
+ const name = state.teamName || '';
323
+ return lang === 'ko'
324
+ ? `Sprag: 이 PC는 SPRAG_TEAM_TOKEN 으로 팀 "${name}" 에 자동 참여됐습니다. 보내는 것은 하루 단위 집계(지출·절감·위임 횟수)뿐입니다. \`sprag team status --show-payload\` 로 내용을 확인하고 \`sprag team leave\` 로 멈출 수 있습니다.`
325
+ : `Sprag: this machine was enrolled in team "${name}" through SPRAG_TEAM_TOKEN. It sends daily aggregates only (spend, savings, delegation counts). See what is sent with \`sprag team status --show-payload\`; stop with \`sprag team leave\`.`;
326
+ }
package/src/changelog.js DELETED
@@ -1,70 +0,0 @@
1
- /**
2
- * changelog — read one version's section out of CHANGELOG.md, and say whether a
3
- * notes draft is still waiting on a person.
4
- *
5
- * Separate from scripts/release-notes.mjs, which is a CLI: importing that file
6
- * runs its argument parsing and exits, so the extraction it depends on cannot be
7
- * tested through it. The boundary logic is the part worth pinning — a section
8
- * pulled one line short drops the release's last item, and one line long carries
9
- * the previous release's first, and the upgrade offer shows either without
10
- * complaint.
11
- *
12
- * The draft marker lives here for a reason. Two scripts gate on it — the one that
13
- * writes drafts and the one that deploys — and they used to carry the string
14
- * separately. Renaming it in one left the other looking for text that no longer
15
- * existed, and a gate that greps for an absent string does not fail: it passes,
16
- * and an unreviewed draft goes out as the release. One definition, imported.
17
- */
18
-
19
- /**
20
- * The body of one version's section, heading excluded.
21
- *
22
- * @param {string} changelog - CHANGELOG.md contents
23
- * @param {string} version - e.g. "3.45.0"
24
- * @returns {string|null} the section text, or null when that version has none
25
- */
26
- export function sectionFor(changelog, version) {
27
- const lines = String(changelog).split(/\r?\n/);
28
- // The version has to match to its end: without the boundary, "3.42.0" would
29
- // also match the "### v3.42.7" heading and publish the wrong notes.
30
- const head = new RegExp(`^### v${String(version).replace(/\./g, '\\.')}(\\s|$|\\()`);
31
- const start = lines.findIndex((l) => head.test(l));
32
- if (start < 0) return null;
33
- const out = [];
34
- for (let i = start + 1; i < lines.length; i += 1) {
35
- // The next version heading ends this section, and so does a shallower one:
36
- // "## All releases" is a container, not part of any release.
37
- if (/^### v/.test(lines[i]) || /^## /.test(lines[i])) break;
38
- out.push(lines[i]);
39
- }
40
- return out.join('\n').trim();
41
- }
42
-
43
- /**
44
- * The line `release-notes.mjs` writes at the top of a fresh draft, and the line
45
- * every gate refuses on. A draft still carrying it has not been through a person.
46
- */
47
- export const DRAFT_MARKER =
48
- '<!-- REVIEW: edit this draft, add the English half, then delete this line -->';
49
-
50
- /** Heading that opens the Korean half of a bilingual release body. */
51
- export const KO_HEADING = '## 한국어';
52
-
53
- /**
54
- * The opening of the marker, which is what the gate actually matches. Spelled out
55
- * rather than sliced off `DRAFT_MARKER`, so that editing the marker's front does
56
- * not silently change what is being tested for.
57
- */
58
- const REVIEW_OPENING = '<!-- REVIEW:';
59
-
60
- /**
61
- * Whether a draft still needs review. Matches on the `<!-- REVIEW:` opening
62
- * rather than the whole line, so editing the wording of the instruction does not
63
- * quietly disarm the gate.
64
- *
65
- * @param {string} draft - the draft file's contents
66
- * @returns {boolean} true while the draft is unreviewed
67
- */
68
- export function needsReview(draft) {
69
- return String(draft || '').includes(REVIEW_OPENING);
70
- }