pomotty 0.0.1 → 1.0.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 +67 -9
- package/assets/break-end.wav +0 -0
- package/assets/work-end.wav +0 -0
- package/dist/bin/pomotty.js +483 -0
- package/package.json +17 -13
- package/LICENSE +0 -21
- package/dist/cli.js +0 -65
package/README.md
CHANGED
|
@@ -1,19 +1,77 @@
|
|
|
1
1
|
# pomotty
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Node.js 22以降で利用できる、ターミナル用のポモドーロタイマーです。
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```shell
|
|
6
|
+
npx pomotty
|
|
7
|
+
```
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
グローバルにインストールする場合は、次のコマンドを実行します。
|
|
8
10
|
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
+
```shell
|
|
12
|
+
npm install --global pomotty
|
|
13
|
+
pomotty
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`pomotty`で作業開始の選択メニューを表示します。上下キーで`OK`を選んで
|
|
17
|
+
Enterを押すと15分の作業と5分の休憩を3セット実行し、`NG`を選ぶとタイマーを
|
|
18
|
+
開始せず終了します。
|
|
19
|
+
|
|
20
|
+
```shell
|
|
21
|
+
pomotty
|
|
11
22
|
```
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
作業時間と休憩時間は、1〜1440の整数(分)で指定できます。片方だけを指定した
|
|
25
|
+
場合、もう片方には既定値が使われます。
|
|
26
|
+
|
|
27
|
+
```shell
|
|
28
|
+
pomotty --work 30 --break 10
|
|
29
|
+
pomotty --work=45 --break=15
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--roop`で作業・休憩の繰り返し回数を指定できます。既定値は3で、1以上の整数
|
|
33
|
+
(最大9007199254740991)を受け付けます。開始確認は最初の1回だけで、最後の休憩が
|
|
34
|
+
完了すると終了します。
|
|
35
|
+
|
|
36
|
+
```shell
|
|
37
|
+
pomotty --roop 5
|
|
38
|
+
pomotty --work=25 --break=5 --roop=2
|
|
39
|
+
```
|
|
14
40
|
|
|
15
|
-
|
|
41
|
+
利用可能なオプションは、次のコマンドで確認できます。
|
|
16
42
|
|
|
17
|
-
|
|
43
|
+
```shell
|
|
44
|
+
pomotty --help
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
作業と休憩の完了時には、それぞれ異なる通知音を再生します。macOSでは
|
|
48
|
+
`afplay`、Linuxでは`paplay`または`aplay`、WindowsではPowerShellを利用し、
|
|
49
|
+
再生できない環境では異なる回数のターミナルベルに切り替わります。
|
|
50
|
+
|
|
51
|
+
## 開発
|
|
52
|
+
|
|
53
|
+
```shell
|
|
54
|
+
pnpm install --frozen-lockfile
|
|
55
|
+
pnpm start
|
|
56
|
+
pnpm test
|
|
57
|
+
pnpm lint
|
|
58
|
+
pnpm format:check
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## npmへの公開・更新
|
|
62
|
+
|
|
63
|
+
開発と公開にはpnpmを使用します。初回または認証が切れた場合は
|
|
64
|
+
`pnpm login --registry=https://registry.npmjs.org/`でnpmへログインします。
|
|
65
|
+
|
|
66
|
+
更新時は変更内容に応じて`pnpm version patch`、`pnpm version minor`、
|
|
67
|
+
`pnpm version major`で未公開のバージョンへ更新します。
|
|
68
|
+
これらのコマンドはGitコミットとタグも作成するため、先に変更をコミットしてください。
|
|
69
|
+
|
|
70
|
+
```shell
|
|
71
|
+
pnpm publish --dry-run --no-git-checks
|
|
72
|
+
pnpm publish
|
|
73
|
+
```
|
|
18
74
|
|
|
19
|
-
|
|
75
|
+
公開前にテスト・lint・フォーマット検証が実行され、パッケージ作成時にビルドされます。
|
|
76
|
+
配布物にはCLI、通知音、README、package.jsonが含まれます。
|
|
77
|
+
公開時にnpmから認証を求められたら、表示される案内に従って完了してください。
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import process$1 from "node:process";
|
|
3
|
+
import { setTimeout } from "node:timers/promises";
|
|
4
|
+
import { on } from "node:events";
|
|
5
|
+
import { emitKeypressEvents } from "node:readline";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
const MILLISECONDS_PER_MINUTE = 6e4;
|
|
9
|
+
const DEFAULT_BREAK_DURATION_MS = 5 * MILLISECONDS_PER_MINUTE;
|
|
10
|
+
const DEFAULT_WORK_DURATION_MS = 15 * MILLISECONDS_PER_MINUTE;
|
|
11
|
+
const waitFor = (durationMs) => setTimeout(durationMs);
|
|
12
|
+
const runPomodoroCycle = async ({ workDurationMs = DEFAULT_WORK_DURATION_MS, breakDurationMs = DEFAULT_BREAK_DURATION_MS, wait = waitFor, onPhaseStarted, onPhaseCompleted } = {}) => {
|
|
13
|
+
if (onPhaseStarted) onPhaseStarted("work", workDurationMs);
|
|
14
|
+
await wait(workDurationMs);
|
|
15
|
+
if (onPhaseCompleted) onPhaseCompleted("work");
|
|
16
|
+
if (onPhaseStarted) onPhaseStarted("break", breakDurationMs);
|
|
17
|
+
await wait(breakDurationMs);
|
|
18
|
+
if (onPhaseCompleted) onPhaseCompleted("break");
|
|
19
|
+
};
|
|
20
|
+
const OPTIONS = [
|
|
21
|
+
{
|
|
22
|
+
description: `Work duration in minutes (1-1440, default: 15)`,
|
|
23
|
+
name: "--work",
|
|
24
|
+
valueName: "<minutes>"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
description: `Break duration in minutes (1-1440, default: 5)`,
|
|
28
|
+
name: "--break",
|
|
29
|
+
valueName: "<minutes>"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
description: `Work-break repetitions (positive integer, default: 3)`,
|
|
33
|
+
name: "--roop",
|
|
34
|
+
valueName: "<count>"
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
alias: "-h",
|
|
38
|
+
description: "Print help",
|
|
39
|
+
name: "--help"
|
|
40
|
+
}
|
|
41
|
+
];
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/terminal/input.ts
|
|
44
|
+
const CHOICES = ["OK", "NG"];
|
|
45
|
+
const CONTROL_C_KEY_NAME = "c";
|
|
46
|
+
const ENTER_KEY_NAMES = /* @__PURE__ */ new Set(["enter", "return"]);
|
|
47
|
+
const FIRST_CHOICE_INDEX = 0;
|
|
48
|
+
const KEYPRESS_EVENT_KEY_INDEX = 1;
|
|
49
|
+
const MOVE_KEY_NAMES = /* @__PURE__ */ new Set(["down", "up"]);
|
|
50
|
+
const NEXT_INDEX_OFFSET = 1;
|
|
51
|
+
const REDRAW_SEQUENCE = `\r\u001B[${CHOICES.length}A\u001B[J`;
|
|
52
|
+
const SELECTION_QUESTION = "Start working? (Use ↑/↓ to select, Enter to confirm)";
|
|
53
|
+
const keyFrom = (value) => {
|
|
54
|
+
let key = {}, normalizedName = "";
|
|
55
|
+
if (typeof value === "object" && value !== null) key = value;
|
|
56
|
+
const ctrl = Reflect.get(key, "ctrl"), name = Reflect.get(key, "name");
|
|
57
|
+
if (typeof name === "string") normalizedName = name;
|
|
58
|
+
return {
|
|
59
|
+
ctrl: ctrl === true,
|
|
60
|
+
name: normalizedName
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
const createSelectionTerminal = () => {
|
|
64
|
+
emitKeypressEvents(process$1.stdin);
|
|
65
|
+
const inputWasFlowing = process$1.stdin.readableFlowing === true, keypressEvents = on(process$1.stdin, "keypress"), rawModeWasEnabled = process$1.stdin.isRaw === true, supportsRawMode = process$1.stdin.isTTY === true;
|
|
66
|
+
let restored = false;
|
|
67
|
+
if (supportsRawMode && !rawModeWasEnabled) process$1.stdin.setRawMode(true);
|
|
68
|
+
if (!inputWasFlowing) process$1.stdin.resume();
|
|
69
|
+
return {
|
|
70
|
+
nextKey: async () => {
|
|
71
|
+
const event = await keypressEvents.next();
|
|
72
|
+
if (event.done) return {
|
|
73
|
+
ctrl: true,
|
|
74
|
+
name: CONTROL_C_KEY_NAME
|
|
75
|
+
};
|
|
76
|
+
return keyFrom(event.value.at(KEYPRESS_EVENT_KEY_INDEX));
|
|
77
|
+
},
|
|
78
|
+
restore: async () => {
|
|
79
|
+
if (restored) return;
|
|
80
|
+
restored = true;
|
|
81
|
+
const stopListening = keypressEvents.return;
|
|
82
|
+
if (stopListening) await stopListening.call(keypressEvents);
|
|
83
|
+
if (supportsRawMode && !rawModeWasEnabled) process$1.stdin.setRawMode(false);
|
|
84
|
+
if (!inputWasFlowing) process$1.stdin.pause();
|
|
85
|
+
},
|
|
86
|
+
write: (output) => {
|
|
87
|
+
process$1.stdout.write(output);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
const markerFor = (selected) => {
|
|
92
|
+
if (selected) return "❯";
|
|
93
|
+
return " ";
|
|
94
|
+
};
|
|
95
|
+
const renderSelection = (selectedIndex) => [SELECTION_QUESTION, ...CHOICES.map((choice, index) => `${markerFor(index === selectedIndex)} ${choice}`)].join("\n");
|
|
96
|
+
const nextChoiceIndex = (selectedIndex) => {
|
|
97
|
+
if (selectedIndex === FIRST_CHOICE_INDEX) return CHOICES.length - NEXT_INDEX_OFFSET;
|
|
98
|
+
return FIRST_CHOICE_INDEX;
|
|
99
|
+
};
|
|
100
|
+
const handleKey = (key, selectedIndex, terminal) => {
|
|
101
|
+
if (key.ctrl && key.name === CONTROL_C_KEY_NAME) {
|
|
102
|
+
terminal.write("\n");
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (MOVE_KEY_NAMES.has(key.name)) {
|
|
106
|
+
const newSelectedIndex = nextChoiceIndex(selectedIndex);
|
|
107
|
+
terminal.write(`${REDRAW_SEQUENCE}${renderSelection(newSelectedIndex)}`);
|
|
108
|
+
return newSelectedIndex;
|
|
109
|
+
}
|
|
110
|
+
if (ENTER_KEY_NAMES.has(key.name)) {
|
|
111
|
+
terminal.write("\n");
|
|
112
|
+
return CHOICES[selectedIndex] === "OK";
|
|
113
|
+
}
|
|
114
|
+
return selectedIndex;
|
|
115
|
+
};
|
|
116
|
+
const confirmWorkStart = async ({ createTerminal = createSelectionTerminal } = {}) => {
|
|
117
|
+
const terminal = createTerminal();
|
|
118
|
+
let selectedIndex = FIRST_CHOICE_INDEX;
|
|
119
|
+
terminal.write(renderSelection(selectedIndex));
|
|
120
|
+
try {
|
|
121
|
+
for (;;) {
|
|
122
|
+
const key = await terminal.nextKey(), selection = handleKey(key, selectedIndex, terminal);
|
|
123
|
+
if (typeof selection === "boolean") return selection;
|
|
124
|
+
selectedIndex = selection;
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
await terminal.restore();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
//#endregion
|
|
131
|
+
//#region src/terminal/constants.ts
|
|
132
|
+
/**
|
|
133
|
+
* CLI起動時に表示するアスキーアートロゴです。
|
|
134
|
+
*/
|
|
135
|
+
const LOGO = `
|
|
136
|
+
██████╗ ██████╗ ███╗ ███╗ ██████╗ ████████╗████████╗██╗ ██╗
|
|
137
|
+
██╔══██╗██╔═══██╗████╗ ████║██╔═══██╗╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝
|
|
138
|
+
██████╔╝██║ ██║██╔████╔██║██║ ██║ ██║ ██║ ╚████╔╝
|
|
139
|
+
██╔═══╝ ██║ ██║██║╚██╔╝██║██║ ██║ ██║ ██║ ╚██╔╝
|
|
140
|
+
██║ ╚██████╔╝██║ ╚═╝ ██║╚██████╔╝ ██║ ██║ ██║
|
|
141
|
+
╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
|
|
142
|
+
`;
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/terminal/render.ts
|
|
145
|
+
/**
|
|
146
|
+
* CLIへ渡されたオプションに応じた出力を生成します。
|
|
147
|
+
*
|
|
148
|
+
* オプションの名前または短縮形と一致した場合はヘルプを生成し、
|
|
149
|
+
* 一致しない場合は通常起動時のロゴを返します。
|
|
150
|
+
*
|
|
151
|
+
* @param parameters CLI出力の生成に必要な値。
|
|
152
|
+
* @returns 標準出力へ書き込む文字列。
|
|
153
|
+
*/
|
|
154
|
+
const CLI_OPTION_INDEX = 2;
|
|
155
|
+
const createCliOutput = ({ options, optionName = process.argv.at(CLI_OPTION_INDEX) }) => {
|
|
156
|
+
if (!options.find(({ alias, name }) => alias === optionName || name === optionName)) return `${LOGO}\n`;
|
|
157
|
+
return [
|
|
158
|
+
"Pomotty CLI",
|
|
159
|
+
"",
|
|
160
|
+
"Usage: pomotty [OPTIONS]",
|
|
161
|
+
"",
|
|
162
|
+
"Options:",
|
|
163
|
+
...options.flatMap(({ alias, description, name, valueName }) => {
|
|
164
|
+
return [` ${[alias, [name, valueName].filter(Boolean).join(" ")].filter(Boolean).join(", ")}`, ` ${description}`];
|
|
165
|
+
}),
|
|
166
|
+
""
|
|
167
|
+
].join("\n");
|
|
168
|
+
};
|
|
169
|
+
//#endregion
|
|
170
|
+
//#region src/diagnostics/writer.ts
|
|
171
|
+
/**
|
|
172
|
+
* 診断の出力先が閉じていても失敗しない書き込み処理を生成します。
|
|
173
|
+
*
|
|
174
|
+
* 最初の書き込みより前に`error`リスナーを登録し、閉じたパイプなどで
|
|
175
|
+
* 発生する非同期エラーを未処理の例外にしません。同期例外も握りつぶし、
|
|
176
|
+
* 一度でも書き込みに失敗したら以後の診断を出力しません。
|
|
177
|
+
* これにより出力先の状態が終了コードへ影響しなくなります。
|
|
178
|
+
*
|
|
179
|
+
* @param stream 診断の出力先。
|
|
180
|
+
* @returns 出力先の状態にかかわらず例外を投げない書き込み処理。
|
|
181
|
+
*/
|
|
182
|
+
const createDiagnosticWriter = (stream = process$1.stderr) => {
|
|
183
|
+
let unavailable = false;
|
|
184
|
+
stream.on("error", () => {
|
|
185
|
+
unavailable = true;
|
|
186
|
+
});
|
|
187
|
+
return (output) => {
|
|
188
|
+
if (unavailable) return;
|
|
189
|
+
try {
|
|
190
|
+
stream.write(output);
|
|
191
|
+
} catch {
|
|
192
|
+
unavailable = true;
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
//#endregion
|
|
197
|
+
//#region src/diagnostics/escape.ts
|
|
198
|
+
const BACKSLASH = "\\";
|
|
199
|
+
const BIDIRECTIONAL_PATTERN = /[\u061C\u200B\u200E\u200F\u2028-\u202E\u2066-\u206F\uFEFF]/u;
|
|
200
|
+
const CONTROL_HEX_LENGTH = 2;
|
|
201
|
+
const CONTROL_PATTERN = /[\u0000-\u001F\u007F-\u009F]/u;
|
|
202
|
+
const EMPTY_CODE_POINT = 0;
|
|
203
|
+
const FIRST_INDEX = 0;
|
|
204
|
+
const HEXADECIMAL_RADIX = 16;
|
|
205
|
+
const HEX_PAD_CHARACTER = "0";
|
|
206
|
+
const MAXIMUM_CODE_POINTS = 160;
|
|
207
|
+
const QUOTATION_MARK = "\"";
|
|
208
|
+
const SURROGATE_HEX_LENGTH = 4;
|
|
209
|
+
const SURROGATE_PATTERN = /[\uD800-\uDFFF]/u;
|
|
210
|
+
const TRUNCATION_MARK = "…";
|
|
211
|
+
const UNPADDED_HEX_LENGTH = 0;
|
|
212
|
+
const characterToHexadecimal = (character, length) => (character.codePointAt(FIRST_INDEX) ?? EMPTY_CODE_POINT).toString(HEXADECIMAL_RADIX).toUpperCase().padStart(length, HEX_PAD_CHARACTER);
|
|
213
|
+
const escapeUnsafeCharacter = (character) => {
|
|
214
|
+
if (CONTROL_PATTERN.test(character)) return String.raw`\x${characterToHexadecimal(character, CONTROL_HEX_LENGTH)}`;
|
|
215
|
+
if (BIDIRECTIONAL_PATTERN.test(character)) return String.raw`\u{${characterToHexadecimal(character, UNPADDED_HEX_LENGTH)}}`;
|
|
216
|
+
if (SURROGATE_PATTERN.test(character)) return String.raw`\u${characterToHexadecimal(character, SURROGATE_HEX_LENGTH)}`;
|
|
217
|
+
return character;
|
|
218
|
+
};
|
|
219
|
+
const escapeCharacter = (character) => {
|
|
220
|
+
if (character === BACKSLASH) return String.raw`\\`;
|
|
221
|
+
if (character === QUOTATION_MARK) return String.raw`\"`;
|
|
222
|
+
return escapeUnsafeCharacter(character);
|
|
223
|
+
};
|
|
224
|
+
const escapeDiagnostic = (value) => {
|
|
225
|
+
const codePoints = [...value], escaped = codePoints.slice(FIRST_INDEX, MAXIMUM_CODE_POINTS).map((character) => escapeCharacter(character)).join("");
|
|
226
|
+
if (codePoints.length > MAXIMUM_CODE_POINTS) return `${QUOTATION_MARK}${escaped}${TRUNCATION_MARK}${QUOTATION_MARK}`;
|
|
227
|
+
return `${QUOTATION_MARK}${escaped}${QUOTATION_MARK}`;
|
|
228
|
+
};
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/cli/parse-arguments.ts
|
|
231
|
+
const ARGUMENT_INDEX_INCREMENT = 1;
|
|
232
|
+
const ARGUMENTS_START_INDEX = 0;
|
|
233
|
+
const DURATION_PATTERN = /^[1-9][0-9]{0,3}$/u;
|
|
234
|
+
const EQUALS_SIGN = "=";
|
|
235
|
+
const EQUALS_SIGN_NOT_FOUND = -1;
|
|
236
|
+
const HELP_OPTIONS = ["--help", "-h"];
|
|
237
|
+
const MAXIMUM_DURATION_MINUTES = 1440;
|
|
238
|
+
const NEXT_ARGUMENT_OFFSET = 1;
|
|
239
|
+
const ROOP_PATTERN = /^[1-9][0-9]*$/u;
|
|
240
|
+
const isNumericOption = (value) => value === "--break" || value === "--work" || value === "--roop";
|
|
241
|
+
const isHelpOption = (value) => HELP_OPTIONS.some((option) => option === value);
|
|
242
|
+
const createValueError = (option) => {
|
|
243
|
+
let maximum = MAXIMUM_DURATION_MINUTES;
|
|
244
|
+
if (option === "--roop") maximum = Number.MAX_SAFE_INTEGER;
|
|
245
|
+
return {
|
|
246
|
+
kind: "error",
|
|
247
|
+
message: `Error: ${option} requires an integer from 1 to ${maximum}.\n`
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
const createDuplicateError = (option) => ({
|
|
251
|
+
kind: "error",
|
|
252
|
+
message: `Error: ${option} may only be specified once.\n`
|
|
253
|
+
});
|
|
254
|
+
const parseNumericValue = (option, value) => {
|
|
255
|
+
let pattern = DURATION_PATTERN, maximum = MAXIMUM_DURATION_MINUTES;
|
|
256
|
+
if (option === "--roop") {
|
|
257
|
+
pattern = ROOP_PATTERN;
|
|
258
|
+
maximum = Number.MAX_SAFE_INTEGER;
|
|
259
|
+
}
|
|
260
|
+
if (typeof value !== "string" || !pattern.test(value)) return;
|
|
261
|
+
const numericValue = Number(value);
|
|
262
|
+
if (!Number.isSafeInteger(numericValue) || numericValue > maximum) return;
|
|
263
|
+
return numericValue;
|
|
264
|
+
};
|
|
265
|
+
const parseNumericArgument = (argument, nextArgument) => {
|
|
266
|
+
const equalsSignIndex = argument.indexOf(EQUALS_SIGN);
|
|
267
|
+
if (equalsSignIndex === EQUALS_SIGN_NOT_FOUND) return {
|
|
268
|
+
consumesNextArgument: true,
|
|
269
|
+
optionName: argument,
|
|
270
|
+
value: nextArgument
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
consumesNextArgument: false,
|
|
274
|
+
optionName: argument.slice(ARGUMENTS_START_INDEX, equalsSignIndex),
|
|
275
|
+
value: argument.slice(equalsSignIndex + NEXT_ARGUMENT_OFFSET)
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
const parseCliArguments = (arguments_) => {
|
|
279
|
+
if (arguments_.some((argument) => isHelpOption(argument))) return { kind: "help" };
|
|
280
|
+
const specifiedOptions = /* @__PURE__ */ new Set();
|
|
281
|
+
let breakDurationMinutes = 5, roopCount = 3, workDurationMinutes = 15;
|
|
282
|
+
for (let argumentIndex = ARGUMENTS_START_INDEX; argumentIndex < arguments_.length; argumentIndex += ARGUMENT_INDEX_INCREMENT) {
|
|
283
|
+
const argument = arguments_[argumentIndex] ?? "", { consumesNextArgument, optionName, value } = parseNumericArgument(argument, arguments_[argumentIndex + NEXT_ARGUMENT_OFFSET]);
|
|
284
|
+
if (!isNumericOption(optionName)) return {
|
|
285
|
+
kind: "error",
|
|
286
|
+
message: `Error: Unknown option: ${escapeDiagnostic(argument)}\n`
|
|
287
|
+
};
|
|
288
|
+
if (specifiedOptions.has(optionName)) return createDuplicateError(optionName);
|
|
289
|
+
const numericValue = parseNumericValue(optionName, value);
|
|
290
|
+
if (typeof numericValue !== "number") return createValueError(optionName);
|
|
291
|
+
specifiedOptions.add(optionName);
|
|
292
|
+
if (consumesNextArgument) argumentIndex += NEXT_ARGUMENT_OFFSET;
|
|
293
|
+
if (optionName === "--work") workDurationMinutes = numericValue;
|
|
294
|
+
else if (optionName === "--roop") roopCount = numericValue;
|
|
295
|
+
else breakDurationMinutes = numericValue;
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
breakDurationMinutes,
|
|
299
|
+
kind: "run",
|
|
300
|
+
roopCount,
|
|
301
|
+
workDurationMinutes
|
|
302
|
+
};
|
|
303
|
+
};
|
|
304
|
+
//#endregion
|
|
305
|
+
//#region src/notification/sound.ts
|
|
306
|
+
const FIRST_COMMAND_INDEX = 0;
|
|
307
|
+
const NEXT_COMMAND_OFFSET = 1;
|
|
308
|
+
const SUCCESS_EXIT_CODE$1 = 0;
|
|
309
|
+
const FALLBACK_SOUNDS = {
|
|
310
|
+
break: "\x07\x07",
|
|
311
|
+
work: "\x07"
|
|
312
|
+
};
|
|
313
|
+
const WINDOWS_SOUND_SCRIPT = [
|
|
314
|
+
"$player = New-Object System.Media.SoundPlayer",
|
|
315
|
+
"$player.SoundLocation = $env:POMOTTY_SOUND_FILE",
|
|
316
|
+
"$player.PlaySync()"
|
|
317
|
+
].join("; ");
|
|
318
|
+
const spawnSoundProcess = (command, commandArguments, options) => {
|
|
319
|
+
const child = spawn(command, [...commandArguments], options);
|
|
320
|
+
return {
|
|
321
|
+
onClose: (listener) => {
|
|
322
|
+
child.once("close", listener);
|
|
323
|
+
},
|
|
324
|
+
onError: (listener) => {
|
|
325
|
+
child.once("error", listener);
|
|
326
|
+
}
|
|
327
|
+
};
|
|
328
|
+
};
|
|
329
|
+
const soundFileFor = (phase) => fileURLToPath(new URL(`../../assets/${phase}-end.wav`, import.meta.url));
|
|
330
|
+
const soundEnvironmentFor = (soundFile) => {
|
|
331
|
+
const environment = structuredClone(process$1.env);
|
|
332
|
+
environment.POMOTTY_SOUND_FILE = soundFile;
|
|
333
|
+
return environment;
|
|
334
|
+
};
|
|
335
|
+
const commandsFor = (platform, soundFile) => {
|
|
336
|
+
switch (platform) {
|
|
337
|
+
case "darwin": return [{
|
|
338
|
+
command: "/usr/bin/afplay",
|
|
339
|
+
commandArguments: [soundFile]
|
|
340
|
+
}];
|
|
341
|
+
case "linux": return [{
|
|
342
|
+
command: "paplay",
|
|
343
|
+
commandArguments: [soundFile]
|
|
344
|
+
}, {
|
|
345
|
+
command: "aplay",
|
|
346
|
+
commandArguments: [soundFile]
|
|
347
|
+
}];
|
|
348
|
+
case "win32": return [{
|
|
349
|
+
command: "powershell.exe",
|
|
350
|
+
commandArguments: [
|
|
351
|
+
"-NoProfile",
|
|
352
|
+
"-NonInteractive",
|
|
353
|
+
"-Command",
|
|
354
|
+
WINDOWS_SOUND_SCRIPT
|
|
355
|
+
],
|
|
356
|
+
env: soundEnvironmentFor(soundFile)
|
|
357
|
+
}];
|
|
358
|
+
default: return [];
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
const observeFailure = (child, onFailure) => {
|
|
362
|
+
let settled = false;
|
|
363
|
+
const failOnce = () => {
|
|
364
|
+
if (settled) return;
|
|
365
|
+
settled = true;
|
|
366
|
+
onFailure();
|
|
367
|
+
};
|
|
368
|
+
child.onError(failOnce);
|
|
369
|
+
child.onClose((code) => {
|
|
370
|
+
if (code !== SUCCESS_EXIT_CODE$1) failOnce();
|
|
371
|
+
});
|
|
372
|
+
};
|
|
373
|
+
const runSoundCommands = ({ commands, fallback, index, spawnProcess }) => {
|
|
374
|
+
const soundCommand = commands.at(index), tryNextCommand = () => {
|
|
375
|
+
runSoundCommands({
|
|
376
|
+
commands,
|
|
377
|
+
fallback,
|
|
378
|
+
index: index + NEXT_COMMAND_OFFSET,
|
|
379
|
+
spawnProcess
|
|
380
|
+
});
|
|
381
|
+
};
|
|
382
|
+
if (!soundCommand) {
|
|
383
|
+
fallback();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
try {
|
|
387
|
+
const child = spawnProcess(soundCommand.command, soundCommand.commandArguments, {
|
|
388
|
+
env: soundCommand.env,
|
|
389
|
+
stdio: "ignore",
|
|
390
|
+
windowsHide: true
|
|
391
|
+
});
|
|
392
|
+
observeFailure(child, tryNextCommand);
|
|
393
|
+
} catch {
|
|
394
|
+
tryNextCommand();
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
const playCompletionSound = (phase, { platform = process$1.platform, spawnProcess = spawnSoundProcess, writeFallback = (output) => {
|
|
398
|
+
process$1.stderr.write(output);
|
|
399
|
+
} } = {}) => {
|
|
400
|
+
const commands = commandsFor(platform, soundFileFor(phase)), fallback = () => {
|
|
401
|
+
writeFallback(FALLBACK_SOUNDS[phase]);
|
|
402
|
+
};
|
|
403
|
+
runSoundCommands({
|
|
404
|
+
commands,
|
|
405
|
+
fallback,
|
|
406
|
+
index: FIRST_COMMAND_INDEX,
|
|
407
|
+
spawnProcess
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
//#endregion
|
|
411
|
+
//#region src/cli/run.ts
|
|
412
|
+
const CLI_ARGUMENTS_START_INDEX = 2;
|
|
413
|
+
const COUNT_INCREMENT = 1;
|
|
414
|
+
const PHASE_ICONS = {
|
|
415
|
+
break: "☕",
|
|
416
|
+
work: "🍅"
|
|
417
|
+
};
|
|
418
|
+
const PHASE_NAMES = {
|
|
419
|
+
break: "Break",
|
|
420
|
+
work: "Work"
|
|
421
|
+
};
|
|
422
|
+
const SUCCESS_EXIT_CODE = 0;
|
|
423
|
+
const USAGE_ERROR_EXIT_CODE = 2;
|
|
424
|
+
const createPhaseStartedOutput = (phase, durationMs) => {
|
|
425
|
+
const durationMinutes = durationMs / MILLISECONDS_PER_MINUTE;
|
|
426
|
+
return `${PHASE_ICONS[phase]} ${PHASE_NAMES[phase]} started (${durationMinutes} min)\n`;
|
|
427
|
+
};
|
|
428
|
+
const emitDiagnostic = (writeError, message) => {
|
|
429
|
+
try {
|
|
430
|
+
writeError(message);
|
|
431
|
+
} catch {}
|
|
432
|
+
};
|
|
433
|
+
const emitStandardOutput = (output) => {
|
|
434
|
+
process$1.stdout.write(output);
|
|
435
|
+
};
|
|
436
|
+
const runCli = async ({ arguments_ = process$1.argv.slice(CLI_ARGUMENTS_START_INDEX), confirmStart = confirmWorkStart, playSound = playCompletionSound, wait, writeError = createDiagnosticWriter(), writeOutput = emitStandardOutput } = {}) => {
|
|
437
|
+
const parsedArguments = parseCliArguments(arguments_);
|
|
438
|
+
if (parsedArguments.kind === "error") {
|
|
439
|
+
emitDiagnostic(writeError, parsedArguments.message);
|
|
440
|
+
return USAGE_ERROR_EXIT_CODE;
|
|
441
|
+
}
|
|
442
|
+
if (parsedArguments.kind === "help") {
|
|
443
|
+
writeOutput(createCliOutput({
|
|
444
|
+
optionName: "--help",
|
|
445
|
+
options: OPTIONS
|
|
446
|
+
}));
|
|
447
|
+
return SUCCESS_EXIT_CODE;
|
|
448
|
+
}
|
|
449
|
+
writeOutput(createCliOutput({
|
|
450
|
+
optionName: "",
|
|
451
|
+
options: OPTIONS
|
|
452
|
+
}));
|
|
453
|
+
if (!await confirmStart()) {
|
|
454
|
+
writeOutput("⏹️ Work was not started.\n");
|
|
455
|
+
return SUCCESS_EXIT_CODE;
|
|
456
|
+
}
|
|
457
|
+
for (let cycleIndex = 0; cycleIndex < parsedArguments.roopCount; cycleIndex += COUNT_INCREMENT) await runPomodoroCycle({
|
|
458
|
+
breakDurationMs: parsedArguments.breakDurationMinutes * MILLISECONDS_PER_MINUTE,
|
|
459
|
+
onPhaseCompleted: (phase) => {
|
|
460
|
+
writeOutput(`✅ ${PHASE_NAMES[phase]} complete.\n`);
|
|
461
|
+
playSound(phase);
|
|
462
|
+
},
|
|
463
|
+
onPhaseStarted: (phase, durationMs) => writeOutput(createPhaseStartedOutput(phase, durationMs)),
|
|
464
|
+
wait,
|
|
465
|
+
workDurationMs: parsedArguments.workDurationMinutes * MILLISECONDS_PER_MINUTE
|
|
466
|
+
});
|
|
467
|
+
writeOutput("🎉 Pomodoro complete.\n");
|
|
468
|
+
return SUCCESS_EXIT_CODE;
|
|
469
|
+
};
|
|
470
|
+
//#endregion
|
|
471
|
+
//#region src/cli.ts
|
|
472
|
+
const FAILURE_EXIT_CODE = 1;
|
|
473
|
+
const handleFailure = (error) => {
|
|
474
|
+
process$1.exitCode = FAILURE_EXIT_CODE;
|
|
475
|
+
process$1.stderr.write(`${String(error)}\n`);
|
|
476
|
+
};
|
|
477
|
+
try {
|
|
478
|
+
process$1.exitCode = await runCli();
|
|
479
|
+
} catch (error) {
|
|
480
|
+
handleFailure(error);
|
|
481
|
+
}
|
|
482
|
+
//#endregion
|
|
483
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pomotty",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An interactive terminal Pomodoro timer with configurable work and break sessions and sound notifications.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"pomodoro",
|
|
@@ -9,25 +9,26 @@
|
|
|
9
9
|
"timer"
|
|
10
10
|
],
|
|
11
11
|
"homepage": "https://github.com/iririn1998/pomotty#readme",
|
|
12
|
-
"bugs":
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/iririn1998/pomotty/issues"
|
|
14
|
+
},
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"author": "",
|
|
13
17
|
"repository": {
|
|
14
18
|
"type": "git",
|
|
15
19
|
"url": "git+https://github.com/iririn1998/pomotty.git"
|
|
16
20
|
},
|
|
17
|
-
"license": "MIT",
|
|
18
|
-
"author": "iririn1998",
|
|
19
|
-
"type": "module",
|
|
20
21
|
"bin": {
|
|
21
|
-
"pomotty": "dist/
|
|
22
|
+
"pomotty": "dist/bin/pomotty.js"
|
|
22
23
|
},
|
|
23
24
|
"files": [
|
|
24
|
-
"dist"
|
|
25
|
+
"dist/bin/pomotty.js",
|
|
26
|
+
"assets/*.wav"
|
|
25
27
|
],
|
|
26
|
-
"
|
|
27
|
-
"node": "^22.18.0 || >=24.11.0"
|
|
28
|
-
},
|
|
28
|
+
"type": "module",
|
|
29
29
|
"publishConfig": {
|
|
30
|
-
"access": "public"
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
31
32
|
},
|
|
32
33
|
"devDependencies": {
|
|
33
34
|
"@types/node": "^26.2.0",
|
|
@@ -43,9 +44,12 @@
|
|
|
43
44
|
"onFail": "error"
|
|
44
45
|
}
|
|
45
46
|
},
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=22"
|
|
49
|
+
},
|
|
46
50
|
"scripts": {
|
|
47
51
|
"build": "rolldown -c rolldown.config.mjs",
|
|
48
|
-
"start": "pnpm build && node dist/
|
|
52
|
+
"start": "pnpm build && node dist/bin/pomotty.js",
|
|
49
53
|
"lint": "oxlint . --deny-warnings --report-unused-disable-directives-severity=error",
|
|
50
54
|
"lint:fix": "oxlint . --fix --deny-warnings --report-unused-disable-directives-severity=error",
|
|
51
55
|
"format": "oxfmt --write",
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 iririn1998
|
|
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/dist/cli.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import process$1 from "node:process";
|
|
3
|
-
//#region src/constants/options.ts
|
|
4
|
-
/**
|
|
5
|
-
* CLIで現在案内しているオプションの一覧
|
|
6
|
-
*/
|
|
7
|
-
const OPTIONS = [{
|
|
8
|
-
alias: "-h",
|
|
9
|
-
description: "Print help",
|
|
10
|
-
name: "--help"
|
|
11
|
-
}];
|
|
12
|
-
//#endregion
|
|
13
|
-
//#region src/constants/logo.ts
|
|
14
|
-
/**
|
|
15
|
-
* CLI起動時に表示するアスキーアートロゴです。
|
|
16
|
-
*/
|
|
17
|
-
const LOGO = `
|
|
18
|
-
██████╗ ██████╗ ███╗ ███╗ ██████╗ ████████╗████████╗██╗ ██╗
|
|
19
|
-
██╔══██╗██╔═══██╗████╗ ████║██╔═══██╗╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝
|
|
20
|
-
██████╔╝██║ ██║██╔████╔██║██║ ██║ ██║ ██║ ╚████╔╝
|
|
21
|
-
██╔═══╝ ██║ ██║██║╚██╔╝██║██║ ██║ ██║ ██║ ╚██╔╝
|
|
22
|
-
██║ ╚██████╔╝██║ ╚═╝ ██║╚██████╔╝ ██║ ██║ ██║
|
|
23
|
-
╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝
|
|
24
|
-
`;
|
|
25
|
-
//#endregion
|
|
26
|
-
//#region src/cli/create-output.ts
|
|
27
|
-
/**
|
|
28
|
-
* CLIへ渡されたオプションに応じた出力を生成します。
|
|
29
|
-
*
|
|
30
|
-
* オプションの名前または短縮形と一致した場合はヘルプを生成し、
|
|
31
|
-
* 一致しない場合は通常起動時のロゴを返します。
|
|
32
|
-
*
|
|
33
|
-
* @param parameters CLI出力の生成に必要な値。
|
|
34
|
-
* @returns 標準出力へ書き込む文字列。
|
|
35
|
-
*/
|
|
36
|
-
const createCliOutput = ({ options }) => {
|
|
37
|
-
const CLI_OPTION_INDEX = 2;
|
|
38
|
-
if (!options.find(({ alias, name }) => {
|
|
39
|
-
const optionName = process.argv.at(CLI_OPTION_INDEX);
|
|
40
|
-
return typeof optionName === "string" && (alias === optionName || name === optionName);
|
|
41
|
-
})) return `${LOGO}\n`;
|
|
42
|
-
return [
|
|
43
|
-
"Pomotty CLI",
|
|
44
|
-
"",
|
|
45
|
-
"Usage: pomotty [OPTIONS]",
|
|
46
|
-
"",
|
|
47
|
-
"Options:",
|
|
48
|
-
...options.flatMap(({ alias, description, name }) => {
|
|
49
|
-
return [` ${[alias, name].filter(Boolean).join(", ")}`, ` ${description}`];
|
|
50
|
-
}),
|
|
51
|
-
""
|
|
52
|
-
].join("\n");
|
|
53
|
-
};
|
|
54
|
-
//#endregion
|
|
55
|
-
//#region src/cli/run.ts
|
|
56
|
-
const runCli = (writeOutput = (output) => {
|
|
57
|
-
process$1.stdout.write(output);
|
|
58
|
-
}) => {
|
|
59
|
-
writeOutput(createCliOutput({ options: OPTIONS }));
|
|
60
|
-
};
|
|
61
|
-
//#endregion
|
|
62
|
-
//#region src/cli.ts
|
|
63
|
-
runCli();
|
|
64
|
-
//#endregion
|
|
65
|
-
export {};
|