pom-tool 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ming
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,126 @@
1
+ ## pom-tool
2
+
3
+ A small **Node.js CLI** (TypeScript + pnpm) for running Pomodoro timers in your terminal, with optional **Bark** notifications synced to your phone.
4
+
5
+ ## Features
6
+
7
+ - **Run a Pomodoro for N minutes**
8
+ - `pom 25` starts a 25-minute Pomodoro.
9
+ - **View stats**
10
+ - `pom status` shows average Pomodoro minutes for:
11
+ - today
12
+ - the last 7 days
13
+ - the last 30 days
14
+ - **Bark notifications**
15
+ - `pom bark` configures a Bark URL so the CLI can push notifications to the Bark app on your iPhone.
16
+ - `pom bark --url <BARK_URL>` supports non-interactive setup.
17
+ - **Help**
18
+ - `pom --help` shows the help message.
19
+ - `pom help` shows the help message.
20
+ - `pom -h` shows the help message.
21
+
22
+ ## Requirements
23
+
24
+ - Node.js (recommended: an active LTS version)
25
+
26
+ ## Install (from npm)
27
+
28
+ Install globally:
29
+
30
+ ```bash
31
+ npm i -g pom-tool
32
+ ```
33
+
34
+ Or use without installing (recommended for quick usage):
35
+
36
+ ```bash
37
+ npx pom-tool 25
38
+ ```
39
+
40
+ After installing, the command is:
41
+
42
+ ```bash
43
+ pom
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Start a Pomodoro
49
+
50
+ ```bash
51
+ pom 25
52
+ ```
53
+
54
+ ### Check status / averages
55
+
56
+ ```bash
57
+ pom status
58
+ ```
59
+
60
+ ### Configure Bark
61
+
62
+ 1. Get your Bark push URL from the Bark app (it usually looks like `https://api.day.app/<your_key>`).
63
+ 2. Set it via the CLI:
64
+
65
+ ```bash
66
+ pom bark
67
+ ```
68
+
69
+ After it’s configured, the CLI will send important notifications (for example: timer started/finished) to Bark.
70
+
71
+ For non-interactive setup:
72
+
73
+ ```bash
74
+ pom bark --url https://api.day.app/<your_key>
75
+ ```
76
+
77
+ ## Development
78
+
79
+ This repo uses **pnpm + TypeScript**.
80
+
81
+ ### Setup
82
+
83
+ ```bash
84
+ git clone <your-repo-url>
85
+ cd pom-tool
86
+ pnpm install
87
+ ```
88
+
89
+ ### Run locally
90
+
91
+ Common workflows for Node CLIs:
92
+
93
+ - Build then run the compiled CLI:
94
+
95
+ ```bash
96
+ pnpm build
97
+ node dist/index.js 25
98
+ ```
99
+
100
+ - Or run directly with a TS runner (if configured in this repo later):
101
+
102
+ ```bash
103
+ pnpm dev -- 25
104
+ ```
105
+
106
+ ### Link for local testing (global `pom`)
107
+
108
+ ```bash
109
+ pnpm build
110
+ pnpm link --global
111
+ pom 25
112
+ ```
113
+
114
+ To unlink:
115
+
116
+ ```bash
117
+ pnpm unlink --global pom-tool
118
+ ```
119
+
120
+ ## Versioning
121
+
122
+ This package follows **Semantic Versioning** (SemVer): `MAJOR.MINOR.PATCH`.
123
+
124
+ ## License
125
+
126
+ See `LICENSE`.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env node
2
+ import { execFile } from "node:child_process";
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import process, { stdin as input, stdout as output } from "node:process";
7
+ import readline from "node:readline/promises";
8
+ import { promisify } from "node:util";
9
+ const ANSI = {
10
+ reset: "\u001b[0m",
11
+ bold: "\u001b[1m",
12
+ dim: "\u001b[2m",
13
+ red: "\u001b[31m",
14
+ green: "\u001b[32m",
15
+ yellow: "\u001b[33m",
16
+ blue: "\u001b[34m",
17
+ cyan: "\u001b[36m",
18
+ gray: "\u001b[90m",
19
+ };
20
+ const DATA_DIR = process.env.POM_TOOL_DATA_DIR || path.join(os.homedir(), ".pom-tool");
21
+ const CONFIG_PATH = path.join(DATA_DIR, "config.json");
22
+ const SESSIONS_PATH = path.join(DATA_DIR, "sessions.json");
23
+ const COLOR_ENABLED = process.stdout.isTTY &&
24
+ !("NO_COLOR" in process.env) &&
25
+ process.env.TERM !== "dumb";
26
+ const execFileAsync = promisify(execFile);
27
+ async function main() {
28
+ const args = process.argv.slice(2);
29
+ const command = args[0];
30
+ if (!command) {
31
+ printHelp();
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ if (command === "--help" || command === "-h" || command === "help") {
36
+ printHelp();
37
+ return;
38
+ }
39
+ if (command === "status") {
40
+ await showStatus();
41
+ return;
42
+ }
43
+ if (command === "bark") {
44
+ await configureBark(args.slice(1));
45
+ return;
46
+ }
47
+ const minutes = Number(command);
48
+ if (!Number.isInteger(minutes) || minutes <= 0) {
49
+ console.error(`Invalid argument: "${command}"`);
50
+ printHelp();
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+ await runPomodoro(minutes);
55
+ }
56
+ function printHelp() {
57
+ console.log(renderPanel("pom-tool", [
58
+ `${label("Usage")} ${style("pom <minutes>", "cyan")} Start a Pomodoro timer`,
59
+ ` ${style("pom status", "cyan")} Show Pomodoro averages`,
60
+ ` ${style("pom bark [--url URL]", "cyan")} Configure Bark notifications`,
61
+ ]));
62
+ }
63
+ async function ensureDataDir() {
64
+ await mkdir(DATA_DIR, { recursive: true });
65
+ }
66
+ async function readJsonFile(filePath, fallback) {
67
+ try {
68
+ const content = await readFile(filePath, "utf8");
69
+ return JSON.parse(content);
70
+ }
71
+ catch (error) {
72
+ if (error.code === "ENOENT") {
73
+ return fallback;
74
+ }
75
+ throw error;
76
+ }
77
+ }
78
+ async function writeJsonFile(filePath, value) {
79
+ await ensureDataDir();
80
+ await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
81
+ }
82
+ async function readStore() {
83
+ const [config, sessions] = await Promise.all([
84
+ readJsonFile(CONFIG_PATH, {}),
85
+ readJsonFile(SESSIONS_PATH, []),
86
+ ]);
87
+ return { config, sessions };
88
+ }
89
+ async function saveConfig(config) {
90
+ await writeJsonFile(CONFIG_PATH, config);
91
+ }
92
+ async function appendSession(record) {
93
+ const sessions = await readJsonFile(SESSIONS_PATH, []);
94
+ sessions.push(record);
95
+ await writeJsonFile(SESSIONS_PATH, sessions);
96
+ }
97
+ async function configureBark(args) {
98
+ const inlineUrl = readFlagValue(args, "--url");
99
+ const currentConfig = await readJsonFile(CONFIG_PATH, {});
100
+ const barkUrl = inlineUrl ?? (await promptForBarkUrl(currentConfig.barkUrl));
101
+ if (!barkUrl) {
102
+ console.log(style("Bark setup cancelled.", "yellow"));
103
+ return;
104
+ }
105
+ validateBarkUrl(barkUrl);
106
+ await saveConfig({ ...currentConfig, barkUrl });
107
+ console.log(renderPanel("Bark", [
108
+ `${label("Status")} ${style("saved", "green")}`,
109
+ `${label("URL")} ${maskBarkUrl(barkUrl)}`,
110
+ ]));
111
+ }
112
+ function readFlagValue(args, flag) {
113
+ const index = args.indexOf(flag);
114
+ if (index === -1) {
115
+ return undefined;
116
+ }
117
+ const value = args[index + 1];
118
+ if (!value || value.startsWith("--")) {
119
+ throw new Error(`Missing value for ${flag}.`);
120
+ }
121
+ return value;
122
+ }
123
+ async function promptForBarkUrl(existingUrl) {
124
+ const rl = readline.createInterface({ input, output });
125
+ try {
126
+ const hint = existingUrl ? ` [current: ${existingUrl}]` : "";
127
+ const answer = await rl.question(`Enter Bark URL${hint}: `);
128
+ const nextUrl = answer.trim();
129
+ return nextUrl || existingUrl;
130
+ }
131
+ finally {
132
+ rl.close();
133
+ }
134
+ }
135
+ function validateBarkUrl(barkUrl) {
136
+ let parsed;
137
+ try {
138
+ parsed = new URL(barkUrl);
139
+ }
140
+ catch {
141
+ throw new Error("Bark URL is not a valid URL.");
142
+ }
143
+ if (!["http:", "https:"].includes(parsed.protocol)) {
144
+ throw new Error("Bark URL must use http or https.");
145
+ }
146
+ }
147
+ async function runPomodoro(minutes) {
148
+ const { config } = await readStore();
149
+ const totalSeconds = minutes * 60;
150
+ const startedAt = new Date();
151
+ console.log(renderPanel("🍅 Pomodoro", [
152
+ `${label("Duration")} ${style(formatMinutes(minutes), "cyan")}`,
153
+ `${label("Started")} ${formatClockTime(startedAt)}`,
154
+ `${label("Bark")} ${config.barkUrl ? style("enabled", "green") : style("disabled", "gray")}`,
155
+ ]));
156
+ const interrupted = await startCountdown(totalSeconds);
157
+ if (interrupted) {
158
+ console.log(`\n${style("Pomodoro cancelled.", "yellow")}`);
159
+ process.exitCode = 130;
160
+ return;
161
+ }
162
+ const finishedAt = new Date();
163
+ await appendSession({
164
+ completedAt: toIsoSeconds(finishedAt),
165
+ minutes,
166
+ });
167
+ await notifyPomodoroFinished(minutes, finishedAt);
168
+ process.stdout.write("\n");
169
+ console.log(renderPanel("🍅 Completed", [
170
+ `${label("Logged")} ${style(formatMinutes(minutes), "green")}`,
171
+ `${label("Finished")} ${formatClockTime(finishedAt)}`,
172
+ `${label("Saved")} ${style("session recorded", "green")}`,
173
+ ]));
174
+ await sendBarkNotification(config.barkUrl, "Pomodoro finished", `${minutes} minute${minutes === 1 ? "" : "s"} completed at ${formatClockTime(finishedAt)}`);
175
+ }
176
+ async function startCountdown(totalSeconds) {
177
+ let interrupted = false;
178
+ const onSigint = () => {
179
+ interrupted = true;
180
+ };
181
+ process.once("SIGINT", onSigint);
182
+ try {
183
+ for (let remaining = totalSeconds; remaining >= 0; remaining -= 1) {
184
+ renderRemaining(remaining);
185
+ if (remaining === 0) {
186
+ break;
187
+ }
188
+ await sleep(1000);
189
+ if (interrupted) {
190
+ return true;
191
+ }
192
+ }
193
+ }
194
+ finally {
195
+ process.off("SIGINT", onSigint);
196
+ }
197
+ return interrupted;
198
+ }
199
+ function renderRemaining(totalSeconds) {
200
+ const minutes = Math.floor(totalSeconds / 60);
201
+ const seconds = totalSeconds % 60;
202
+ const timestamp = `${pad(minutes)}:${pad(seconds)}`;
203
+ const reset = COLOR_ENABLED ? ANSI.reset : "";
204
+ process.stdout.write(`\r⏳ ${style("Timer", "blue")} ${style(timestamp, totalSeconds <= 10 ? "yellow" : "cyan")}${reset}`);
205
+ }
206
+ function pad(value) {
207
+ return value.toString().padStart(2, "0");
208
+ }
209
+ function sleep(ms) {
210
+ return new Promise((resolve) => setTimeout(resolve, ms));
211
+ }
212
+ async function sendBarkNotification(barkUrl, title, body) {
213
+ if (!barkUrl) {
214
+ return;
215
+ }
216
+ try {
217
+ const url = new URL(barkUrl);
218
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${encodeURIComponent(title)}/${encodeURIComponent(body)}`;
219
+ const response = await fetch(url, { method: "GET" });
220
+ if (!response.ok) {
221
+ console.warn(`Bark notification failed with status ${response.status}.`);
222
+ }
223
+ }
224
+ catch (error) {
225
+ console.warn(`Bark notification failed: ${error.message}`);
226
+ }
227
+ }
228
+ async function notifyPomodoroFinished(minutes, finishedAt) {
229
+ playTerminalBell();
230
+ await maybeSendSystemNotification(minutes, finishedAt);
231
+ }
232
+ function playTerminalBell() {
233
+ process.stdout.write("\u0007");
234
+ }
235
+ async function maybeSendSystemNotification(minutes, finishedAt) {
236
+ if (process.platform !== "darwin") {
237
+ return;
238
+ }
239
+ const message = `Pomodoro finished. ${formatMinutes(minutes)} completed at ${formatClockTime(finishedAt)}.`;
240
+ try {
241
+ await execFileAsync("/usr/bin/say", [message]);
242
+ }
243
+ catch {
244
+ // Best-effort only. Terminal bell already fired.
245
+ }
246
+ }
247
+ async function showStatus() {
248
+ const { config, sessions } = await readStore();
249
+ const today = new Date();
250
+ const todayTotal = sumMinutesSince(sessions, startOfDay(today));
251
+ const last7Total = sumMinutesSince(sessions, daysAgo(today, 6));
252
+ const last30Total = sumMinutesSince(sessions, daysAgo(today, 29));
253
+ const lastSession = getLastSession(sessions);
254
+ console.log(renderPanel("Pomodoro Status", [
255
+ `${label("Today")} ${style(formatMinutesDecimal(todayTotal), "green")}`,
256
+ `${label("7d avg")} ${style(`${formatMinutesDecimal(last7Total / 7)}/day`, "cyan")}`,
257
+ `${label("30d avg")} ${style(`${formatMinutesDecimal(last30Total / 30)}/day`, "cyan")}`,
258
+ `${label("Sessions")} ${style(String(sessions.length), "blue")}`,
259
+ `${label("Bark")} ${config.barkUrl ? style("configured", "green") : style("not configured", "gray")}`,
260
+ `${label("Last done")} ${lastSession ? formatSessionTime(lastSession.completedAt) : style("none yet", "gray")}`,
261
+ ]));
262
+ }
263
+ function sumMinutesSince(sessions, since) {
264
+ return sessions.reduce((total, session) => {
265
+ const completedAt = new Date(session.completedAt);
266
+ return completedAt >= since ? total + session.minutes : total;
267
+ }, 0);
268
+ }
269
+ function startOfDay(date) {
270
+ const next = new Date(date);
271
+ next.setHours(0, 0, 0, 0);
272
+ return next;
273
+ }
274
+ function daysAgo(date, days) {
275
+ const next = startOfDay(date);
276
+ next.setDate(next.getDate() - days);
277
+ return next;
278
+ }
279
+ function getLastSession(sessions) {
280
+ return sessions.reduce((latest, session) => {
281
+ if (!latest) {
282
+ return session;
283
+ }
284
+ return new Date(session.completedAt) > new Date(latest.completedAt)
285
+ ? session
286
+ : latest;
287
+ }, undefined);
288
+ }
289
+ function formatSessionTime(value) {
290
+ return new Date(value).toLocaleString([], {
291
+ hour12: false,
292
+ year: "numeric",
293
+ month: "2-digit",
294
+ day: "2-digit",
295
+ hour: "2-digit",
296
+ minute: "2-digit",
297
+ second: "2-digit",
298
+ });
299
+ }
300
+ function formatMinutes(minutes) {
301
+ return `${minutes} minute${minutes === 1 ? "" : "s"}`;
302
+ }
303
+ function formatMinutesDecimal(minutes) {
304
+ return `${minutes.toFixed(1)} min`;
305
+ }
306
+ function formatClockTime(date) {
307
+ return date.toLocaleTimeString([], {
308
+ hour12: false,
309
+ hour: "2-digit",
310
+ minute: "2-digit",
311
+ second: "2-digit",
312
+ });
313
+ }
314
+ function toIsoSeconds(date) {
315
+ return date.toISOString().replace(/\.\d{3}Z$/, "Z");
316
+ }
317
+ function label(text) {
318
+ return style(text.padEnd(9, " "), "gray");
319
+ }
320
+ function renderPanel(title, lines) {
321
+ const width = Math.max(title.length, ...lines.map(stripAnsi).map((line) => line.length));
322
+ const top = `+${"-".repeat(width + 2)}+`;
323
+ const header = `| ${style(title.padEnd(width, " "), "bold")} |`;
324
+ const body = lines.map((line) => `| ${padAnsi(line, width)} |`);
325
+ return [top, header, top, ...body, top].join("\n");
326
+ }
327
+ function padAnsi(value, width) {
328
+ const visible = stripAnsi(value).length;
329
+ return `${value}${" ".repeat(Math.max(0, width - visible))}`;
330
+ }
331
+ function stripAnsi(value) {
332
+ return value.replace(/\u001b\[[0-9;]*m/g, "");
333
+ }
334
+ function style(text, color) {
335
+ if (!COLOR_ENABLED) {
336
+ return text;
337
+ }
338
+ return `${ANSI[color]}${text}${ANSI.reset}`;
339
+ }
340
+ function maskBarkUrl(url) {
341
+ try {
342
+ const parsed = new URL(url);
343
+ const pathParts = parsed.pathname.split("/").filter(Boolean);
344
+ const key = pathParts[pathParts.length - 1] ?? "";
345
+ const maskedKey = key.length <= 6 ? "***" : `${key.slice(0, 3)}...${key.slice(-3)}`;
346
+ return `${parsed.origin}/${maskedKey}`;
347
+ }
348
+ catch {
349
+ return url;
350
+ }
351
+ }
352
+ main().catch((error) => {
353
+ console.error(error instanceof Error ? error.message : String(error));
354
+ process.exitCode = 1;
355
+ });
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "pom-tool",
3
+ "version": "0.1.0",
4
+ "description": "A small Node.js CLI for running Pomodoro timers in your terminal.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pom": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.json",
14
+ "dev": "node --loader ts-node/esm src/index.ts"
15
+ },
16
+ "keywords": [
17
+ "pomodoro",
18
+ "cli",
19
+ "timer",
20
+ "bark"
21
+ ],
22
+ "license": "MIT",
23
+ "author": "AlucPro",
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "https://github.com/AlucPro/pom-tool.git"
27
+ },
28
+ "homepage": "https://github.com/AlucPro/pom-tool",
29
+ "bugs": {
30
+ "url": "https://github.com/AlucPro/pom-tool/issues"
31
+ },
32
+ "packageManager": "pnpm@10.12.1",
33
+ "engines": {
34
+ "node": ">=18"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^24.0.0",
38
+ "ts-node": "^10.9.2",
39
+ "typescript": "^5.8.2"
40
+ }
41
+ }