@arach/pomo 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +66 -0
  2. package/bin/pomo.js +465 -0
  3. package/package.json +36 -0
package/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @arach/pomo
2
+
3
+ Control — and install — the [Pomo](https://github.com/arach/pomo) macOS HUD
4
+ timer from the shell or an agent. A thin, zero-dependency wrapper over Pomo's
5
+ `pomo://` URL scheme and the JSON state file it writes on every tick.
6
+
7
+ > macOS only. It drives the installed Pomo app via `open` (and `hdiutil` for
8
+ > `install`); it doesn't bundle the app itself.
9
+
10
+ ## Use it
11
+
12
+ No install needed — run it with `npx`:
13
+
14
+ ```sh
15
+ npx @arach/pomo install # download & install the latest Pomo.app
16
+ npx @arach/pomo start # start a focus session
17
+ npx @arach/pomo status # see what's happening
18
+ ```
19
+
20
+ Or put it on your PATH:
21
+
22
+ ```sh
23
+ npm install -g @arach/pomo
24
+ pomo status
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ ```
30
+ Timer status [--json] · start · pause · toggle · reset · skip
31
+ session <focus|short|long> · duration <minutes>
32
+ Intent intent <text…> · intent clear
33
+ Audio audio <url> · audio <play|pause|stop|next|prev> · volume <0-100>
34
+ Video video <show|hide|toggle|browser>
35
+ Favorites fav · fav add <url> [title…] · fav play <n> · fav remove <n>
36
+ Window show · hide · hud · menu · face <name> · settings · stats
37
+ Login login · login import [--browser b] [--profile p] · login profiles
38
+ login account <n> · logout
39
+ App install [--dry-run] [--open] · quit
40
+ ```
41
+
42
+ Run `pomo` with no arguments for a live status; `pomo help` for the full list.
43
+
44
+ ### Examples
45
+
46
+ ```sh
47
+ pomo intent "Writing the launch post"
48
+ pomo audio "https://youtube.com/watch?v=jfKfPfyJRdk"
49
+ pomo fav play 1
50
+ pomo status --json | jq .remainingSeconds
51
+ ```
52
+
53
+ ### `install`
54
+
55
+ Finds the newest GitHub release carrying a `.dmg`, downloads it, mounts it,
56
+ copies `Pomo.app` into `/Applications` (falling back to `~/Applications` if that
57
+ isn't writable), clears the download quarantine, and unmounts. `--dry-run`
58
+ prints what it would do; `--open` launches the app afterward.
59
+
60
+ ## How it works
61
+
62
+ - **Commands** → `open "pomo://<verb>"` (fire-and-forget).
63
+ - **`status`** → reads `~/Library/Application Support/Pomo/state.json`.
64
+
65
+ That's the whole contract, so anything the app exposes over `pomo://` is one
66
+ line away here.
package/bin/pomo.js ADDED
@@ -0,0 +1,465 @@
1
+ #!/usr/bin/env node
2
+ // pomo — control and install the Pomo macOS HUD timer.
3
+ //
4
+ // Commands are sent fire-and-forget over the `pomo://` URL scheme (via `open`);
5
+ // `status` reads the JSON state file the app writes on every tick; `install`
6
+ // pulls the latest .dmg from GitHub releases and drops Pomo.app in /Applications.
7
+ //
8
+ // Zero dependencies — Node 18+ built-ins only. macOS only (it drives `open`,
9
+ // `hdiutil`, etc.).
10
+
11
+ import { execFileSync } from 'node:child_process';
12
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
13
+ import { homedir, tmpdir } from 'node:os';
14
+ import { join } from 'node:path';
15
+ import { createInterface } from 'node:readline';
16
+
17
+ const REPO = 'arach/pomo';
18
+ const STATE_FILE = join(homedir(), 'Library', 'Application Support', 'Pomo', 'state.json');
19
+
20
+ const argv = process.argv.slice(2);
21
+ const cmd = (argv[0] || '').toLowerCase();
22
+
23
+ // ─── helpers ────────────────────────────────────────────────────────────────
24
+
25
+ function die(msg) {
26
+ console.error(`pomo: ${msg}`);
27
+ process.exit(1);
28
+ }
29
+
30
+ function requireMac() {
31
+ if (process.platform !== 'darwin') die('this CLI only works on macOS.');
32
+ }
33
+
34
+ /** Fire a pomo:// command at the app via `open`. */
35
+ function send(path) {
36
+ requireMac();
37
+ try {
38
+ execFileSync('open', [`pomo://${path}`], { stdio: 'ignore' });
39
+ } catch {
40
+ die(`couldn't reach Pomo. Is it installed? Try: pomo install`);
41
+ }
42
+ }
43
+
44
+ /** Build a query string from {k: v} pairs, skipping null/undefined. */
45
+ function query(pairs) {
46
+ const parts = Object.entries(pairs)
47
+ .filter(([, v]) => v !== undefined && v !== null && v !== '')
48
+ .map(([k, v]) => `${k}=${encodeURIComponent(v)}`);
49
+ return parts.length ? `?${parts.join('&')}` : '';
50
+ }
51
+
52
+ /** Pull a `--flag value` (or `--flag=value`) out of args; returns the value or undefined. */
53
+ function takeFlag(args, name) {
54
+ const eq = args.find((a) => a.startsWith(`--${name}=`));
55
+ if (eq) return eq.slice(name.length + 3);
56
+ const i = args.indexOf(`--${name}`);
57
+ if (i >= 0 && i + 1 < args.length) return args[i + 1];
58
+ return undefined;
59
+ }
60
+
61
+ function hasFlag(args, name) {
62
+ return args.includes(`--${name}`);
63
+ }
64
+
65
+ function readState() {
66
+ if (!existsSync(STATE_FILE)) {
67
+ die(`no state file at ${STATE_FILE}\n Pomo may not be installed or hasn't run yet. Try: pomo install`);
68
+ }
69
+ try {
70
+ return JSON.parse(readFileSync(STATE_FILE, 'utf8'));
71
+ } catch {
72
+ die('state file is unreadable.');
73
+ }
74
+ }
75
+
76
+ // ─── status ───────────────────────────────────────────────────────────────
77
+
78
+ function printStatus(args) {
79
+ const s = readState();
80
+ if (hasFlag(args, 'json')) {
81
+ console.log(JSON.stringify(s, null, 2));
82
+ return;
83
+ }
84
+ const dot = s.phase === 'running' ? '●' : s.phase === 'paused' ? '❚❚' : '○';
85
+ const phase = s.phase === 'idle' ? 'idle' : s.phase;
86
+ console.log(`${dot} ${s.sessionType} · ${s.clock} (${phase})`);
87
+ if (s.intent) console.log(` intent ${s.intent}`);
88
+
89
+ let audio = '—';
90
+ if (s.audioURL) {
91
+ const fav = (s.favorites || []).find((f) => f.url === s.audioURL);
92
+ const label = fav ? fav.title : s.audioURL;
93
+ audio = `${s.audioPlaying ? '▶' : '⏸'} ${label}`;
94
+ }
95
+ console.log(` audio ${audio}`);
96
+ console.log(` today ${s.focusToday ?? 0} focus · streak ${s.streakDays ?? 0}d · ${s.focusTotal ?? 0} total`);
97
+ console.log(` hud ${s.hudVisible ? 'visible' : 'hidden'} · face ${s.watchface}`);
98
+
99
+ const favs = s.favorites || [];
100
+ if (favs.length) {
101
+ console.log(' favorites');
102
+ favs.forEach((f, i) => console.log(` ${i + 1}. ${f.title}`));
103
+ }
104
+ }
105
+
106
+ // ─── install ────────────────────────────────────────────────────────────────
107
+
108
+ async function latestDmg() {
109
+ const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=30`, {
110
+ headers: { 'User-Agent': 'pomo-cli', Accept: 'application/vnd.github+json' },
111
+ });
112
+ if (!res.ok) die(`GitHub API returned ${res.status} ${res.statusText}.`);
113
+ const releases = await res.json();
114
+ // Releases come back newest-first; take the newest one carrying a .dmg.
115
+ for (const rel of releases) {
116
+ if (rel.draft) continue;
117
+ const dmgs = (rel.assets || []).filter((a) => a.name.toLowerCase().endsWith('.dmg'));
118
+ if (!dmgs.length) continue;
119
+ // Prefer the native, unversioned `Pomo.dmg`; otherwise take the first .dmg.
120
+ const asset = dmgs.find((a) => a.name === 'Pomo.dmg') || dmgs[0];
121
+ return { tag: rel.tag_name, name: rel.name, asset };
122
+ }
123
+ return null;
124
+ }
125
+
126
+ function sh(file, args) {
127
+ return execFileSync(file, args, { encoding: 'utf8' });
128
+ }
129
+
130
+ async function install(args) {
131
+ requireMac();
132
+ const dryRun = hasFlag(args, 'dry-run');
133
+
134
+ process.stderr.write('Finding the latest Pomo .dmg…\n');
135
+ const found = await latestDmg();
136
+ if (!found) die(`no .dmg asset found in any ${REPO} release yet.`);
137
+ const { tag, asset } = found;
138
+ const sizeMB = (asset.size / 1e6).toFixed(1);
139
+ console.log(`Latest: ${asset.name} (${tag}, ${sizeMB} MB)`);
140
+ console.log(` ${asset.browser_download_url}`);
141
+
142
+ if (dryRun) {
143
+ console.log(' → would install to /Applications/Pomo.app (dry run, nothing downloaded)');
144
+ return;
145
+ }
146
+
147
+ // 1. Download.
148
+ const dir = mkdtempSync(join(tmpdir(), 'pomo-install-'));
149
+ const dmgPath = join(dir, asset.name);
150
+ process.stderr.write('Downloading…\n');
151
+ const dl = await fetch(asset.browser_download_url, { headers: { 'User-Agent': 'pomo-cli' } });
152
+ if (!dl.ok) die(`download failed: ${dl.status} ${dl.statusText}`);
153
+ writeFileSync(dmgPath, Buffer.from(await dl.arrayBuffer()));
154
+
155
+ // 2. Mount.
156
+ process.stderr.write('Mounting…\n');
157
+ const attach = sh('hdiutil', ['attach', dmgPath, '-nobrowse', '-noverify', '-readonly']);
158
+ const mount = attach
159
+ .trim()
160
+ .split('\n')
161
+ .map((l) => l.split('\t').pop().trim())
162
+ .filter((p) => p.startsWith('/Volumes/'))
163
+ .pop();
164
+ if (!mount) die('could not determine the mounted volume.');
165
+
166
+ try {
167
+ const app = readdirSync(mount).find((n) => n.endsWith('.app'));
168
+ if (!app) die('no .app inside the disk image.');
169
+
170
+ // 3. Copy into /Applications (fall back to ~/Applications if not writable).
171
+ let appsDir = '/Applications';
172
+ let dest = join(appsDir, app);
173
+ try {
174
+ rmSync(dest, { recursive: true, force: true });
175
+ sh('ditto', [join(mount, app), dest]);
176
+ } catch (e) {
177
+ if (e && (e.code === 'EACCES' || e.code === 'EPERM' || /Permission denied/.test(String(e)))) {
178
+ appsDir = join(homedir(), 'Applications');
179
+ dest = join(appsDir, app);
180
+ process.stderr.write('/Applications not writable — installing to ~/Applications instead.\n');
181
+ rmSync(dest, { recursive: true, force: true });
182
+ sh('mkdir', ['-p', appsDir]);
183
+ sh('ditto', [join(mount, app), dest]);
184
+ } else {
185
+ throw e;
186
+ }
187
+ }
188
+
189
+ // 4. Clear the download quarantine so it launches without a Gatekeeper prompt.
190
+ try {
191
+ sh('xattr', ['-dr', 'com.apple.quarantine', dest]);
192
+ } catch {
193
+ /* best effort */
194
+ }
195
+
196
+ console.log(`Installed ${app} → ${dest}`);
197
+ if (hasFlag(args, 'open')) {
198
+ sh('open', [dest]);
199
+ console.log('Launched.');
200
+ } else {
201
+ console.log('Launch it with: pomo show (or: open -a Pomo)');
202
+ }
203
+ } finally {
204
+ // 5. Always unmount.
205
+ try {
206
+ sh('hdiutil', ['detach', mount, '-quiet']);
207
+ } catch {
208
+ /* best effort */
209
+ }
210
+ }
211
+ }
212
+
213
+ // ─── login / cookie import (local browser data) ─────────────────────────────
214
+
215
+ /** Detected Chromium-family browser profiles, for cookie import. */
216
+ function browserProfiles() {
217
+ // Most-common first, so the browsers people actually use lead the picker.
218
+ const bases = [
219
+ ['chrome', 'Google/Chrome'],
220
+ ['edge', 'Microsoft Edge'],
221
+ ['brave', 'BraveSoftware/Brave-Browser'],
222
+ ['chromium', 'Chromium'],
223
+ ];
224
+ const out = [];
225
+ for (const [browser, base] of bases) {
226
+ const ls = join(homedir(), 'Library', 'Application Support', base, 'Local State');
227
+ if (!existsSync(ls)) continue;
228
+ try {
229
+ const cache = JSON.parse(readFileSync(ls, 'utf8'))?.profile?.info_cache ?? {};
230
+ for (const [dir, info] of Object.entries(cache)) {
231
+ out.push({ browser, dir, name: info?.name ?? '?' });
232
+ }
233
+ } catch {
234
+ /* skip unreadable */
235
+ }
236
+ }
237
+ return out;
238
+ }
239
+
240
+ function listProfiles() {
241
+ const ps = browserProfiles();
242
+ if (!ps.length) return console.log('(no Chromium browser profiles found)');
243
+ for (const p of ps) console.log(` ${p.browser.padEnd(9)} ${p.dir.padEnd(14)} ${p.name}`);
244
+ }
245
+
246
+ async function pickProfile(ps) {
247
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
248
+ ps.forEach((p, i) => process.stderr.write(` ${i + 1}. ${p.browser} · ${p.name} (${p.dir})\n`));
249
+ const ans = await new Promise((res) => rl.question('Import cookies from which profile? [number] ', res));
250
+ rl.close();
251
+ return ps[parseInt(ans, 10) - 1];
252
+ }
253
+
254
+ async function loginCmd(args) {
255
+ const sub = (args[0] || '').toLowerCase();
256
+ switch (sub) {
257
+ case 'profiles':
258
+ return listProfiles();
259
+ case 'account': {
260
+ const n = parseInt(args[1], 10);
261
+ if (!Number.isInteger(n)) die('usage: pomo login account <n>');
262
+ return send(`login/account/${n}`);
263
+ }
264
+ case 'import': {
265
+ let browser = takeFlag(args, 'browser');
266
+ let profile = takeFlag(args, 'profile');
267
+ if (!browser) {
268
+ const ps = browserProfiles();
269
+ if (!ps.length) die('no Chromium browser profiles found to import from.');
270
+ if (!process.stdin.isTTY) {
271
+ console.log('Pick one and pass --browser/--profile:');
272
+ listProfiles();
273
+ return;
274
+ }
275
+ const choice = await pickProfile(ps);
276
+ if (!choice) die('no profile selected.');
277
+ browser = choice.browser;
278
+ profile = choice.dir;
279
+ }
280
+ return send(`login/import${query({ browser, profile })}`);
281
+ }
282
+ default:
283
+ return send('login');
284
+ }
285
+ }
286
+
287
+ // ─── favorites (list reads state; mutations go over the scheme) ──────────────
288
+
289
+ function favorites(args) {
290
+ const sub = (args[0] || 'list').toLowerCase();
291
+ switch (sub) {
292
+ case 'list': {
293
+ const favs = readState().favorites || [];
294
+ if (!favs.length) return console.log('(no favorites yet)');
295
+ favs.forEach((f, i) => console.log(`${i + 1}. ${f.title} — ${f.url}`));
296
+ return;
297
+ }
298
+ case 'add': {
299
+ const url = args[1];
300
+ if (!url) die('usage: pomo fav add <url> [title…]');
301
+ const title = args.slice(2).join(' ') || undefined;
302
+ return send(`favorite/add${query({ url, title })}`);
303
+ }
304
+ case 'play': {
305
+ const n = parseInt(args[1], 10);
306
+ if (!Number.isInteger(n)) die('usage: pomo fav play <n>');
307
+ return send(`favorite/play/${n}`);
308
+ }
309
+ case 'remove': {
310
+ const n = parseInt(args[1], 10);
311
+ if (!Number.isInteger(n)) die('usage: pomo fav remove <n>');
312
+ return send(`favorite/remove/${n}`);
313
+ }
314
+ default:
315
+ die(`unknown fav command: ${sub}`);
316
+ }
317
+ }
318
+
319
+ // ─── help ─────────────────────────────────────────────────────────────────
320
+
321
+ function help() {
322
+ console.log(`pomo — control & install the Pomo macOS HUD timer
323
+
324
+ Usage: pomo <command> [args]
325
+
326
+ Timer
327
+ status [--json] show the live state (default when run with no command)
328
+ start | pause | toggle | reset | skip
329
+ session <focus|short|long>
330
+ duration <minutes>
331
+
332
+ Intent
333
+ intent <text…> set what you're working on
334
+ intent clear clear it
335
+
336
+ Audio / video
337
+ audio <url> play a YouTube/stream link
338
+ audio <play|pause|stop|next|prev>
339
+ volume <0-100>
340
+ video <show|hide|toggle|browser>
341
+
342
+ Favorites
343
+ fav list saved stations
344
+ fav add <url> [title…]
345
+ fav play <n>
346
+ fav remove <n>
347
+
348
+ Window & app
349
+ show | hide | hud summon / dismiss / toggle the HUD
350
+ menu open the menu-bar popover
351
+ face <name> switch watchface
352
+ settings | stats open the Settings / Stats window
353
+ login audio sign-in (YouTube)
354
+ login import [--browser b] [--profile p] import browser cookies (ad-free)
355
+ login profiles list detected browser profiles
356
+ login account <n> | logout
357
+ install [--dry-run] [--open] download & install the latest .dmg
358
+ quit
359
+
360
+ Reads ~/Library/Application Support/Pomo/state.json; sends pomo:// URLs via open.`);
361
+ }
362
+
363
+ // ─── dispatch ────────────────────────────────────────────────────────────────
364
+
365
+ const rest = argv.slice(1);
366
+
367
+ switch (cmd) {
368
+ // plain verbs
369
+ case 'start':
370
+ case 'pause':
371
+ case 'toggle':
372
+ case 'reset':
373
+ case 'skip':
374
+ case 'menu':
375
+ case 'show':
376
+ case 'hide':
377
+ case 'logout':
378
+ case 'settings':
379
+ case 'stats':
380
+ case 'quit':
381
+ send(cmd);
382
+ break;
383
+
384
+ case 'login':
385
+ await loginCmd(rest);
386
+ break;
387
+
388
+ case 'hud':
389
+ send('hud');
390
+ break;
391
+
392
+ case 'session':
393
+ if (!rest[0]) die('usage: pomo session <focus|short|long>');
394
+ send(`session/${rest[0].toLowerCase()}`);
395
+ break;
396
+
397
+ case 'face':
398
+ if (!rest[0]) die('usage: pomo face <name>');
399
+ send(`face/${rest[0].toLowerCase()}`);
400
+ break;
401
+
402
+ case 'duration':
403
+ if (!/^\d+$/.test(rest[0] || '')) die('usage: pomo duration <minutes>');
404
+ send(`duration/${rest[0]}`);
405
+ break;
406
+
407
+ case 'intent':
408
+ if (rest[0] === 'clear' || hasFlag(rest, 'clear') || rest.length === 0) {
409
+ send('intent/clear');
410
+ } else {
411
+ send(`intent${query({ text: rest.join(' ') })}`);
412
+ }
413
+ break;
414
+
415
+ case 'audio': {
416
+ const a = rest[0] || '';
417
+ if (/^https?:\/\//i.test(a)) send(`audio${query({ url: a })}`);
418
+ else if (['play', 'pause', 'stop', 'next', 'prev', 'previous'].includes(a.toLowerCase()))
419
+ send(`audio/${a.toLowerCase()}`);
420
+ else die('usage: pomo audio <url|play|pause|stop|next|prev>');
421
+ break;
422
+ }
423
+
424
+ case 'volume':
425
+ if (!/^\d+$/.test(rest[0] || '')) die('usage: pomo volume <0-100>');
426
+ send(`volume/${rest[0]}`);
427
+ break;
428
+
429
+ case 'video': {
430
+ const sub = (rest[0] || 'toggle').toLowerCase();
431
+ if (!['show', 'hide', 'toggle', 'browser', 'open'].includes(sub))
432
+ die('usage: pomo video <show|hide|toggle|browser>');
433
+ send(`video/${sub}`);
434
+ break;
435
+ }
436
+
437
+ case 'fav':
438
+ case 'favorite':
439
+ case 'favorites':
440
+ favorites(rest);
441
+ break;
442
+
443
+ case 'status':
444
+ printStatus(rest);
445
+ break;
446
+
447
+ case 'install':
448
+ await install(rest);
449
+ break;
450
+
451
+ case '':
452
+ // bare `pomo` → a friendly status if the app's around, else help.
453
+ if (existsSync(STATE_FILE)) printStatus([]);
454
+ else help();
455
+ break;
456
+
457
+ case 'help':
458
+ case '--help':
459
+ case '-h':
460
+ help();
461
+ break;
462
+
463
+ default:
464
+ die(`unknown command: ${cmd}\n Run 'pomo help' for usage.`);
465
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@arach/pomo",
3
+ "version": "0.1.0",
4
+ "description": "Control and install the Pomo macOS HUD timer from the shell or an agent — a thin wrapper over the pomo:// URL scheme and the JSON state file.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pomo": "bin/pomo.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "README.md"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "os": [
17
+ "darwin"
18
+ ],
19
+ "license": "MIT",
20
+ "author": "arach",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/arach/pomo.git",
24
+ "directory": "apps/cli"
25
+ },
26
+ "homepage": "https://github.com/arach/pomo/tree/master/apps/cli",
27
+ "keywords": [
28
+ "pomodoro",
29
+ "pomo",
30
+ "cli",
31
+ "macos",
32
+ "timer",
33
+ "focus",
34
+ "agent"
35
+ ]
36
+ }