vibestreak 0.2.0 → 0.3.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 +4 -4
- package/bin/vibestreak.mjs +110 -21
- package/package.json +1 -1
- 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/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,7 +7,9 @@ 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'
|
|
10
13
|
import { boardView, dashboardView, profileView, todayView } from '../src/views.mjs'
|
|
11
14
|
|
|
12
15
|
const BRAND = process.env.VIBESTREAK_BRAND ?? 'vibestreak'
|
|
@@ -15,6 +18,7 @@ const DEFAULT_SERVER = process.env.VIBESTREAK_SERVER ?? 'https://vibestreak.king
|
|
|
15
18
|
const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and how steadily.
|
|
16
19
|
|
|
17
20
|
${BRAND} your streak, heatmap and rank at a glance
|
|
21
|
+
(the first time: sets this machine up)
|
|
18
22
|
${BRAND} me your profile: streak, heatmap, totals
|
|
19
23
|
${BRAND} profile <handle> someone else's public profile
|
|
20
24
|
${BRAND} board [today|7d|30d|90d|all] [consistency|shipped|streak|tokens] [tool]
|
|
@@ -22,8 +26,10 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
|
|
|
22
26
|
${BRAND} today who is coding with AI today; streaks at risk
|
|
23
27
|
|
|
24
28
|
${BRAND} collect [--json] preview what would be sent (nothing leaves the machine)
|
|
25
|
-
${BRAND}
|
|
26
|
-
|
|
29
|
+
${BRAND} setup [--token <t>] link, sync and turn on autosync and the nudge
|
|
30
|
+
with the defaults, asking nothing
|
|
31
|
+
${BRAND} login link this machine in the browser
|
|
32
|
+
(or --token <t> from your settings page)
|
|
27
33
|
${BRAND} sync [--quiet] collect and upload daily totals
|
|
28
34
|
${BRAND} emails [scan|add <e>|remove <e>]
|
|
29
35
|
the addresses you commit as (all of your accounts)
|
|
@@ -34,6 +40,9 @@ const HELP = `${BRAND} — share your AI coding heatmap: what you shipped, and h
|
|
|
34
40
|
${BRAND} nudge [on|off|test] an evening notification when your streak is
|
|
35
41
|
about to end (on with autosync); alone, its status
|
|
36
42
|
${BRAND} nudge at 21:45 when it checks (17:00 to 23:30; default 20:30)
|
|
43
|
+
${BRAND} pause [1h|3d|tomorrow] pause background syncs and the nudge (up to 7
|
|
44
|
+
days; default until tomorrow 06:00)
|
|
45
|
+
${BRAND} resume end a pause now
|
|
37
46
|
${BRAND} whoami the profile this machine syncs to
|
|
38
47
|
|
|
39
48
|
Reads: Claude Code, Codex, Cursor, Copilot (CLI and VS Code), Gemini CLI,
|
|
@@ -47,7 +56,7 @@ All commands, with examples: ${DEFAULT_SERVER}/cli`
|
|
|
47
56
|
const fmt = n => n.toLocaleString('en-US')
|
|
48
57
|
|
|
49
58
|
async function api(config, path, init = {}) {
|
|
50
|
-
if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login
|
|
59
|
+
if (!config.server || !config.token) throw new Error(`Not linked yet: run \`${BRAND} login\`.`)
|
|
51
60
|
const response = await fetch(new URL(path, config.server), {
|
|
52
61
|
...init,
|
|
53
62
|
headers: { authorization: `Bearer ${config.token}`, 'content-type': 'application/json', ...init.headers },
|
|
@@ -71,6 +80,9 @@ async function main() {
|
|
|
71
80
|
from: { type: 'string' },
|
|
72
81
|
to: { type: 'string' },
|
|
73
82
|
limit: { type: 'string' },
|
|
83
|
+
// Set by the background jobs, so a pause skips them and nothing else.
|
|
84
|
+
scheduled: { type: 'boolean' },
|
|
85
|
+
yes: { type: 'boolean', short: 'y' },
|
|
74
86
|
},
|
|
75
87
|
})
|
|
76
88
|
const [command = 'dashboard', ...rest] = positionals
|
|
@@ -82,8 +94,12 @@ async function main() {
|
|
|
82
94
|
const server = config.server ?? DEFAULT_SERVER
|
|
83
95
|
|
|
84
96
|
if (command === 'dashboard') {
|
|
85
|
-
// Plain `vibestreak`: the dashboard once linked; before that,
|
|
86
|
-
|
|
97
|
+
// Plain `vibestreak`: the dashboard once linked; before that, setup (or,
|
|
98
|
+
// with no one at the keyboard, how to start).
|
|
99
|
+
if (!config.token) {
|
|
100
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return console.log(`${HELP}\n\nGet started: ${server.replace(/\/$/, '')}/cli`)
|
|
101
|
+
return setup(config, values, { ask: !values.yes })
|
|
102
|
+
}
|
|
87
103
|
const me = await api(config, '/api/v1/me')
|
|
88
104
|
return dashboardView({ server, token: config.token, handle: me.handle, columns })
|
|
89
105
|
}
|
|
@@ -130,19 +146,25 @@ async function main() {
|
|
|
130
146
|
return console.log((config.roots ?? DEFAULT_ROOTS).join('\n'))
|
|
131
147
|
}
|
|
132
148
|
|
|
133
|
-
if (command === 'login')
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const first = await globalEmail()
|
|
140
|
-
if (first) config.emails = [first]
|
|
141
|
-
}
|
|
149
|
+
if (command === 'login') return login(config, values)
|
|
150
|
+
if (command === 'setup') return setup(config, values, { ask: false })
|
|
151
|
+
|
|
152
|
+
if (command === 'pause') {
|
|
153
|
+
const until = parsePause(rest[0])
|
|
154
|
+
config.pausedUntil = until.toISOString()
|
|
142
155
|
await saveConfig(config)
|
|
143
|
-
|
|
144
|
-
console.log(
|
|
145
|
-
|
|
156
|
+
await refreshJobs(config)
|
|
157
|
+
return console.log(
|
|
158
|
+
`Paused until ${untilText(until)}: no background syncs or streak nudge until then.` +
|
|
159
|
+
` The first sync after it uploads everything you did meanwhile. \`${BRAND} resume\` ends it sooner.`,
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
if (command === 'resume') {
|
|
163
|
+
const was = pausedUntil(config)
|
|
164
|
+
delete config.pausedUntil
|
|
165
|
+
await saveConfig(config)
|
|
166
|
+
await refreshJobs(config)
|
|
167
|
+
return console.log(was ? `Resumed: background syncs${config.nudge === false ? '' : ' and the nudge'} run again from the next scheduled time.` : 'Not paused.')
|
|
146
168
|
}
|
|
147
169
|
|
|
148
170
|
if (command === 'whoami') {
|
|
@@ -150,7 +172,11 @@ async function main() {
|
|
|
150
172
|
return console.log(`${me.handle} · ${me.profileUrl}\nGitHub: ${me.github.join(', ')}\nMachine: ${config.machineName}`)
|
|
151
173
|
}
|
|
152
174
|
|
|
153
|
-
if (command === 'collect' || command === 'sync')
|
|
175
|
+
if (command === 'collect' || command === 'sync') {
|
|
176
|
+
const until = values.scheduled && pausedUntil(config)
|
|
177
|
+
if (until) return console.log(`${new Date().toISOString()} paused until ${until.toISOString()}; skipped`)
|
|
178
|
+
return sync(config, values, log, command === 'sync')
|
|
179
|
+
}
|
|
154
180
|
|
|
155
181
|
if (command === 'status') return status(config)
|
|
156
182
|
|
|
@@ -180,6 +206,8 @@ async function main() {
|
|
|
180
206
|
return console.log(`Sent a test notification (your streak: ${streak.current} days; ${streak.activeToday ? 'active today' : 'not active yet today'}).`)
|
|
181
207
|
}
|
|
182
208
|
// `nudge run`, the evening job: sync, then nudge if the streak would end at midnight.
|
|
209
|
+
const until = pausedUntil(config)
|
|
210
|
+
if (until) return console.log(`${new Date().toISOString()} paused until ${until.toISOString()}; skipped`)
|
|
183
211
|
const result = await sync(config, values, log, true)
|
|
184
212
|
if (shouldNudge(result.streak, config)) {
|
|
185
213
|
await notify(nudgeText(result.streak))
|
|
@@ -210,6 +238,63 @@ async function main() {
|
|
|
210
238
|
process.exitCode = 1
|
|
211
239
|
}
|
|
212
240
|
|
|
241
|
+
// Links this machine: in the browser by default, or with a token from the
|
|
242
|
+
// settings page (for machines without one).
|
|
243
|
+
async function login(config, values, { addresses = true } = {}) {
|
|
244
|
+
config.server = values.server ?? config.server ?? DEFAULT_SERVER
|
|
245
|
+
if (values.token) config.token = values.token
|
|
246
|
+
else config.token = (await deviceLogin({ server: config.server, machine: config.machineName })).token
|
|
247
|
+
const me = await api(config, '/api/v1/me')
|
|
248
|
+
if (!config.emails.length) {
|
|
249
|
+
const first = await globalEmail()
|
|
250
|
+
if (first) config.emails = [first]
|
|
251
|
+
}
|
|
252
|
+
await saveConfig(config)
|
|
253
|
+
console.log(`Linked ${config.machineName} to @${me.handle} (${me.profileUrl}).`)
|
|
254
|
+
if (addresses) console.log(`Commit addresses: ${config.emails.join(', ') || 'none'}; \`${BRAND} emails scan\` finds your other accounts' addresses.`)
|
|
255
|
+
return me
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Everything a machine needs, with the defaults: linked (in the browser, or
|
|
259
|
+
// with --token), the commit addresses from git config, the usual repo
|
|
260
|
+
// folders, a first sync, and autosync with the evening nudge. The first plain
|
|
261
|
+
// `npx vibestreak` asks about autosync; `setup` asks nothing.
|
|
262
|
+
async function setup(config, values, { ask: interactive }) {
|
|
263
|
+
console.log(`${BRAND}: your AI coding heatmap and streak, from this machine's history.\n`)
|
|
264
|
+
console.log('Nothing but daily counts leaves this machine: never prompts, code, file paths or repo names.')
|
|
265
|
+
console.log(`(\`${BRAND} collect\` previews it all without sending anything.)\n`)
|
|
266
|
+
await login(config, values, { addresses: false })
|
|
267
|
+
const found = await configuredEmails(config.roots ?? DEFAULT_ROOTS)
|
|
268
|
+
config.emails = [...new Set([...config.emails, ...found])]
|
|
269
|
+
await saveConfig(config)
|
|
270
|
+
console.log(`Counting commits by ${config.emails.join(', ') || 'no address yet'} (from git config) and your GitHub accounts' noreply addresses.`)
|
|
271
|
+
console.log('\nFirst sync: reading your history (a minute for a big one)…')
|
|
272
|
+
await sync(config, values, console.log, true)
|
|
273
|
+
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] `))
|
|
274
|
+
if (yes) {
|
|
275
|
+
const result = await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
|
|
276
|
+
config.autosync = result.installed
|
|
277
|
+
await saveConfig(config)
|
|
278
|
+
console.log(result.installed ? `On: syncing via ${result.using}. \`${BRAND} pause\` or \`${BRAND} autosync off\` any time.` : result.hint)
|
|
279
|
+
} else console.log(`Off. \`${BRAND} sync\` updates it, or \`${BRAND} autosync on\` later.`)
|
|
280
|
+
console.log(`\nDone. \`${BRAND}\` shows your streak. Commit from other addresses too? \`${BRAND} emails scan\` lists them. \`${BRAND} help\` lists everything.`)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async function ask(question) {
|
|
284
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
285
|
+
try {
|
|
286
|
+
return !/^n/i.test((await rl.question(question)).trim())
|
|
287
|
+
} finally {
|
|
288
|
+
rl.close()
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The background jobs run a copy of the CLI kept in the config folder: a
|
|
293
|
+
// pause or resume refreshes it, so the jobs run code that knows about pauses.
|
|
294
|
+
async function refreshJobs(config) {
|
|
295
|
+
if (config.autosync) await installAutosync(fileURLToPath(import.meta.url), { nudge: config.nudge !== false, nudgeTime: nudgeTimeOf(config) })
|
|
296
|
+
}
|
|
297
|
+
|
|
213
298
|
// Collects this machine's totals and, for a sync, uploads them; the server
|
|
214
299
|
// answers with where the streak stands (the nudge's input).
|
|
215
300
|
async function sync(config, values, log, isSync) {
|
|
@@ -250,7 +335,7 @@ const at = ({ at, day }) => `${day} ${clock([at.getHours(), at.getMinutes()])}`
|
|
|
250
335
|
|
|
251
336
|
// The server's view of the streak, or why it can't be had (not linked, offline).
|
|
252
337
|
async function serverStatus(config) {
|
|
253
|
-
if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login
|
|
338
|
+
if (!config.server || !config.token) return { error: `not linked: run \`${BRAND} login\`` }
|
|
254
339
|
try {
|
|
255
340
|
return await api(config, '/api/v1/me')
|
|
256
341
|
} catch (error) {
|
|
@@ -274,7 +359,9 @@ async function nudgeStatus(config) {
|
|
|
274
359
|
console.log(`Last nudge ${config.lastNudge ?? 'never'}`)
|
|
275
360
|
if (me.error) return console.log(`Tonight unknown (${me.error})`)
|
|
276
361
|
const s = me.streak
|
|
277
|
-
const
|
|
362
|
+
const paused = pausedUntil(config)
|
|
363
|
+
if (paused) console.log(`Paused until ${untilText(paused)}`)
|
|
364
|
+
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
365
|
console.log(`Streak ${streakLine(s)}`)
|
|
279
366
|
console.log(`Tonight ${tonight}`)
|
|
280
367
|
}
|
|
@@ -289,6 +376,8 @@ async function status(config) {
|
|
|
289
376
|
row('Last sync', config.lastSync ? `${ago(config.lastSync)}${last ? ` · ${last.days} days, ${last.commits} commits from ${last.repos} repos` : ''}` : `never: run \`${BRAND} sync\``)
|
|
290
377
|
if (!me.error) row('Streak', streakLine(me.streak))
|
|
291
378
|
row('Autosync', scheduled.on ? `on (${scheduled.using}) · next sync ${at(nextRun(SYNC_TIMES))}` : `off: \`${BRAND} autosync on\` syncs at ${SYNC_TIMES.map(clock).join(', ')}`)
|
|
379
|
+
const paused = pausedUntil(config)
|
|
380
|
+
if (paused) row('Paused', `until ${untilText(paused)}: background syncs and the nudge skip their runs (\`${BRAND} resume\` ends it)`)
|
|
292
381
|
const nudgeOn = config.nudge !== false && scheduled.nudge
|
|
293
382
|
row('Nudge', nudgeOn ? `on · checks at ${clock(nudgeTimeOf(config))} · last nudged ${config.lastNudge ?? 'never'}` : config.nudge === false ? 'off' : 'off (needs autosync)')
|
|
294
383
|
const github = me.error ? 0 : (me.emails?.length ?? 0)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibestreak",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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
|
@@ -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
|
+
}
|