vibestreak 0.1.0 → 0.1.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/bin/vibestreak.mjs +67 -7
- package/package.json +1 -1
- package/src/autosync.mjs +34 -1
- package/src/nudge.mjs +8 -0
package/bin/vibestreak.mjs
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { parseArgs } from 'node:util'
|
|
4
4
|
|
|
5
|
-
import { installAutosync, removeAutosync } from '../src/autosync.mjs'
|
|
6
|
-
import { notify, nudgeText, shouldNudge } from '../src/nudge.mjs'
|
|
5
|
+
import { autosyncStatus, clock, installAutosync, NUDGE_TIME, nextRun, removeAutosync, SYNC_TIMES } from '../src/autosync.mjs'
|
|
6
|
+
import { notify, nudgeAction, nudgeText, shouldNudge } from '../src/nudge.mjs'
|
|
7
7
|
import { collect, sinceDate, summarize } from '../src/collect.mjs'
|
|
8
8
|
import { CONFIG_PATH, loadConfig, saveConfig } from '../src/config.mjs'
|
|
9
9
|
import { DEFAULT_ROOTS, globalEmail, scanEmails } from '../src/git.mjs'
|
|
@@ -21,9 +21,10 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
|
|
|
21
21
|
the addresses you commit as (all of your accounts)
|
|
22
22
|
${BRAND} roots [add <dir>|remove <dir>]
|
|
23
23
|
where to look for git repos
|
|
24
|
+
${BRAND} status profile, last sync, streak, autosync and nudge
|
|
24
25
|
${BRAND} autosync [on|off] sync in the background through the day
|
|
25
26
|
${BRAND} nudge [on|off|test] an evening notification when your streak is
|
|
26
|
-
about to end (on with autosync)
|
|
27
|
+
about to end (on with autosync); alone, its status
|
|
27
28
|
${BRAND} whoami the profile this machine syncs to
|
|
28
29
|
|
|
29
30
|
Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
|
|
@@ -116,20 +117,24 @@ async function main() {
|
|
|
116
117
|
|
|
117
118
|
if (command === 'collect' || command === 'sync') return sync(config, values, log, command === 'sync')
|
|
118
119
|
|
|
120
|
+
if (command === 'status') return status(config)
|
|
121
|
+
|
|
119
122
|
if (command === 'nudge') {
|
|
120
123
|
const bin = fileURLToPath(import.meta.url)
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
const action = nudgeAction(rest, values)
|
|
125
|
+
if (action === 'status') return nudgeStatus(config)
|
|
126
|
+
if (action === 'on' || action === 'off') {
|
|
127
|
+
config.nudge = action === 'on'
|
|
123
128
|
await saveConfig(config)
|
|
124
129
|
if (config.autosync) await installAutosync(bin, { nudge: config.nudge })
|
|
125
130
|
return console.log(`Streak nudge ${config.nudge ? 'on: 20:30 each evening when your streak is at risk' : 'off'}.`)
|
|
126
131
|
}
|
|
127
|
-
if (
|
|
132
|
+
if (action === 'test') {
|
|
128
133
|
const { streak } = await api(config, '/api/v1/me')
|
|
129
134
|
await notify(nudgeText({ ...streak, current: Math.max(1, streak.current) }))
|
|
130
135
|
return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
|
|
131
136
|
}
|
|
132
|
-
//
|
|
137
|
+
// `nudge run`, the evening job: sync, then nudge if the streak would end at midnight.
|
|
133
138
|
const result = await sync(config, values, log, true)
|
|
134
139
|
if (shouldNudge(result.streak, config)) {
|
|
135
140
|
await notify(nudgeText(result.streak))
|
|
@@ -181,6 +186,7 @@ async function sync(config, values, log, isSync) {
|
|
|
181
186
|
if (!isSync) return log(`\nNothing was sent. \`${BRAND} sync\` uploads these totals.`)
|
|
182
187
|
const result = await api(config, '/api/v1/upload', { method: 'POST', body: JSON.stringify(payload) })
|
|
183
188
|
config.lastSync = new Date().toISOString()
|
|
189
|
+
config.lastSyncSummary = { days: result.days, commits: result.commits, repos: s.repos }
|
|
184
190
|
await saveConfig(config)
|
|
185
191
|
// Always one line, quiet or not: the background sync's log is how a person sees it ran.
|
|
186
192
|
const streak = result.streak ? ` · streak ${result.streak.current}d${result.streak.activeToday ? '' : ' (not yet today)'}` : ''
|
|
@@ -188,6 +194,60 @@ async function sync(config, values, log, isSync) {
|
|
|
188
194
|
return result
|
|
189
195
|
}
|
|
190
196
|
|
|
197
|
+
const ago = iso => {
|
|
198
|
+
if (!iso) return 'never'
|
|
199
|
+
const minutes = Math.round((Date.now() - Date.parse(iso)) / 60000)
|
|
200
|
+
return minutes < 1 ? 'just now' : minutes < 60 ? `${minutes} min ago` : minutes < 48 * 60 ? `${Math.round(minutes / 60)} h ago` : `${Math.round(minutes / 1440)} days ago`
|
|
201
|
+
}
|
|
202
|
+
const at = ({ at, day }) => `${day} ${clock([at.getHours(), at.getMinutes()])}`
|
|
203
|
+
|
|
204
|
+
// The server's view of the streak, or why it can't be had (not linked, offline).
|
|
205
|
+
async function serverStatus(config) {
|
|
206
|
+
if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login --token PASTE_YOUR_TOKEN\`` }
|
|
207
|
+
try {
|
|
208
|
+
return await api(config, '/api/v1/me')
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return { error: error.message }
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function streakLine(streak) {
|
|
215
|
+
if (!streak) return 'unknown'
|
|
216
|
+
const today = streak.activeToday ? 'active today' : streak.current > 0 ? 'not yet today: ends at midnight' : 'no live streak'
|
|
217
|
+
const next = streak.next ? ` · ${streak.next.remaining} ${streak.next.remaining === 1 ? 'day' : 'days'} to ${streak.next.name}` : ''
|
|
218
|
+
return `${streak.current} ${streak.current === 1 ? 'day' : 'days'} · ${today}${next}`
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function nudgeStatus(config) {
|
|
222
|
+
const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
|
|
223
|
+
const on = config.nudge !== false && scheduled.nudge
|
|
224
|
+
const why = config.nudge === false ? ` (\`${BRAND} nudge on\` turns it on)` : !scheduled.on ? ` (needs autosync: \`${BRAND} autosync on\`)` : ''
|
|
225
|
+
console.log(`Nudge ${on ? `on · checks at ${clock(NUDGE_TIME)}` : `off${why}`}`)
|
|
226
|
+
console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
|
|
227
|
+
if (me.error) return console.log(`Tonight unknown (${me.error})`)
|
|
228
|
+
const s = me.streak
|
|
229
|
+
const tonight = !on ? 'no check' : s.activeToday ? 'no nudge: you were active today' : s.current > 0 ? (config.lastNudge === s.today ? 'already nudged today' : `will nudge at ${clock(NUDGE_TIME)} unless you code with AI first`) : 'no nudge: no live streak'
|
|
230
|
+
console.log(`Streak ${streakLine(s)}`)
|
|
231
|
+
console.log(`Tonight ${tonight}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function status(config) {
|
|
235
|
+
const [scheduled, me] = await Promise.all([autosyncStatus(), serverStatus(config)])
|
|
236
|
+
const row = (label, value) => console.log(`${label.padEnd(11)} ${value}`)
|
|
237
|
+
row('Profile', me.error ? me.error : `@${me.handle} · ${me.profileUrl}`)
|
|
238
|
+
if (!me.error && me.github?.length) row('GitHub', me.github.map(g => `github.com/${g}`).join(', '))
|
|
239
|
+
row('Machine', config.machineName)
|
|
240
|
+
const last = config.lastSyncSummary
|
|
241
|
+
row('Last sync', config.lastSync ? `${ago(config.lastSync)}${last ? ` · ${last.days} days, ${last.commits} commits from ${last.repos} repos` : ''}` : `never: run \`${BRAND} sync\``)
|
|
242
|
+
if (!me.error) row('Streak', streakLine(me.streak))
|
|
243
|
+
row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
|
|
244
|
+
const nudgeOn = config.nudge !== false && scheduled.nudge
|
|
245
|
+
row('Nudge', nudgeOn ? `on · checks at ${clock(NUDGE_TIME)} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
|
|
246
|
+
const github = me.error ? 0 : (me.emails?.length ?? 0)
|
|
247
|
+
row('Addresses', `${config.emails.join(', ') || 'none'}${github ? ` · +${github} from GitHub` : ''}`)
|
|
248
|
+
row('Config', CONFIG_PATH)
|
|
249
|
+
}
|
|
250
|
+
|
|
191
251
|
main().catch(error => {
|
|
192
252
|
console.error(`${BRAND}: ${error.message}`)
|
|
193
253
|
process.exitCode = 1
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibestreak",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.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",
|
package/src/autosync.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
2
3
|
import { cp, mkdir, rm, writeFile } from 'node:fs/promises'
|
|
3
4
|
import { homedir, platform } from 'node:os'
|
|
4
5
|
import { dirname, join } from 'node:path'
|
|
@@ -24,7 +25,7 @@ const logPath = () => join(configDir(), 'sync.log')
|
|
|
24
25
|
// The jobs to schedule: a sync at each sync time, and the evening nudge.
|
|
25
26
|
export const jobs = nudge => [
|
|
26
27
|
{ name: 'sync', args: ['sync', '--quiet'], times: SYNC_TIMES },
|
|
27
|
-
...(nudge ? [{ name: 'nudge', args: ['nudge', '--quiet'], times: [NUDGE_TIME] }] : []),
|
|
28
|
+
...(nudge ? [{ name: 'nudge', args: ['nudge', 'run', '--quiet'], times: [NUDGE_TIME] }] : []),
|
|
28
29
|
]
|
|
29
30
|
|
|
30
31
|
// A CLI run through npx lives in npm's cache, which can be cleared; a
|
|
@@ -78,6 +79,10 @@ const launchd = {
|
|
|
78
79
|
}
|
|
79
80
|
return 'launchd'
|
|
80
81
|
},
|
|
82
|
+
async status() {
|
|
83
|
+
const has = name => existsSync(join(agents(), `${LAUNCHD[name]}.plist`))
|
|
84
|
+
return { on: has('sync'), using: 'launchd', nudge: has('nudge') }
|
|
85
|
+
},
|
|
81
86
|
async remove() {
|
|
82
87
|
for (const label of Object.values(LAUNCHD)) {
|
|
83
88
|
const plist = join(agents(), `${label}.plist`)
|
|
@@ -170,6 +175,14 @@ const linux = {
|
|
|
170
175
|
await writeCrontab(cronBlock(node, bin, nudge, logPath()))
|
|
171
176
|
return 'crontab'
|
|
172
177
|
},
|
|
178
|
+
async status() {
|
|
179
|
+
if (existsSync(join(units(), 'vibestreak-sync.timer'))) {
|
|
180
|
+
return { on: true, using: 'systemd', nudge: existsSync(join(units(), 'vibestreak-nudge.timer')) }
|
|
181
|
+
}
|
|
182
|
+
const cron = await run('crontab', ['-l']).then(r => r.stdout, () => '')
|
|
183
|
+
const block = cron.match(new RegExp(`${CRON_BEGIN}[\\s\\S]*?${CRON_END}`))?.[0] ?? ''
|
|
184
|
+
return { on: /\bsync\b/.test(block), using: 'crontab', nudge: /\bnudge\b/.test(block) }
|
|
185
|
+
},
|
|
173
186
|
async remove() {
|
|
174
187
|
for (const name of ['vibestreak-sync', 'vibestreak-nudge']) {
|
|
175
188
|
await run('systemctl', ['--user', 'disable', '--now', `${name}.timer`]).catch(() => {})
|
|
@@ -204,6 +217,11 @@ const windows = {
|
|
|
204
217
|
}
|
|
205
218
|
return 'Task Scheduler'
|
|
206
219
|
},
|
|
220
|
+
async status() {
|
|
221
|
+
const has = name => run('schtasks', ['/Query', '/TN', name]).then(() => true, () => false)
|
|
222
|
+
const [syncJob, nudgeJob] = jobs(true)
|
|
223
|
+
return { on: await has(windowsTaskName(syncJob, syncJob.times[0])), using: 'Task Scheduler', nudge: await has(windowsTaskName(nudgeJob, NUDGE_TIME)) }
|
|
224
|
+
},
|
|
207
225
|
async remove() {
|
|
208
226
|
for (const name of ALL_WINDOWS_TASKS) await run('schtasks', ['/Delete', '/F', '/TN', name]).catch(() => {})
|
|
209
227
|
},
|
|
@@ -225,6 +243,21 @@ export async function installAutosync(bin, { nudge = true } = {}) {
|
|
|
225
243
|
return { installed: true, using, times, nudgeAt, log: logPath() }
|
|
226
244
|
}
|
|
227
245
|
|
|
246
|
+
// What the system's scheduler holds now: whether the syncs and the nudge are scheduled.
|
|
247
|
+
export async function autosyncStatus() {
|
|
248
|
+
const scheduler = SCHEDULERS[platform()]
|
|
249
|
+
return scheduler ? scheduler.status() : { on: false, using: undefined, nudge: false }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// The next of the given daily times after `now`: today if one is still ahead, else tomorrow's first.
|
|
253
|
+
export function nextRun(times, now = new Date()) {
|
|
254
|
+
const today = times.map(([h, m]) => new Date(now.getFullYear(), now.getMonth(), now.getDate(), h, m)).sort((a, b) => a - b)
|
|
255
|
+
const later = today.find(t => t > now)
|
|
256
|
+
if (later) return { at: later, day: 'today' }
|
|
257
|
+
const [first] = today
|
|
258
|
+
return { at: new Date(first.getFullYear(), first.getMonth(), first.getDate() + 1, first.getHours(), first.getMinutes()), day: 'tomorrow' }
|
|
259
|
+
}
|
|
260
|
+
|
|
228
261
|
export async function removeAutosync() {
|
|
229
262
|
await SCHEDULERS[platform()]?.remove()
|
|
230
263
|
return { removed: true }
|
package/src/nudge.mjs
CHANGED
|
@@ -11,6 +11,14 @@ export function shouldNudge(streak, config) {
|
|
|
11
11
|
return streak.atRisk === true && streak.current > 0 && config.lastNudge !== streak.today
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
// What `nudge` was asked to do. Alone it shows the nudge's status; with
|
|
15
|
+
// --quiet and nothing else it is the scheduled check as 0.1.0 installed it
|
|
16
|
+
// (`nudge --quiet`), so those jobs keep working until autosync is rerun.
|
|
17
|
+
export function nudgeAction(rest, values) {
|
|
18
|
+
if (['on', 'off', 'test', 'run'].includes(rest[0])) return rest[0]
|
|
19
|
+
return values.quiet ? 'run' : 'status'
|
|
20
|
+
}
|
|
21
|
+
|
|
14
22
|
export function nudgeText(streak) {
|
|
15
23
|
const days = streak.current === 1 ? '1-day streak' : `${streak.current}-day streak`
|
|
16
24
|
const next = streak.next && streak.current + 1 >= streak.next.days ? ` Today makes it ${streak.next.name}.` : ''
|