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 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 login --token PASTE_YOUR_TOKEN # token from Settings → Machines
9
- npx vibestreak emails scan # add every address you commit as
10
- npx vibestreak sync
11
- npx vibestreak autosync on # background syncs + evening streak check
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
@@ -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} login --token <t> [--server <url>]
26
- link this machine (token from your settings page)
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 --token <token>\`.`)
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, how to start.
86
- if (!config.token) return console.log(`${HELP}\n\nGet started: ${server.replace(/\/$/, '')}/cli`)
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
- return dashboardView({ server, token: config.token, handle: me.handle, columns })
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
- if (!values.token) return console.log(`Get a token on ${values.server ?? config.server ?? DEFAULT_SERVER}/settings, then:\n ${BRAND} login --token <token>`)
135
- config.server = values.server ?? config.server ?? DEFAULT_SERVER
136
- config.token = values.token
137
- const me = await api(config, '/api/v1/me')
138
- if (!config.emails.length) {
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
- console.log(`Linked ${config.machineName} to ${me.handle} (${me.profileUrl}).`)
144
- console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; run \`${BRAND} emails scan\` to add your other accounts.`)
145
- return
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') return sync(config, values, log, 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 --token PASTE_YOUR_TOKEN\`` }
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 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 ${time} unless you code with AI first`) : 'no nudge: no live streak'
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.2.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
+ }
@@ -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`