vibestreak 0.2.0 → 0.3.1
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/CHANGELOG.md +45 -0
- package/README.md +4 -4
- package/bin/vibestreak.mjs +127 -24
- package/package.json +2 -2
- package/src/autosync.mjs +2 -2
- package/src/git.mjs +15 -0
- package/src/login.mjs +45 -0
- package/src/pause.mjs +40 -0
- package/src/version.mjs +21 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
Any version runs with `npx vibestreak@<version>`; `npx vibestreak@latest autosync on`
|
|
4
|
+
moves the background sync to the newest one.
|
|
5
|
+
|
|
6
|
+
## 0.3.1 (2026-10-25)
|
|
7
|
+
|
|
8
|
+
- `vibestreak --version` (or `-v`), and the version in `help` and `status`.
|
|
9
|
+
- Says when a newer version is out: after `vibestreak`, `sync` and in `status`.
|
|
10
|
+
- Sends its version with each sync. Settings shows each machine's version, and
|
|
11
|
+
the server can turn away versions too old to count correctly, with the
|
|
12
|
+
command that updates them.
|
|
13
|
+
|
|
14
|
+
## 0.3.0 (2026-10-25)
|
|
15
|
+
|
|
16
|
+
- First run: plain `npx vibestreak` signs in through the browser (no token to
|
|
17
|
+
copy), syncs, and offers background sync and the evening nudge.
|
|
18
|
+
- `vibestreak setup [--token <t>]`: all of that with the defaults and no
|
|
19
|
+
questions; commit addresses come from git config.
|
|
20
|
+
- `vibestreak login` signs in through the browser; `--token` still works.
|
|
21
|
+
- `vibestreak pause [1h|3d|tomorrow]` and `resume`: the background syncs and
|
|
22
|
+
nudge skip their runs for up to 7 days, and nothing is lost.
|
|
23
|
+
|
|
24
|
+
## 0.2.0 (2026-10-25)
|
|
25
|
+
|
|
26
|
+
- `vibestreak` shows a dashboard: streak, level, a 12-week heatmap and your rank.
|
|
27
|
+
- `vibestreak me`, `profile <handle>`, `board`, `today` in the terminal.
|
|
28
|
+
- Syncs after the first one send only the last 7 days: about 3 seconds.
|
|
29
|
+
|
|
30
|
+
## 0.1.2 (2026-10-25)
|
|
31
|
+
|
|
32
|
+
- `vibestreak nudge at <time>`: when the evening streak check runs (17:00 to
|
|
33
|
+
23:30; 20:30 by default).
|
|
34
|
+
|
|
35
|
+
## 0.1.1 (2026-10-25)
|
|
36
|
+
|
|
37
|
+
- `vibestreak status`; plain `vibestreak nudge` shows the nudge's status.
|
|
38
|
+
- The evening job runs `nudge run` (older schedules keep working).
|
|
39
|
+
|
|
40
|
+
## 0.1.0 (2026-10-25)
|
|
41
|
+
|
|
42
|
+
- First release: reads Claude Code, Codex, Cursor, Copilot (CLI and VS Code),
|
|
43
|
+
Gemini CLI, Antigravity and OpenCode history and git; syncs daily totals.
|
|
44
|
+
- Background sync on macOS (launchd), Linux (systemd or cron) and Windows
|
|
45
|
+
(Task Scheduler), with the evening streak nudge.
|
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ with AI agents and the commits you shipped with them, ranked on consistency,
|
|
|
5
5
|
not spend. Profiles and the leaderboard: https://vibestreak.kingsbridge-consultancy.com
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npx vibestreak
|
|
9
|
-
npx vibestreak
|
|
10
|
-
npx vibestreak
|
|
11
|
-
npx vibestreak
|
|
8
|
+
npx vibestreak # sign in through the browser, sync, offer autosync
|
|
9
|
+
npx vibestreak setup --token PASTE_YOUR_TOKEN # or: everything with the defaults, no questions
|
|
10
|
+
npx vibestreak emails scan # addresses from your other accounts
|
|
11
|
+
npx vibestreak pause 3d # background syncs and the nudge off for a while
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
All commands: https://vibestreak.kingsbridge-consultancy.com/cli
|
package/bin/vibestreak.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
3
|
import { fileURLToPath } from 'node:url'
|
|
3
4
|
import { parseArgs } from 'node:util'
|
|
4
5
|
|
|
@@ -6,15 +7,19 @@ import { autosyncStatus, clock, installAutosync, nextRun, removeAutosync, SYNC_T
|
|
|
6
7
|
import { NUDGE_DEFAULT, notify, nudgeAction, nudgeText, nudgeTimeOf, parseNudgeTime, shouldNudge } from '../src/nudge.mjs'
|
|
7
8
|
import { collect, sinceDate, summarize, syncSince } from '../src/collect.mjs'
|
|
8
9
|
import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
|
|
9
|
-
import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
|
|
10
|
+
import { configuredEmails, DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
|
|
11
|
+
import { deviceLogin } from '../src/login.mjs'
|
|
12
|
+
import { parsePause, pausedUntil, untilText } from '../src/pause.mjs'
|
|
13
|
+
import { updateCommand, updateNotice, VERSION } from '../src/version.mjs'
|
|
10
14
|
import { boardView, dashboardView, profileView, todayView } from '../src/views.mjs'
|
|
11
15
|
|
|
12
16
|
const BRAND = process.env.VIBESTREAK_BRAND ?? 'vibestreak'
|
|
13
17
|
const DEFAULT_SERVER = process.env.VIBESTREAK_SERVER ?? 'https://vibestreak.kingsbridge-consultancy.com'
|
|
14
18
|
|
|
15
|
-
const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and how steadily.
|
|
19
|
+
const HELP = `${BRAND} ${VERSION} — share your AI coding heatmap: what you shipped, and how steadily.
|
|
16
20
|
|
|
17
21
|
${BRAND} your streak, heatmap and rank at a glance
|
|
22
|
+
(the first time: sets this machine up)
|
|
18
23
|
${BRAND} me your profile: streak, heatmap, totals
|
|
19
24
|
${BRAND} profile <handle> someone else's public profile
|
|
20
25
|
${BRAND} board [today|7d|30d|90d|all] [consistency|shipped|streak|tokens] [tool]
|
|
@@ -22,8 +27,10 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
|
|
|
22
27
|
${BRAND} today who is coding with AI today; streaks at risk
|
|
23
28
|
|
|
24
29
|
${BRAND} collect [--json] preview what would be sent (nothing leaves the machine)
|
|
25
|
-
${BRAND}
|
|
26
|
-
|
|
30
|
+
${BRAND} setup [--token <t>] link, sync and turn on autosync and the nudge
|
|
31
|
+
with the defaults, asking nothing
|
|
32
|
+
${BRAND} login link this machine in the browser
|
|
33
|
+
(or --token <t> from your settings page)
|
|
27
34
|
${BRAND} sync [--quiet] collect and upload daily totals
|
|
28
35
|
${BRAND} emails [scan|add <e>|remove <e>]
|
|
29
36
|
the addresses you commit as (all of your accounts)
|
|
@@ -34,7 +41,11 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
|
|
|
34
41
|
${BRAND} nudge [on|off|test] an evening notification when your streak is
|
|
35
42
|
about to end (on with autosync); alone, its status
|
|
36
43
|
${BRAND} nudge at 21:45 when it checks (17:00 to 23:30; default 20:30)
|
|
44
|
+
${BRAND} pause [1h|3d|tomorrow] pause background syncs and the nudge (up to 7
|
|
45
|
+
days; default until tomorrow 06:00)
|
|
46
|
+
${BRAND} resume end a pause now
|
|
37
47
|
${BRAND} whoami the profile this machine syncs to
|
|
48
|
+
${BRAND} --version this CLI's version
|
|
38
49
|
|
|
39
50
|
Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
|
|
40
51
|
Antigravity and OpenCode history, and git.
|
|
@@ -47,10 +58,10 @@ All commands, with examples: ${DEFAULT_SERVER}/cli`
|
|
|
47
58
|
const fmt = n => n.toLocaleString('en-US')
|
|
48
59
|
|
|
49
60
|
async function api(config, path, init = {}) {
|
|
50
|
-
if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login
|
|
61
|
+
if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login\`.`)
|
|
51
62
|
const response = await fetch(new URL(path, config.server), {
|
|
52
63
|
...init,
|
|
53
|
-
headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json', ...init.headers },
|
|
64
|
+
headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json', 'x-vibestreak-cli': VERSION, 'user-agent': `${BRAND}/${VERSION}`, ...init.headers },
|
|
54
65
|
})
|
|
55
66
|
const body = await response.json().catch(() => ({}))
|
|
56
67
|
if (!response.ok) throw new Error(body.error ?? `${response.status} ${response.statusText}`)
|
|
@@ -71,6 +82,10 @@ async function main() {
|
|
|
71
82
|
from: { type: 'string' },
|
|
72
83
|
to: { type: 'string' },
|
|
73
84
|
limit: { type: 'string' },
|
|
85
|
+
// Set by the background jobs, so a pause skips them and nothing else.
|
|
86
|
+
scheduled: { type: 'boolean' },
|
|
87
|
+
yes: { type: 'boolean', short: 'y' },
|
|
88
|
+
version: { type: 'boolean', short: 'v' },
|
|
74
89
|
},
|
|
75
90
|
})
|
|
76
91
|
const [command = 'dashboard', ...rest] = positionals
|
|
@@ -78,14 +93,20 @@ async function main() {
|
|
|
78
93
|
const log = (...args) => values.quiet || console.log(...args)
|
|
79
94
|
|
|
80
95
|
if (command === 'help' || values.help) return console.log(HELP)
|
|
96
|
+
if (command === 'version' || values.version) return console.log(VERSION)
|
|
81
97
|
const columns = process.stdout.columns || 100
|
|
82
98
|
const server = config.server ?? DEFAULT_SERVER
|
|
83
99
|
|
|
84
100
|
if (command === 'dashboard') {
|
|
85
|
-
// Plain `vibestreak`: the dashboard once linked; before that,
|
|
86
|
-
|
|
101
|
+
// Plain `vibestreak`: the dashboard once linked; before that, setup (or,
|
|
102
|
+
// with no one at the keyboard, how to start).
|
|
103
|
+
if (!config.token) {
|
|
104
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return console.log(`${HELP}\n\nGet started: ${server.replace(/\/$/, '')}/cli`)
|
|
105
|
+
return setup(config, values, { ask: !values.yes })
|
|
106
|
+
}
|
|
87
107
|
const me = await api(config, '/api/v1/me')
|
|
88
|
-
|
|
108
|
+
await dashboardView({ server, token: config.token, handle: me.handle, columns })
|
|
109
|
+
return notice(config, me.cli)
|
|
89
110
|
}
|
|
90
111
|
if (command === 'me') {
|
|
91
112
|
const me = await api(config, '/api/v1/me')
|
|
@@ -130,19 +151,25 @@ async function main() {
|
|
|
130
151
|
return console.log((config.roots ?? DEFAULT_ROOTS).join('\n'))
|
|
131
152
|
}
|
|
132
153
|
|
|
133
|
-
if (command === 'login')
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const first = await globalEmail()
|
|
140
|
-
if (first) config.emails = [first]
|
|
141
|
-
}
|
|
154
|
+
if (command === 'login') return login(config, values)
|
|
155
|
+
if (command === 'setup') return setup(config, values, { ask: false })
|
|
156
|
+
|
|
157
|
+
if (command === 'pause') {
|
|
158
|
+
const until = parsePause(rest[0])
|
|
159
|
+
config.pausedUntil = until.toISOString()
|
|
142
160
|
await saveConfig(config)
|
|
143
|
-
|
|
144
|
-
console.log(
|
|
145
|
-
|
|
161
|
+
await refreshJobs(config)
|
|
162
|
+
return console.log(
|
|
163
|
+
`Paused until ${untilText(until)}: no background syncs or streak nudge until then.` +
|
|
164
|
+
` The first sync after it uploads everything you did meanwhile. \`${BRAND} resume\` ends it sooner.`,
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
if (command === 'resume') {
|
|
168
|
+
const was = pausedUntil(config)
|
|
169
|
+
delete config.pausedUntil
|
|
170
|
+
await saveConfig(config)
|
|
171
|
+
await refreshJobs(config)
|
|
172
|
+
return console.log(was ? `Resumed: background syncs${config.nudge === false ? '' : ' and the nudge'} run again from the next scheduled time.` : 'Not paused.')
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
if (command === 'whoami') {
|
|
@@ -150,7 +177,11 @@ async function main() {
|
|
|
150
177
|
return console.log(`${me.handle} · ${me.profileUrl}\nGitHub: ${me.github.join(', ')}\nMachine: ${config.machineName}`)
|
|
151
178
|
}
|
|
152
179
|
|
|
153
|
-
if (command === 'collect' || command === 'sync')
|
|
180
|
+
if (command === 'collect' || command === 'sync') {
|
|
181
|
+
const until = values.scheduled && pausedUntil(config)
|
|
182
|
+
if (until) return console.log(`${new Date().toISOString()} paused until ${until.toISOString()}; skipped`)
|
|
183
|
+
return sync(config, values, log, command === 'sync')
|
|
184
|
+
}
|
|
154
185
|
|
|
155
186
|
if (command === 'status') return status(config)
|
|
156
187
|
|
|
@@ -180,6 +211,8 @@ async function main() {
|
|
|
180
211
|
return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
|
|
181
212
|
}
|
|
182
213
|
// `nudge run`, the evening job: sync, then nudge if the streak would end at midnight.
|
|
214
|
+
const until = pausedUntil(config)
|
|
215
|
+
if (until) return console.log(`${new Date().toISOString()} paused until ${until.toISOString()}; skipped`)
|
|
183
216
|
const result = await sync(config, values, log, true)
|
|
184
217
|
if (shouldNudge(result.streak, config)) {
|
|
185
218
|
await notify(nudgeText(result.streak))
|
|
@@ -210,6 +243,63 @@ async function main() {
|
|
|
210
243
|
process.exitCode = 1
|
|
211
244
|
}
|
|
212
245
|
|
|
246
|
+
// Links this machine: in the browser by default, or with a token from the
|
|
247
|
+
// settings page (for machines without one).
|
|
248
|
+
async function login(config, values, { addresses = true } = {}) {
|
|
249
|
+
config.server = values.server ?? config.server ?? DEFAULT_SERVER
|
|
250
|
+
if (values.token) config.token = values.token
|
|
251
|
+
else config.token = (await deviceLogin({ server: config.server, machine: config.machineName })).token
|
|
252
|
+
const me = await api(config, '/api/v1/me')
|
|
253
|
+
if (!config.emails.length) {
|
|
254
|
+
const first = await globalEmail()
|
|
255
|
+
if (first) config.emails = [first]
|
|
256
|
+
}
|
|
257
|
+
await saveConfig(config)
|
|
258
|
+
console.log(`Linked ${config.machineName} to @${me.handle} (${me.profileUrl}).`)
|
|
259
|
+
if (addresses) console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; \`${BRAND} emails scan\` finds your other accounts' addresses.`)
|
|
260
|
+
return me
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Everything a machine needs, with the defaults: linked (in the browser, or
|
|
264
|
+
// with --token), the commit addresses from git config, the usual repo
|
|
265
|
+
// folders, a first sync, and autosync with the evening nudge. The first plain
|
|
266
|
+
// `npx vibestreak` asks about autosync; `setup` asks nothing.
|
|
267
|
+
async function setup(config, values, { ask: interactive }) {
|
|
268
|
+
console.log(`${BRAND}: your AI coding heatmap and streak, from this machine's history.\n`)
|
|
269
|
+
console.log('Nothing but daily counts leaves this machine: never prompts, code, file paths or repo names.')
|
|
270
|
+
console.log(`(\`${BRAND} collect\` previews it all without sending anything.)\n`)
|
|
271
|
+
await login(config, values, { addresses: false })
|
|
272
|
+
const found = await configuredEmails(config.roots ?? DEFAULT_ROOTS)
|
|
273
|
+
config.emails = [...new Set([...config.emails, ...found])]
|
|
274
|
+
await saveConfig(config)
|
|
275
|
+
console.log(`Counting commits by ${config.emails.join(', ') || 'no address yet'} (from git config) and your GitHub accounts' noreply addresses.`)
|
|
276
|
+
console.log('\nFirst sync: reading your history (a minute for a big one)…')
|
|
277
|
+
await sync(config, values, console.log, true)
|
|
278
|
+
const yes = !interactive || (await ask(`\nKeep it up to date in the background (${SYNC_TIMES.map(clock).join(', ')}) and nudge you at ${clock(nudgeTimeOf(config))} when your streak is about to end? [Y/n] `))
|
|
279
|
+
if (yes) {
|
|
280
|
+
const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
|
|
281
|
+
config.autosync = result.installed
|
|
282
|
+
await saveConfig(config)
|
|
283
|
+
console.log(result.installed ? `On: syncing via ${result.using}. \`${BRAND} pause\` or \`${BRAND} autosync off\` any time.` : result.hint)
|
|
284
|
+
} else console.log(`Off. \`${BRAND} sync\` updates it, or \`${BRAND} autosync on\` later.`)
|
|
285
|
+
console.log(`\nDone. \`${BRAND}\` shows your streak. Commit from other addresses too? \`${BRAND} emails scan\` lists them. \`${BRAND} help\` lists everything.`)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function ask(question) {
|
|
289
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
290
|
+
try {
|
|
291
|
+
return !/^n/i.test((await rl.question(question)).trim())
|
|
292
|
+
} finally {
|
|
293
|
+
rl.close()
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The background jobs run a copy of the CLI kept in the config folder: a
|
|
298
|
+
// pause or resume refreshes it, so the jobs run code that knows about pauses.
|
|
299
|
+
async function refreshJobs(config) {
|
|
300
|
+
if (config.autosync) await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
|
|
301
|
+
}
|
|
302
|
+
|
|
213
303
|
// Collects this machine's totals and, for a sync, uploads them; the server
|
|
214
304
|
// answers with where the streak stands (the nudge's input).
|
|
215
305
|
async function sync(config, values, log, isSync) {
|
|
@@ -238,9 +328,16 @@ async function sync(config, values, log, isSync) {
|
|
|
238
328
|
// Always one line, quiet or not: the background sync's log is how a person sees it ran.
|
|
239
329
|
const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
|
|
240
330
|
console.log(`${config.lastSync} uploaded ${result.days} days, ${result.commits} commits from ${s.repos} repos${streak} → ${result.profileUrl}`)
|
|
331
|
+
if (!values.quiet) notice(config, result.cli)
|
|
241
332
|
return result
|
|
242
333
|
}
|
|
243
334
|
|
|
335
|
+
// Says so when a newer CLI is out (the server checks npm).
|
|
336
|
+
function notice(config, cli) {
|
|
337
|
+
const line = updateNotice(cli, { brand: BRAND, autosync: config.autosync })
|
|
338
|
+
if (line) console.log(`\n${line}`)
|
|
339
|
+
}
|
|
340
|
+
|
|
244
341
|
const ago = iso => {
|
|
245
342
|
if (!iso) return 'never'
|
|
246
343
|
const minutes = Math.round((Date.now() - Date.parse(iso)) / 60000)
|
|
@@ -250,7 +347,7 @@ const at = ({ at, day }) => `${day} ${clock([at.getHours(), at.getMinutes()])}`
|
|
|
250
347
|
|
|
251
348
|
// The server's view of the streak, or why it can't be had (not linked, offline).
|
|
252
349
|
async function serverStatus(config) {
|
|
253
|
-
if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login
|
|
350
|
+
if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login\`` }
|
|
254
351
|
try {
|
|
255
352
|
return await api(config, '/api/v1/me')
|
|
256
353
|
} catch (error) {
|
|
@@ -274,7 +371,9 @@ async function nudgeStatus(config) {
|
|
|
274
371
|
console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
|
|
275
372
|
if (me.error) return console.log(`Tonight unknown (${me.error})`)
|
|
276
373
|
const s = me.streak
|
|
277
|
-
const
|
|
374
|
+
const paused = pausedUntil(config)
|
|
375
|
+
if (paused) console.log(`Paused until ${untilText(paused)}`)
|
|
376
|
+
const tonight = !on ? 'no check' : paused && paused > nextRun([nudgeTimeOf(config)]).at ? 'no check: paused' : s.activeToday ? 'no nudge: you were active today' : s.current > 0 ? (config.lastNudge === s.today ? 'already nudged today' : `will nudge at ${time} unless you code with AI first`) : 'no nudge: no live streak'
|
|
278
377
|
console.log(`Streak ${streakLine(s)}`)
|
|
279
378
|
console.log(`Tonight ${tonight}`)
|
|
280
379
|
}
|
|
@@ -289,10 +388,14 @@ async function status(config) {
|
|
|
289
388
|
row('Last sync', config.lastSync ? `${ago(config.lastSync)}${last ? ` · ${last.days} days, ${last.commits} commits from ${last.repos} repos` : ''}` : `never: run \`${BRAND} sync\``)
|
|
290
389
|
if (!me.error) row('Streak', streakLine(me.streak))
|
|
291
390
|
row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
|
|
391
|
+
const paused = pausedUntil(config)
|
|
392
|
+
if (paused) row('Paused', `until ${untilText(paused)}: background syncs and the nudge skip their runs (\`${BRAND} resume\` ends it)`)
|
|
292
393
|
const nudgeOn = config.nudge !== false && scheduled.nudge
|
|
293
394
|
row('Nudge', nudgeOn ? `on · checks at ${clock(nudgeTimeOf(config))} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
|
|
294
395
|
const github = me.error ? 0 : (me.emails?.length ?? 0)
|
|
295
396
|
row('Addresses', `${config.emails.join(', ') || 'none'}${github ? ` · +${github} from GitHub` : ''}`)
|
|
397
|
+
const update = me.error ? undefined : updateNotice(me.cli, { brand: BRAND, autosync: config.autosync })
|
|
398
|
+
row('Version', update ? `${VERSION} · ${me.cli.latest} is out: ${updateCommand(BRAND, config.autosync)}` : `${VERSION}${me.cli?.latest ? ' (latest)' : ''}`)
|
|
296
399
|
row('Config', CONFIG_PATH)
|
|
297
400
|
}
|
|
298
401
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibestreak",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Your AI coding streak: a heatmap of the days you coded with Claude Code, Codex, Cursor, Copilot, Gemini and OpenCode, and the commits you shipped with them. Sends daily totals only.",
|
|
5
5
|
"keywords": ["claude-code", "codex", "cursor", "copilot", "gemini", "opencode", "ai-coding", "heatmap", "streak", "leaderboard", "cli"],
|
|
6
6
|
"homepage": "https://vibestreak.kingsbridge-consultancy.com/cli",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"author": "Kingsbridge Consultancy LLC <support@kingsbridge-consultancy.com>",
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": { "vibestreak": "bin/vibestreak.mjs" },
|
|
11
|
-
"files": ["bin", "src", "README.md", "LICENSE"],
|
|
11
|
+
"files": ["bin", "src", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
12
12
|
"engines": { "node": ">=22.13" },
|
|
13
13
|
"os": ["darwin", "linux", "win32"],
|
|
14
14
|
"scripts": { "test": "node --test test/*.test.mjs" }
|
package/src/autosync.mjs
CHANGED
|
@@ -26,8 +26,8 @@ const logPath = () => join(configDir(), 'sync.log')
|
|
|
26
26
|
// The jobs to schedule: a sync at each sync time, and the evening nudge at
|
|
27
27
|
// the person's chosen time.
|
|
28
28
|
export const jobs = (nudge, nudgeTime = NUDGE_DEFAULT) => [
|
|
29
|
-
{ name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
|
|
30
|
-
...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [nudgeTime] }] : []),
|
|
29
|
+
{ name: 'sync', args: ['sync', '--quiet', '--scheduled'], times: SYNC_TIMES },
|
|
30
|
+
...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet', '--scheduled'], times: [nudgeTime] }] : []),
|
|
31
31
|
]
|
|
32
32
|
|
|
33
33
|
// A CLI run through npx lives in npm's cache, which can be cleared; a
|
package/src/git.mjs
CHANGED
|
@@ -136,6 +136,21 @@ export async function scanEmails({ roots, since }) {
|
|
|
136
136
|
return [...counts].sort((a, b) => b[1] - a[1])
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
// The addresses this person commits as, by their own git config: the global
|
|
140
|
+
// one, and any a repo sets for itself (a work address in work repos). Other
|
|
141
|
+
// people's addresses in the same repos are never picked up this way.
|
|
142
|
+
export async function configuredEmails(roots = DEFAULT_ROOTS) {
|
|
143
|
+
const local = await Promise.all(
|
|
144
|
+
(await findRepos(roots)).map(repo =>
|
|
145
|
+
run('git', ['-C', repo, 'config', '--local', '--get', 'user.email']).then(
|
|
146
|
+
r => r.stdout.trim(),
|
|
147
|
+
() => '',
|
|
148
|
+
),
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
return [...new Set([await globalEmail(), ...local].filter(e => e && e.includes('@')))]
|
|
152
|
+
}
|
|
153
|
+
|
|
139
154
|
export async function globalEmail() {
|
|
140
155
|
try {
|
|
141
156
|
return (await run('git', ['config', '--global', 'user.email'])).stdout.trim() || undefined
|
package/src/login.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
// Opens a page in the default browser; false when there's no way to (a
|
|
4
|
+
// server, a container), and the person opens the printed link themselves.
|
|
5
|
+
export function openBrowser(url, platform = process.platform) {
|
|
6
|
+
const [command, args] =
|
|
7
|
+
platform === 'darwin' ? ['open', [url]] : platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]]
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
try {
|
|
10
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true, windowsHide: true })
|
|
11
|
+
child.on('error', () => resolve(false))
|
|
12
|
+
child.on('spawn', () => {
|
|
13
|
+
child.unref()
|
|
14
|
+
resolve(true)
|
|
15
|
+
})
|
|
16
|
+
} catch {
|
|
17
|
+
resolve(false)
|
|
18
|
+
}
|
|
19
|
+
})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
|
|
23
|
+
|
|
24
|
+
// Browser sign-in: the server hands out a code, the person approves it on
|
|
25
|
+
// the site (signing in with GitHub first if need be), and the token comes back
|
|
26
|
+
// to the secret only this process holds. Nothing to copy or paste.
|
|
27
|
+
export async function deviceLogin({ server, machine, log = console.log, open = openBrowser, fetchImpl = fetch, sleep = pause }) {
|
|
28
|
+
const post = (path, body) => fetchImpl(new URL(path, server), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
29
|
+
const started = await post('/api/v1/device', { machine })
|
|
30
|
+
if (!started.ok) throw new Error(`Couldn't start sign-in (${started.status}); try again, or use a token from ${new URL('/settings', server)}.`)
|
|
31
|
+
const { code, secret, verifyUrl, interval = 2, expiresIn = 600 } = await started.json()
|
|
32
|
+
const opened = await open(verifyUrl)
|
|
33
|
+
log(`${opened ? 'Opening your browser to link this machine' : 'Open this page to link this machine'}: ${verifyUrl}`)
|
|
34
|
+
log(`Check it shows ${code}, then approve. Waiting…`)
|
|
35
|
+
const deadline = Date.now() + expiresIn * 1000
|
|
36
|
+
while (Date.now() < deadline) {
|
|
37
|
+
await sleep(interval * 1000)
|
|
38
|
+
const response = await post('/api/v1/device/token', { secret }).catch(() => null)
|
|
39
|
+
if (!response || response.status === 202 || response.status === 429 || response.status >= 500) continue
|
|
40
|
+
const body = await response.json().catch(() => ({}))
|
|
41
|
+
if (response.ok && body.token) return body
|
|
42
|
+
throw new Error(body.error ?? `Sign-in failed (${response.status}).`)
|
|
43
|
+
}
|
|
44
|
+
throw new Error('Sign-in timed out; run login again.')
|
|
45
|
+
}
|
package/src/pause.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pausing the background jobs: the scheduled syncs and the evening nudge skip
|
|
2
|
+
// their runs until a set time, then carry on by themselves. A pause is held
|
|
3
|
+
// to 7 days, the window a sync catches up afterwards (older days can't be
|
|
4
|
+
// changed), so nothing done during it is lost: it is uploaded on resume.
|
|
5
|
+
export const MAX_PAUSE_DAYS = 7
|
|
6
|
+
export const MORNING = 6
|
|
7
|
+
|
|
8
|
+
// `1h`…`168h`, `1d`…`7d`, or `tomorrow` (the default: until 06:00 tomorrow,
|
|
9
|
+
// before the first sync of the day).
|
|
10
|
+
export function parsePause(word = 'tomorrow', from = new Date()) {
|
|
11
|
+
const w = String(word).trim().toLowerCase()
|
|
12
|
+
if (w === 'tomorrow' || w === 'today') {
|
|
13
|
+
const until = new Date(from)
|
|
14
|
+
until.setDate(until.getDate() + 1)
|
|
15
|
+
until.setHours(MORNING, 0, 0, 0)
|
|
16
|
+
return until
|
|
17
|
+
}
|
|
18
|
+
const match = /^(\d+)\s*(h|hours?|d|days?)$/.exec(w)
|
|
19
|
+
if (!match) throw new Error(`Pause for how long? \`1h\`, \`3d\` or \`tomorrow\` (up to ${MAX_PAUSE_DAYS} days).`)
|
|
20
|
+
const hours = Number(match[1]) * (match[2].startsWith('d') ? 24 : 1)
|
|
21
|
+
if (hours < 1 || hours > MAX_PAUSE_DAYS * 24) {
|
|
22
|
+
throw new Error(`A pause lasts 1 hour to ${MAX_PAUSE_DAYS} days: a sync afterwards catches up only the last ${MAX_PAUSE_DAYS}. For longer, \`autosync off\`.`)
|
|
23
|
+
}
|
|
24
|
+
return new Date(from.getTime() + hours * 3600e3)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// The time background runs resume, while a pause lasts.
|
|
28
|
+
export const pausedUntil = (config, at = new Date()) => {
|
|
29
|
+
const until = config.pausedUntil ? new Date(config.pausedUntil) : undefined
|
|
30
|
+
return until && until > at ? until : undefined
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const untilText = (until, at = new Date()) => {
|
|
34
|
+
const time = until.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
35
|
+
const sameDay = until.toDateString() === at.toDateString()
|
|
36
|
+
const tomorrow = new Date(at)
|
|
37
|
+
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
38
|
+
const day = sameDay ? 'today' : until.toDateString() === tomorrow.toDateString() ? 'tomorrow' : until.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' })
|
|
39
|
+
return `${day} ${time}`
|
|
40
|
+
}
|
package/src/version.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
4
|
+
|
|
5
|
+
// Release versions only (x.y.z): negative when a is older than b.
|
|
6
|
+
export function compareVersions(a, b) {
|
|
7
|
+
const pa = String(a).split('.').map(Number)
|
|
8
|
+
const pb = String(b).split('.').map(Number)
|
|
9
|
+
for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0)
|
|
10
|
+
return 0
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// One line when the server says a newer CLI is out; nothing otherwise. With
|
|
14
|
+
// autosync on, the update command also moves the background jobs to it.
|
|
15
|
+
export function updateNotice(cli, { brand = 'vibestreak', autosync = false, current = VERSION } = {}) {
|
|
16
|
+
if (!cli?.latest || compareVersions(current, cli.latest) >= 0) return undefined
|
|
17
|
+
return `${brand} ${cli.latest} is out (this is ${current}): ${updateCommand(brand, autosync)}.`
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const updateCommand = (brand, autosync) =>
|
|
21
|
+
autosync ? `\`npx ${brand}@latest autosync on\` updates the background sync to it` : `\`npx ${brand}@latest\` runs it`
|