shipper-town 0.0.0-stage → 0.1.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/README.md +41 -2
- package/dist/api.js +72 -0
- package/dist/browser.js +13 -0
- package/dist/commands.js +154 -0
- package/dist/errors.js +16 -0
- package/dist/index.js +58 -0
- package/dist/settings-file.js +48 -0
- package/dist/settings.js +97 -0
- package/package.json +32 -4
package/README.md
CHANGED
|
@@ -1,3 +1,42 @@
|
|
|
1
|
-
#
|
|
1
|
+
# shipper-town
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Join the [Shipper Town](https://shipper.town) leaderboard: a live board of people building with Claude Code.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx shipper-town join # sign in with GitHub and connect this machine
|
|
7
|
+
npx shipper-town status # show your connection and rank
|
|
8
|
+
npx shipper-town leave # disconnect this machine
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What `join` does
|
|
12
|
+
|
|
13
|
+
It turns on Claude Code's built-in usage metrics (OpenTelemetry) and points them at Shipper Town, by adding
|
|
14
|
+
these 7 entries to the `env` block of `~/.claude/settings.json`. It shows the exact changes and asks before writing,
|
|
15
|
+
and it keeps a backup of the previous file.
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
CLAUDE_CODE_ENABLE_TELEMETRY=1
|
|
19
|
+
OTEL_METRICS_EXPORTER=otlp
|
|
20
|
+
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/json
|
|
21
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=https://ingest.shipper.town/v1/metrics
|
|
22
|
+
OTEL_EXPORTER_OTLP_METRICS_HEADERS=Authorization=Bearer <this machine's key>
|
|
23
|
+
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE=delta
|
|
24
|
+
OTEL_METRIC_EXPORT_INTERVAL=600000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Restart Claude Code afterwards. Reports are sent every 10 minutes and when a session ends.
|
|
28
|
+
|
|
29
|
+
## What is counted
|
|
30
|
+
|
|
31
|
+
Only totals: active time, token counts, sessions, lines changed, commits and pull requests. No prompts, code,
|
|
32
|
+
file names, email, model names or session IDs are stored. See [shipper.town/privacy](https://shipper.town/privacy).
|
|
33
|
+
|
|
34
|
+
## Leaving
|
|
35
|
+
|
|
36
|
+
`npx shipper-town leave` removes exactly the entries above (anything you customised stays) and revokes this
|
|
37
|
+
machine's key. You can also revoke keys or delete your account at [shipper.town/settings](https://shipper.town/settings).
|
|
38
|
+
|
|
39
|
+
If Claude Code already sends metrics to another tool, `join` stops and tells you, since Claude Code supports
|
|
40
|
+
one metrics destination. `--force` replaces it.
|
|
41
|
+
|
|
42
|
+
Independent fan project, not affiliated with Anthropic. MIT licensed.
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export const CLIENT_ID = 'shipper-cli';
|
|
2
|
+
const json = { 'content-type': 'application/json' };
|
|
3
|
+
export function createApi(baseUrl, ingestUrl, fetchImpl = fetch, now = Date.now) {
|
|
4
|
+
const url = (p) => `${baseUrl}${p}`;
|
|
5
|
+
return {
|
|
6
|
+
async requestDeviceCode() {
|
|
7
|
+
const res = await fetchImpl(url('/api/auth/device/code'), { method: 'POST', headers: json, body: JSON.stringify({ client_id: CLIENT_ID }) });
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
throw new Error(`Could not start sign-in (${res.status})`);
|
|
10
|
+
return (await res.json());
|
|
11
|
+
},
|
|
12
|
+
async pollToken(dc, sleep) {
|
|
13
|
+
let interval = dc.interval;
|
|
14
|
+
const deadline = now() + dc.expires_in * 1000;
|
|
15
|
+
while (now() < deadline) {
|
|
16
|
+
await sleep(interval * 1000);
|
|
17
|
+
const res = await fetchImpl(url('/api/auth/device/token'), {
|
|
18
|
+
method: 'POST', headers: json,
|
|
19
|
+
body: JSON.stringify({ grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: dc.device_code, client_id: CLIENT_ID }),
|
|
20
|
+
});
|
|
21
|
+
const body = (await res.json().catch(() => ({})));
|
|
22
|
+
if (res.ok && body.access_token)
|
|
23
|
+
return body.access_token;
|
|
24
|
+
if (body.error === 'authorization_pending')
|
|
25
|
+
continue;
|
|
26
|
+
if (body.error === 'slow_down') {
|
|
27
|
+
interval += 5;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (body.error === 'access_denied')
|
|
31
|
+
throw new Error('Sign-in was denied in the browser.');
|
|
32
|
+
if (body.error === 'expired_token')
|
|
33
|
+
break;
|
|
34
|
+
throw new Error(`Sign-in failed (${body.error ?? res.status})`);
|
|
35
|
+
}
|
|
36
|
+
throw new Error('The code expired. Run the command again.');
|
|
37
|
+
},
|
|
38
|
+
async createKey(sessionToken, label) {
|
|
39
|
+
const res = await fetchImpl(url('/api/cli/keys'), {
|
|
40
|
+
method: 'POST', headers: { ...json, authorization: `Bearer ${sessionToken}` }, body: JSON.stringify({ label }),
|
|
41
|
+
});
|
|
42
|
+
if (res.status !== 201)
|
|
43
|
+
throw new Error(`Could not create a key (${res.status})`);
|
|
44
|
+
return (await res.json());
|
|
45
|
+
},
|
|
46
|
+
async signOut(sessionToken) {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetchImpl(url('/api/auth/sign-out'), {
|
|
49
|
+
method: 'POST', headers: { ...json, authorization: `Bearer ${sessionToken}` }, body: '{}',
|
|
50
|
+
});
|
|
51
|
+
return res.ok;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
async revokeKey(key) {
|
|
58
|
+
const res = await fetchImpl(url('/api/cli/keys/revoke'), { method: 'POST', headers: { authorization: `Bearer ${key}` } });
|
|
59
|
+
return res.ok;
|
|
60
|
+
},
|
|
61
|
+
async status(key) {
|
|
62
|
+
const res = await fetchImpl(url('/api/cli/status'), { headers: { authorization: `Bearer ${key}` } });
|
|
63
|
+
return res.ok ? (await res.json()) : null;
|
|
64
|
+
},
|
|
65
|
+
async testExport(key) {
|
|
66
|
+
const res = await fetchImpl(`${ingestUrl}/v1/metrics`, {
|
|
67
|
+
method: 'POST', headers: { ...json, authorization: `Bearer ${key}` }, body: JSON.stringify({ resourceMetrics: [] }),
|
|
68
|
+
});
|
|
69
|
+
return res.ok;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
package/dist/browser.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Only https URLs (or http on localhost) are ever handed to the OS opener. */
|
|
2
|
+
export function isSafeBrowserUrl(url) {
|
|
3
|
+
let u;
|
|
4
|
+
try {
|
|
5
|
+
u = new URL(url);
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (u.protocol === 'https:')
|
|
11
|
+
return true;
|
|
12
|
+
return u.protocol === 'http:' && (u.hostname === 'localhost' || u.hostname === '127.0.0.1');
|
|
13
|
+
}
|
package/dist/commands.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { existingShipperKey, maskKey, mergeSettings, removeShipperEnv, findConflicts } from './settings.js';
|
|
2
|
+
import { InvalidSettingsError, readSettings, writeSettings } from './settings-file.js';
|
|
3
|
+
export function prettyHostname(h) {
|
|
4
|
+
const s = h.replace(/\.local$/i, '').replace(/[-_]+/g, ' ').trim();
|
|
5
|
+
return s || 'My machine';
|
|
6
|
+
}
|
|
7
|
+
const formatCode = (c) => (c.length === 8 ? `${c.slice(0, 4)}-${c.slice(4)}` : c);
|
|
8
|
+
async function load(io) {
|
|
9
|
+
try {
|
|
10
|
+
return await readSettings(io.settingsPath);
|
|
11
|
+
}
|
|
12
|
+
catch (e) {
|
|
13
|
+
io.log(e instanceof InvalidSettingsError ? e.message : `Could not read ${io.settingsPath}: ${e.message}`);
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function join(api, io, ingestUrl, opts) {
|
|
18
|
+
const current = await load(io);
|
|
19
|
+
if (!current)
|
|
20
|
+
return 1;
|
|
21
|
+
const conflicts = findConflicts(current, ingestUrl);
|
|
22
|
+
if (conflicts.length && !opts.force) {
|
|
23
|
+
const r = mergeSettings(current, 'ship_x', ingestUrl, opts);
|
|
24
|
+
if (!r.ok)
|
|
25
|
+
io.log(r.reason);
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
if (io.fileExists(io.managedPath)) {
|
|
29
|
+
io.log(`Note: your organization has managed settings at ${io.managedPath}. They may override these settings.`);
|
|
30
|
+
}
|
|
31
|
+
const dc = await api.requestDeviceCode();
|
|
32
|
+
io.log(`\nOpen ${dc.verification_uri} and enter: ${formatCode(dc.user_code)}\n`);
|
|
33
|
+
io.openBrowser(dc.verification_uri_complete);
|
|
34
|
+
io.log('Waiting for you to approve in the browser…');
|
|
35
|
+
let token;
|
|
36
|
+
try {
|
|
37
|
+
token = await api.pollToken(dc, io.sleep);
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
io.log(e.message);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
let created;
|
|
44
|
+
try {
|
|
45
|
+
created = await api.createKey(token, prettyHostname(io.hostname));
|
|
46
|
+
}
|
|
47
|
+
catch (e) {
|
|
48
|
+
io.log(`Could not create a key: ${e.message}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
// The device-flow session has done its job; best-effort sign it out so no long-lived session lingers.
|
|
52
|
+
await api.signOut(token).catch(() => false);
|
|
53
|
+
const { key, handle } = created;
|
|
54
|
+
const revokeFresh = async () => {
|
|
55
|
+
try {
|
|
56
|
+
if (!(await api.revokeKey(key)))
|
|
57
|
+
io.log('Note: the new key could not be revoked on the server. Revoke it at https://shipper.town/settings.');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
io.log('Note: the new key could not be revoked on the server. Revoke it at https://shipper.town/settings.');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
let oldKey;
|
|
64
|
+
try {
|
|
65
|
+
const merged = mergeSettings(current, key, ingestUrl, opts);
|
|
66
|
+
if (!merged.ok) {
|
|
67
|
+
io.log(merged.reason);
|
|
68
|
+
await revokeFresh();
|
|
69
|
+
return 1;
|
|
70
|
+
}
|
|
71
|
+
oldKey = merged.oldKey;
|
|
72
|
+
io.log(`\nThis will update ${io.settingsPath}:`);
|
|
73
|
+
for (const c of merged.changes)
|
|
74
|
+
io.log(c.from ? ` ~ ${c.key}: ${c.from} → ${c.to}` : ` + ${c.key}=${c.to}`);
|
|
75
|
+
if (!opts.yes && !(await io.confirm('Apply these changes?'))) {
|
|
76
|
+
await revokeFresh();
|
|
77
|
+
io.log('Cancelled. Nothing was changed.');
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
const backup = await writeSettings(io.settingsPath, merged.next, io.now());
|
|
81
|
+
if (backup)
|
|
82
|
+
io.log(`Backup saved to ${backup}`);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
io.log(`Could not update settings: ${e.message}. Nothing was changed.`);
|
|
86
|
+
await revokeFresh();
|
|
87
|
+
return 1;
|
|
88
|
+
}
|
|
89
|
+
if (oldKey && oldKey !== key) {
|
|
90
|
+
try {
|
|
91
|
+
if (!(await api.revokeKey(oldKey)))
|
|
92
|
+
io.log('Note: your previous key could not be revoked on the server. Revoke it at https://shipper.town/settings.');
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
io.log('Note: your previous key could not be revoked on the server. Revoke it at https://shipper.town/settings.');
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let pinged = false;
|
|
99
|
+
try {
|
|
100
|
+
pinged = await api.testExport(key);
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
if (!pinged) {
|
|
104
|
+
io.log("Settings written and this machine's key is active, but the test report failed. Check your connection and run `npx shipper-town status`.");
|
|
105
|
+
return 1;
|
|
106
|
+
}
|
|
107
|
+
io.log(`\nConnected as @${handle}. Restart open Claude Code sessions to start counting.`);
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
export async function status(api, io, ingestUrl) {
|
|
111
|
+
const current = await load(io);
|
|
112
|
+
if (!current)
|
|
113
|
+
return 1;
|
|
114
|
+
const key = existingShipperKey(current);
|
|
115
|
+
if (!key) {
|
|
116
|
+
io.log('This machine is not connected. Run `npx shipper-town join`.');
|
|
117
|
+
return 1;
|
|
118
|
+
}
|
|
119
|
+
const s = await api.status(key);
|
|
120
|
+
if (!s) {
|
|
121
|
+
io.log(`Key ${maskKey(key)} is no longer valid. Run \`npx shipper-town join\` again.`);
|
|
122
|
+
return 1;
|
|
123
|
+
}
|
|
124
|
+
const merged = mergeSettings(current, key, ingestUrl, { force: false });
|
|
125
|
+
const settingsOk = merged.ok && merged.changes.length === 0;
|
|
126
|
+
io.log(`Connected as @${s.handle} (${s.label})`);
|
|
127
|
+
io.log(`Last active: ${s.lastSeenAt ? new Date(s.lastSeenAt * 1000).toLocaleString() : 'not yet'}`);
|
|
128
|
+
io.log(`Rank this week: ${s.weekRank ? `#${s.weekRank}` : 'not ranked yet'}`);
|
|
129
|
+
io.log(settingsOk ? 'Settings look correct.' : 'Settings differ from expected. Run `npx shipper-town join` to repair.');
|
|
130
|
+
return 0;
|
|
131
|
+
}
|
|
132
|
+
export async function leave(api, io, ingestUrl) {
|
|
133
|
+
const current = await load(io);
|
|
134
|
+
if (!current)
|
|
135
|
+
return 1;
|
|
136
|
+
const { next, removed, oldKey } = removeShipperEnv(current, ingestUrl);
|
|
137
|
+
if (removed.length === 0) {
|
|
138
|
+
io.log('Nothing to remove. This machine was not connected.');
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
141
|
+
let revoked = false;
|
|
142
|
+
if (oldKey) {
|
|
143
|
+
try {
|
|
144
|
+
revoked = await api.revokeKey(oldKey);
|
|
145
|
+
}
|
|
146
|
+
catch { }
|
|
147
|
+
}
|
|
148
|
+
const backup = await writeSettings(io.settingsPath, next, io.now());
|
|
149
|
+
const done = `Removed ${removed.length} settings${backup ? ` (backup: ${backup})` : ''}.`;
|
|
150
|
+
io.log(!oldKey || revoked
|
|
151
|
+
? `${done} This machine is disconnected.`
|
|
152
|
+
: `${done} Settings removed, but the key could not be revoked on the server. Revoke it at https://shipper.town/settings`);
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const HINTS = {
|
|
2
|
+
ENOTFOUND: 'the address could not be found (check your connection or DNS)',
|
|
3
|
+
ECONNREFUSED: 'the connection was refused',
|
|
4
|
+
ETIMEDOUT: 'the connection timed out',
|
|
5
|
+
ECONNRESET: 'the connection was reset',
|
|
6
|
+
};
|
|
7
|
+
/** Turns Node's bare "fetch failed" into something a person can act on. */
|
|
8
|
+
export function describeError(e, base) {
|
|
9
|
+
if (!(e instanceof Error))
|
|
10
|
+
return String(e);
|
|
11
|
+
if (e.message !== 'fetch failed')
|
|
12
|
+
return e.message;
|
|
13
|
+
const code = e.cause?.code;
|
|
14
|
+
const why = (code && HINTS[code]) ?? (code ? code : 'a network error');
|
|
15
|
+
return `Could not reach ${base}: ${why}.`;
|
|
16
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { homedir, hostname, platform } from 'node:os';
|
|
5
|
+
import { createInterface } from 'node:readline/promises';
|
|
6
|
+
import { createApi } from './api.js';
|
|
7
|
+
import { join, leave, status } from './commands.js';
|
|
8
|
+
import { isSafeBrowserUrl } from './browser.js';
|
|
9
|
+
import { managedSettingsPath, settingsPath } from './settings-file.js';
|
|
10
|
+
import { describeError } from './errors.js';
|
|
11
|
+
const BASE = (process.env.SHIPPER_TOWN_URL ?? 'https://shipper.town').replace(/\/$/, '');
|
|
12
|
+
const INGEST = (process.env.SHIPPER_TOWN_INGEST_URL ?? 'https://ingest.shipper.town').replace(/\/$/, '');
|
|
13
|
+
function openBrowser(url) {
|
|
14
|
+
if (!isSafeBrowserUrl(url))
|
|
15
|
+
return;
|
|
16
|
+
const win = platform() === 'win32';
|
|
17
|
+
const cmd = platform() === 'darwin' ? 'open' : win ? 'rundll32' : 'xdg-open';
|
|
18
|
+
const args = win ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
19
|
+
try {
|
|
20
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => { }).unref();
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
}
|
|
24
|
+
const argv = new Set(process.argv.slice(3));
|
|
25
|
+
const assumeYes = argv.has('--yes') || argv.has('-y');
|
|
26
|
+
const io = {
|
|
27
|
+
log: (m) => console.log(m),
|
|
28
|
+
async confirm(q) {
|
|
29
|
+
if (!process.stdin.isTTY && !assumeYes)
|
|
30
|
+
return false;
|
|
31
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
32
|
+
const closed = new Promise((r) => rl.once('close', () => r('')));
|
|
33
|
+
const a = await Promise.race([rl.question(`${q} [y/N] `), closed]);
|
|
34
|
+
rl.close();
|
|
35
|
+
return /^y(es)?$/i.test(a.trim());
|
|
36
|
+
},
|
|
37
|
+
openBrowser,
|
|
38
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
39
|
+
now: () => Math.floor(Date.now() / 1000),
|
|
40
|
+
settingsPath: settingsPath(process.env, homedir()),
|
|
41
|
+
managedPath: managedSettingsPath(platform()),
|
|
42
|
+
hostname: hostname(),
|
|
43
|
+
fileExists: existsSync,
|
|
44
|
+
};
|
|
45
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
46
|
+
const flags = new Set(rest);
|
|
47
|
+
const api = createApi(BASE, INGEST);
|
|
48
|
+
const run = async () => {
|
|
49
|
+
switch (cmd) {
|
|
50
|
+
case 'join': return join(api, io, INGEST, { yes: flags.has('--yes') || flags.has('-y'), force: flags.has('--force') });
|
|
51
|
+
case 'status': return status(api, io, INGEST);
|
|
52
|
+
case 'leave': return leave(api, io, INGEST);
|
|
53
|
+
default:
|
|
54
|
+
console.log('Usage: npx shipper-town <join|status|leave> [--yes] [--force]');
|
|
55
|
+
return cmd ? 1 : 0;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
run().then((code) => process.exit(code), (e) => { console.error(describeError(e, BASE)); process.exit(1); });
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { copyFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
export function settingsPath(env, home) {
|
|
4
|
+
return join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json');
|
|
5
|
+
}
|
|
6
|
+
export function managedSettingsPath(platform) {
|
|
7
|
+
if (platform === 'darwin')
|
|
8
|
+
return '/Library/Application Support/ClaudeCode/managed-settings.json';
|
|
9
|
+
if (platform === 'win32')
|
|
10
|
+
return 'C:\\Program Files\\ClaudeCode\\managed-settings.json';
|
|
11
|
+
return '/etc/claude-code/managed-settings.json';
|
|
12
|
+
}
|
|
13
|
+
export class InvalidSettingsError extends Error {
|
|
14
|
+
}
|
|
15
|
+
export async function readSettings(path) {
|
|
16
|
+
let text;
|
|
17
|
+
try {
|
|
18
|
+
text = await readFile(path, 'utf8');
|
|
19
|
+
}
|
|
20
|
+
catch (e) {
|
|
21
|
+
if (e.code === 'ENOENT')
|
|
22
|
+
return {};
|
|
23
|
+
throw e;
|
|
24
|
+
}
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(text);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new InvalidSettingsError(`${path} is not valid JSON. Fix it by hand first; nothing was changed.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Writes settings, backing up any existing file first. Returns the backup path, or '' if there was no file. */
|
|
33
|
+
export async function writeSettings(path, next, now) {
|
|
34
|
+
await mkdir(dirname(path), { recursive: true });
|
|
35
|
+
let backup = '';
|
|
36
|
+
try {
|
|
37
|
+
backup = `${path}.bak-${now}`;
|
|
38
|
+
await copyFile(path, backup);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
if (e.code !== 'ENOENT')
|
|
42
|
+
throw e;
|
|
43
|
+
backup = '';
|
|
44
|
+
}
|
|
45
|
+
// mode applies only when the file is created; existing files keep their mode.
|
|
46
|
+
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
47
|
+
return backup;
|
|
48
|
+
}
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/** The exact env block. Keep in sync with apps/web/src/setup-env.ts (both are tested). */
|
|
2
|
+
export function telemetryEnv(key, ingestUrl) {
|
|
3
|
+
return {
|
|
4
|
+
CLAUDE_CODE_ENABLE_TELEMETRY: '1',
|
|
5
|
+
OTEL_METRICS_EXPORTER: 'otlp',
|
|
6
|
+
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: 'http/json',
|
|
7
|
+
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: `${ingestUrl}/v1/metrics`,
|
|
8
|
+
OTEL_EXPORTER_OTLP_METRICS_HEADERS: `Authorization=Bearer ${key}`,
|
|
9
|
+
OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE: 'delta',
|
|
10
|
+
OTEL_METRIC_EXPORT_INTERVAL: '600000',
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export const SHIPPER_ENV_KEYS = Object.keys(telemetryEnv('k', 'b'));
|
|
14
|
+
const isObj = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
15
|
+
const KEY_RE = /Authorization=Bearer (ship_[0-9A-Za-z]+)/;
|
|
16
|
+
function envOf(settings) {
|
|
17
|
+
if (!isObj(settings) || !isObj(settings.env))
|
|
18
|
+
return {};
|
|
19
|
+
return Object.fromEntries(Object.entries(settings.env).filter(([, v]) => typeof v === 'string'));
|
|
20
|
+
}
|
|
21
|
+
export function existingShipperKey(settings) {
|
|
22
|
+
return KEY_RE.exec(envOf(settings).OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? '')?.[1] ?? null;
|
|
23
|
+
}
|
|
24
|
+
export function maskKey(key) {
|
|
25
|
+
return key.length <= 13 ? key : `${key.slice(0, 9)}…${key.slice(-4)}`;
|
|
26
|
+
}
|
|
27
|
+
/** Env vars already sending metrics somewhere other than Shipper Town. */
|
|
28
|
+
export function findConflicts(settings, ingestUrl) {
|
|
29
|
+
const env = envOf(settings);
|
|
30
|
+
const ours = `${ingestUrl}/v1/metrics`;
|
|
31
|
+
const out = [];
|
|
32
|
+
if (env.OTEL_METRICS_EXPORTER && env.OTEL_METRICS_EXPORTER !== 'otlp')
|
|
33
|
+
out.push('OTEL_METRICS_EXPORTER');
|
|
34
|
+
if (env.OTEL_EXPORTER_OTLP_ENDPOINT)
|
|
35
|
+
out.push('OTEL_EXPORTER_OTLP_ENDPOINT');
|
|
36
|
+
// A ship_ key means a previous Shipper Town setup (another server, e.g. localhost or an old host): replace it freely.
|
|
37
|
+
const shipperSetup = KEY_RE.test(env.OTEL_EXPORTER_OTLP_METRICS_HEADERS ?? '');
|
|
38
|
+
if (env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT && env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT !== ours && !shipperSetup) {
|
|
39
|
+
out.push('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT');
|
|
40
|
+
}
|
|
41
|
+
if (env.OTEL_EXPORTER_OTLP_METRICS_HEADERS && !KEY_RE.test(env.OTEL_EXPORTER_OTLP_METRICS_HEADERS))
|
|
42
|
+
out.push('OTEL_EXPORTER_OTLP_METRICS_HEADERS');
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
const display = (v) => v.replace(/ship_[0-9A-Za-z]+/, (k) => maskKey(k));
|
|
46
|
+
/** Foreign header values may hold secrets, so never print them. */
|
|
47
|
+
const shownFrom = (k, v) => k.endsWith('_HEADERS') && !KEY_RE.test(v) ? '[redacted]' : display(v);
|
|
48
|
+
export function mergeSettings(settings, key, ingestUrl, opts) {
|
|
49
|
+
if (!isObj(settings))
|
|
50
|
+
return { ok: false, reason: 'settings.json is not a JSON object' };
|
|
51
|
+
if (settings.env !== undefined && !isObj(settings.env))
|
|
52
|
+
return { ok: false, reason: '"env" in settings.json is not an object' };
|
|
53
|
+
const conflicts = findConflicts(settings, ingestUrl);
|
|
54
|
+
if (conflicts.length && !opts.force) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
reason: `Claude Code already sends metrics elsewhere (${conflicts.join(', ')}). ` +
|
|
58
|
+
'Claude Code supports one metrics destination, so joining would stop that. Re-run with --force to replace it.',
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const current = envOf(settings);
|
|
62
|
+
const wanted = telemetryEnv(key, ingestUrl);
|
|
63
|
+
const changes = [];
|
|
64
|
+
for (const [k, v] of Object.entries(wanted)) {
|
|
65
|
+
if (current[k] === v)
|
|
66
|
+
continue;
|
|
67
|
+
changes.push(current[k] === undefined ? { key: k, to: display(v) } : { key: k, from: shownFrom(k, current[k]), to: display(v) });
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
ok: true,
|
|
71
|
+
next: { ...settings, env: { ...settings.env, ...wanted } },
|
|
72
|
+
changes,
|
|
73
|
+
// Only a key from this same server can be revoked here; one from another server is just replaced.
|
|
74
|
+
oldKey: current.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT === `${ingestUrl}/v1/metrics` ? existingShipperKey(settings) : null,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Removes only the env vars Shipper Town wrote, and only when this is our setup
|
|
79
|
+
* (our endpoint plus a ship_ key). A key the user customised stays.
|
|
80
|
+
*/
|
|
81
|
+
export function removeShipperEnv(settings, ingestUrl) {
|
|
82
|
+
const base = isObj(settings) ? { ...settings } : {};
|
|
83
|
+
const oldKey = existingShipperKey(base);
|
|
84
|
+
const ours = oldKey !== null && envOf(base).OTEL_EXPORTER_OTLP_METRICS_ENDPOINT === `${ingestUrl}/v1/metrics`;
|
|
85
|
+
if (!ours || !isObj(base.env))
|
|
86
|
+
return { next: base, removed: [], oldKey };
|
|
87
|
+
const expected = telemetryEnv(oldKey, ingestUrl);
|
|
88
|
+
const env = { ...base.env };
|
|
89
|
+
const removed = SHIPPER_ENV_KEYS.filter((k) => k in env && env[k] === expected[k]);
|
|
90
|
+
for (const k of removed)
|
|
91
|
+
delete env[k];
|
|
92
|
+
if (Object.keys(env).length === 0)
|
|
93
|
+
delete base.env;
|
|
94
|
+
else
|
|
95
|
+
base.env = env;
|
|
96
|
+
return { next: base, removed, oldKey };
|
|
97
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipper-town",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Join the Shipper Town leaderboard: turns on Claude Code's built-in usage metrics for this machine.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://shipper.town",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"claude-code",
|
|
9
|
+
"leaderboard",
|
|
10
|
+
"opentelemetry",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"bin": {
|
|
15
|
+
"shipper-town": "dist/index.js"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"test": "vitest run",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.10.0",
|
|
31
|
+
"typescript": "5.9.3",
|
|
32
|
+
"vitest": "5.0.5"
|
|
33
|
+
}
|
|
34
|
+
}
|