cron-human 1.0.1 → 1.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Akin Ibitoye
3
+ Copyright (c) 2026 Akin
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,282 +1,160 @@
1
1
  # cron-human
2
2
 
3
- > A command-line tool that converts cron expressions into human-readable English and calculates the next scheduled run times.
3
+ > Convert cron expressions into human-readable English and see when they'll run next.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/cron-human.svg)](https://www.npmjs.com/package/cron-human)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
- ## What is this?
9
-
10
- If you've ever looked at a cron expression like `0 12 * * MON-FRI` and wondered "when does this actually run?", this tool is for you. It translates cryptic cron syntax into plain English and shows you exactly when the job will execute next.
11
-
12
- Perfect for:
13
- - 🔍 **Debugging cron jobs** - Verify your cron expressions do what you expect
14
- - � **Understanding existing schedules** - Decode cron expressions in config files
15
- - ✅ **Validating syntax** - Catch errors before deploying to production
16
- - 🌐 **Timezone conversions** - See execution times in different timezones
17
-
18
- ## Features
19
-
20
- - �🗣 **Human readable** - Converts `*/5 * * * *` to "Every 5 minutes"
21
- - 📅 **Next run times** - Calculates exact dates for upcoming executions
22
- - 🌍 **Timezone aware** - Supports IANA timezone identifiers (e.g., `America/New_York`, `Asia/Tokyo`)
23
- - 🤖 **JSON output** - Machine-readable format for scripting and automation
24
- - ✅ **Strict validation** - Catches invalid expressions and provides clear error messages
25
- - ⏱️ **Seconds support** - Handle 6-field cron expressions with `--seconds` flag
26
- - 🏷️ **Macro shortcuts** - Supports `@daily`, `@hourly`, `@weekly`, etc.
27
-
28
- ## Installation
29
-
30
- ### One-time use with npx
31
-
32
- No installation needed - run directly with npx:
8
+ ## Quick Start
33
9
 
34
10
  ```bash
35
11
  npx cron-human "*/5 * * * *"
36
12
  ```
37
13
 
38
- ### Global installation
39
-
40
- Install once, use anywhere:
41
-
42
- ```bash
43
- npm install -g cron-human
14
+ **Output:**
44
15
  ```
16
+ Every 5 minutes
45
17
 
46
- Then use it directly:
47
-
48
- ```bash
49
- cron-human "0 12 * * MON-FRI"
18
+ Next 5 runs:
19
+ - 2026-01-22 02:40:00
20
+ - 2026-01-22 02:45:00
21
+ - 2026-01-22 02:50:00
22
+ - 2026-01-22 02:55:00
23
+ - 2026-01-22 03:00:00
50
24
  ```
51
25
 
52
- ## Usage
53
-
54
- ### Basic Usage
26
+ ## Installation
55
27
 
56
28
  ```bash
57
- cron-human "<cron-expression>"
29
+ npm install -g cron-human
58
30
  ```
59
31
 
60
- **Example:**
61
- ```bash
62
- $ cron-human "30 4 * * *"
63
- At 04:30
32
+ ## Features
64
33
 
65
- Next 5 runs:
66
- - 2026-01-22 04:30:00
67
- - 2026-01-23 04:30:00
68
- - 2026-01-24 04:30:00
69
- - 2026-01-25 04:30:00
70
- - 2026-01-26 04:30:00
71
- ```
34
+ - 🗣 **Human readable** - `*/5 * * * *` → "Every 5 minutes"
35
+ - 📅 **Next run times** - Shows exact execution dates
36
+ - 🌍 **Timezone support** - `--tz America/New_York`, `Asia/Tokyo`, etc.
37
+ - 🤖 **JSON output** - Use `--json` for scripts/automation
38
+ - 🏷️ **Macros** - `@daily`, `@hourly`, `@weekly`, `@monthly`, `@yearly`
39
+ - ⏱️ **Seconds precision** - 6-field cron with `--seconds`
72
40
 
73
- ### Options
41
+ ## Options
74
42
 
75
43
  | Option | Alias | Description | Default |
76
44
  |---|---|---|---|
77
- | `--next <number>` | `-n` | Number of upcoming run times to show (max 100) | 5 |
78
- | `--tz <timezone>` | | IANA timezone for output dates (e.g., `America/New_York`) | System local time |
79
- | `--json` | | Output in JSON format for scripting | false |
80
- | `--quiet` | `-q` | Print only the human description (no next runs) | false |
81
- | `--seconds` | | Enable 6-field cron format (includes seconds) | false |
82
- | `--help` | `-h` | Show help message | |
83
- | `--version` | `-v` | Show installed version | |
45
+ | `--next <n>` | `-n` | Number of runs to show (max 100) | 5 |
46
+ | `--tz <zone>` | | IANA timezone (e.g., `UTC`, `America/New_York`) | Local |
47
+ | `--json` | | JSON output format | false |
48
+ | `--quiet` | `-q` | Description only (no run times) | false |
49
+ | `--seconds` | | Enable 6-field cron (with seconds) | false |
50
+ | `--help` | `-h` | Show help | |
51
+ | `--version` | `-v` | Show version | |
84
52
 
85
53
  ## Examples
86
54
 
87
- ### Common Cron Patterns
88
-
89
- **Every 5 minutes:**
55
+ **Common patterns:**
90
56
  ```bash
91
- $ cron-human "*/5 * * * *"
92
- Every 5 minutes
57
+ cron-human "0 9 * * MON-FRI" # At 09:00, Monday through Friday
58
+ cron-human "0 0 1 * *" # At 00:00, on day 1 of the month
59
+ cron-human "@daily" # At 00:00
93
60
  ```
94
61
 
95
- **Daily at midnight:**
62
+ **With timezone:**
96
63
  ```bash
97
- $ cron-human "0 0 * * *"
98
- At 00:00
64
+ cron-human "0 9 * * *" --tz America/New_York --next 3
99
65
  ```
100
66
 
101
- **Weekdays only at 9 AM:**
67
+ **JSON output:**
102
68
  ```bash
103
- $ cron-human "0 9 * * MON-FRI"
104
- At 09:00, Monday through Friday
69
+ cron-human "*/15 * * * *" --json --quiet
105
70
  ```
106
71
 
107
- **First day of every month:**
108
- ```bash
109
- $ cron-human "0 0 1 * *"
110
- At 00:00, on day 1 of the month
111
- ```
112
-
113
- ### Using Macros
114
-
115
- Cron macros are shortcuts for common schedules:
116
-
117
- ```bash
118
- $ cron-human "@daily"
119
- At 00:00
120
-
121
- $ cron-human "@hourly"
122
- Every hour
123
-
124
- $ cron-human "@weekly"
125
- At 00:00, only on Sunday
126
- ```
127
-
128
- **Supported macros:**
129
- - `@yearly` or `@annually` - Run once a year: `0 0 1 1 *`
130
- - `@monthly` - Run once a month: `0 0 1 * *`
131
- - `@weekly` - Run once a week: `0 0 * * 0`
132
- - `@daily` - Run once a day: `0 0 * * *`
133
- - `@hourly` - Run once an hour: `0 * * * *`
134
-
135
- ### Timezone Examples
136
-
137
- **New York time:**
138
- ```bash
139
- $ cron-human "0 9 * * *" --tz America/New_York
140
- At 09:00
141
-
142
- Next 5 runs:
143
- - 2026-01-22 09:00:00
144
- - 2026-01-23 09:00:00
145
- - ...
146
- ```
147
-
148
- **Tokyo time:**
149
- ```bash
150
- $ cron-human "30 14 * * *" --tz Asia/Tokyo --next 3
151
- At 14:30
152
-
153
- Next 3 runs:
154
- - 2026-01-22 14:30:00
155
- - 2026-01-23 14:30:00
156
- - 2026-01-24 14:30:00
157
- ```
158
-
159
- ### JSON Output for Scripting
160
-
161
- Perfect for parsing in scripts or CI/CD pipelines:
162
-
163
- ```bash
164
- $ cron-human "*/15 * * * *" --json --next 2
72
+ **Output:**
73
+ ```json
165
74
  {
166
75
  "expression": "*/15 * * * *",
167
76
  "description": "Every 15 minutes",
168
- "nextRuns": [
169
- "2026-01-22 12:15:00",
170
- "2026-01-22 12:30:00"
171
- ]
77
+ "nextRuns": []
172
78
  }
173
79
  ```
174
80
 
175
- ### Quiet Mode
176
-
177
- Get just the description without the run times:
178
-
179
- ```bash
180
- $ cron-human "0 */6 * * *" --quiet
181
- Every 6 hours
182
- ```
183
-
184
- ### Seconds-Precision Cron
81
+ ## Cron Format
185
82
 
186
- Some systems (like certain schedulers) support 6-field cron with seconds:
187
-
188
- ```bash
189
- $ cron-human "*/30 * * * * *" --seconds
190
- Every 30 seconds
191
- ```
192
-
193
- ## Supported Cron Format
194
-
195
- ### Standard 5-field format:
83
+ **Standard (5 fields):**
196
84
  ```
197
85
  * * * * *
198
- │ │ │ │
199
- │ │ │ └─── Day of week (0-6, SUN-SAT)
200
- │ │ └───── Month (1-12)
201
- └─────── Day of month (1-31)
202
- └───────── Hour (0-23)
203
- └─────────── Minute (0-59)
86
+ │ │ │ │ └─ Day of week (0-6)
87
+ │ │ │ └─── Month (1-12)
88
+ │ │ └───── Day of month (1-31)
89
+ │ └─────── Hour (0-23)
90
+ └───────── Minute (0-59)
204
91
  ```
205
92
 
206
- ### 6-field format (with `--seconds`):
93
+ **With seconds (6 fields, use `--seconds`):**
207
94
  ```
208
95
  * * * * * *
209
- │ │
210
- │ │ │ │ │ └─── Day of week (0-6)
211
- │ │ │ │ └───── Month (1-12)
212
- │ │ │ └─────── Day of month (1-31)
213
- │ │ └───────── Hour (0-23)
214
- │ └─────────── Minute (0-59)
215
- └───────────── Second (0-59)
96
+ └─ Second (0-59) + above
216
97
  ```
217
98
 
218
- ### Special characters:
219
- - `*` - Any value
220
- - `,` - List (e.g., `1,3,5`)
221
- - `-` - Range (e.g., `1-5`)
222
- - `/` - Step (e.g., `*/10` = every 10)
223
-
224
- ## Troubleshooting
99
+ **Special characters:**
100
+ - `*` = any value
101
+ - `,` = list (e.g., `1,3,5`)
102
+ - `-` = range (e.g., `1-5`)
103
+ - `/` = step (e.g., `*/10`)
225
104
 
226
- ### Invalid cron expression error
227
-
228
- Make sure your expression has the correct number of fields:
229
- - **5 fields** for standard cron (default)
230
- - **6 fields** with `--seconds` flag
231
-
232
- ### Timezone not recognized
233
-
234
- Use valid IANA timezone identifiers. Common examples:
235
- - `UTC`
236
- - `America/New_York`
237
- - `Europe/London`
238
- - `Asia/Tokyo`
105
+ ## License
239
106
 
240
- Find your timezone: [IANA Timezone Database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)
107
+ MIT © Akin Ibitoye
241
108
 
242
- ### Expression accepted but results look wrong
109
+ ## Interactive Mode (TUI)
243
110
 
244
- Use `--tz UTC` to see results in UTC and verify the pattern:
111
+ Launch the interactive Terminal UI with:
245
112
 
246
113
  ```bash
247
- cron-human "0 12 * * *" --tz UTC --next 7
114
+ cron-human tui
115
+ # or
116
+ cron-human --interactive
248
117
  ```
249
118
 
250
- ## Development
251
-
252
- Want to contribute or run locally?
119
+ ![Cron Human TUI Screenshot](tui-screenshot.png)
253
120
 
254
- 1. **Clone the repository:**
255
- ```bash
256
- git clone https://github.com/AKforCodes/cron-human.git
257
- cd cron-human
258
- ```
121
+ ### Key Features
122
+ - **Live Preview**: Real-time validation and English translation as you type.
123
+ - **History**: Auto-saves successful cron expressions. Navigate history with `Up/Down` and reload with `Enter`.
124
+ - **Clipboard Integration**: Paste (`Ctrl+V`) directly into the editor. Copy (`c`) saved expressions from history.
125
+ - **Toggle Options**: Quickly enable/disable seconds support (`Space`).
259
126
 
260
- 2. **Install dependencies:**
261
- ```bash
262
- npm install
263
- ```
127
+ ### Keybindings
264
128
 
265
- 3. **Run tests:**
266
- ```bash
267
- npm test
268
- ```
129
+ | Key | Action |
130
+ |---|---|
131
+ | `Tab` | Cycle focus (Input → Options → History) |
132
+ | `Ctrl+V` | Paste from clipboard |
133
+ | `Ctrl+R` | Reset/Clear input |
134
+ | `Ctrl+C` / `Q` | Quit |
269
135
 
270
- 4. **Build:**
271
- ```bash
272
- npm run build
273
- ```
136
+ **History Panel:**
137
+ | Key | Action |
138
+ |---|---|
139
+ | `Up` / `Down` | Navigate history |
140
+ | `Enter` | Load selected expression |
141
+ | `c` | Copy selected expression |
274
142
 
275
- 5. **Run locally:**
276
- ```bash
277
- npm run dev "*/5 * * * *"
278
- ```
143
+ **Options Panel:**
144
+ | Key | Action |
145
+ |---|---|
146
+ | `Space` | Toggle checkbox |
279
147
 
280
- ## License
148
+ ## Manual Test Cases
281
149
 
282
- MIT © Akin Ibitoye
150
+ 1. **Basic Minute**: `* * * * *` -> "Every minute"
151
+ 2. **Specific Time**: `30 14 * * *` -> "At 14:30"
152
+ 3. **Interval**: `*/5 * * * *` -> "Every 5 minutes"
153
+ 4. **Range**: `0 9-17 * * 1-5` -> "At 0 minutes past the hour, between 09:00 and 17:59, Monday through Friday"
154
+ 5. **Macro**: `@daily` -> "At 00:00"
155
+ 6. **With Seconds**: `*/30 * * * * *` (Enable "Allow Seconds") -> "Every 30 seconds"
156
+ 7. **Invalid**: `* * * 99 *` -> "Error: Invalid cron expression..."
157
+ 8. **Timezone**: Set TZ to `UTC` -> Verify next runs match UTC.
158
+ 9. **History**: Enter a valid cron -> Press Up -> should see it.
159
+ 10. **Help**: Press `Tab` until Focus is History, then `Tab` again -> Focus Input.
160
+ 11. **Clipboard**: Press `Ctrl+V` to paste a cron string. Select history item and press `c` to copy.
package/dist/cli.js CHANGED
@@ -1,22 +1,31 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
- import { createRequire } from 'module';
4
3
  import { DateTime } from 'luxon';
5
4
  import { explainCron, getNextRuns, validateCron } from './lib.js';
6
- const require = createRequire(import.meta.url);
7
- const pkg = require('../package.json');
5
+ import { handleErr } from "./utils/error.js";
6
+ import pkg from "../package.json" with { type: "json" };
8
7
  const program = new Command();
9
8
  program
10
9
  .name('cron-human')
11
10
  .description('Converts cron expressions to human-readable English and prints next run times.')
12
11
  .version(pkg.version, '-v, --version')
13
- .argument('<expression>', 'Cron expression to parse')
12
+ .argument('[expression]', 'Cron expression to parse')
14
13
  .option('-n, --next <number>', 'how many upcoming run times to print', '5')
15
14
  .option('--tz <timezone>', 'timezone for output (default: system timezone)')
16
15
  .option('--json', 'output machine-readable JSON', false)
17
16
  .option('-q, --quiet', 'only print the one-line human explanation (no next runs)', false)
18
17
  .option('--seconds', 'support 6-field cron expressions with seconds', false)
19
- .action((expression, options) => {
18
+ .option('-i, --interactive', 'launch interactive TUI mode')
19
+ .action(async (expression, options) => {
20
+ if (options.interactive) {
21
+ const { startTui } = await import('./ui/launcher.js');
22
+ await startTui();
23
+ return;
24
+ }
25
+ if (!expression) {
26
+ program.help();
27
+ return;
28
+ }
20
29
  if (options.tz) {
21
30
  const test = DateTime.now().setZone(options.tz);
22
31
  if (!test.isValid) {
@@ -47,7 +56,8 @@ program
47
56
  description = explainCron(expression);
48
57
  }
49
58
  catch (e) {
50
- console.error(`Error: Could not generate description. ${e?.message ?? e}`);
59
+ const err = handleErr(e);
60
+ console.error(`Error: Could not generate description. ${err.message}`);
51
61
  process.exit(1);
52
62
  }
53
63
  const output = {
@@ -68,9 +78,17 @@ program
68
78
  }
69
79
  }
70
80
  }
71
- catch (err) {
72
- console.error(`Error: ${err.message}`);
81
+ catch (e) {
82
+ const err = handleErr(e);
83
+ console.error(err);
73
84
  process.exit(1);
74
85
  }
75
86
  });
87
+ // Add explicit command as well
88
+ program.command('tui')
89
+ .description('Launch the interactive TUI')
90
+ .action(async () => {
91
+ const { startTui } = await import('./ui/launcher.js');
92
+ await startTui();
93
+ });
76
94
  program.parse();
package/dist/lib.js CHANGED
@@ -2,23 +2,22 @@ import cronstrue from 'cronstrue';
2
2
  import { DateTime } from 'luxon';
3
3
  import { CronExpressionParser } from 'cron-parser';
4
4
  const MACROS = {
5
- "@yearly": "0 0 0 1 1 *",
6
- "@annually": "0 0 0 1 1 *",
7
- "@monthly": "0 0 0 1 * *",
8
- "@weekly": "0 0 0 * * 0",
9
- "@daily": "0 0 0 * * *",
10
- "@hourly": "0 0 * * * *",
11
- "@minutely": "0 * * * * *",
5
+ "@yearly": "0 0 1 1 *",
6
+ "@annually": "0 0 1 1 *",
7
+ "@monthly": "0 0 1 * *",
8
+ "@weekly": "0 0 * * 0",
9
+ "@daily": "0 0 * * *",
10
+ "@hourly": "0 * * * *",
11
+ "@minutely": "* * * * *",
12
12
  "@secondly": "* * * * * *",
13
- "@weekdays": "0 0 0 * * 1-5",
14
- "@weekends": "0 0 0 * * 0,6",
13
+ "@weekdays": "0 0 * * 1-5",
14
+ "@weekends": "0 0 * * 0,6",
15
15
  };
16
16
  function normalizeForParse(expr) {
17
17
  const t = expr.trim();
18
18
  if (!t.startsWith("@"))
19
19
  return t;
20
20
  const key = t.toLowerCase();
21
- // for parsing, use expanded cron if known, otherwise keep original (will error)
22
21
  return MACROS[key] ?? t;
23
22
  }
24
23
  function fieldCount(expr) {
@@ -51,15 +50,9 @@ export function validateCron(expression, options = {}) {
51
50
  }
52
51
  const normalized = normalizeForParse(expression);
53
52
  if (expression.trim().startsWith("@")) {
54
- try {
55
- const parseOpts = {};
56
- if (options.timezone)
57
- parseOpts.tz = options.timezone;
58
- CronExpressionParser.parse(normalized, parseOpts);
59
- return null;
60
- }
61
- catch (err) {
62
- return `Invalid cron expression: ${err.message}`;
53
+ const macro = expression.trim().toLowerCase();
54
+ if (macro === '@secondly' && !options.allowSeconds) {
55
+ return 'Error: @secondly requires --seconds flag.';
63
56
  }
64
57
  }
65
58
  const fields = fieldCount(normalized);
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const App: React.FC;
package/dist/ui/app.js ADDED
@@ -0,0 +1,105 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useEffect } from 'react';
3
+ import { Box, Text, useInput, useApp } from 'ink';
4
+ import { InputSection } from './components/InputSection.js';
5
+ import { PreviewSection } from './components/PreviewSection.js';
6
+ import { OptionsPanel } from './components/OptionsPanel.js';
7
+ import { HistoryPanel } from './components/HistoryPanel.js';
8
+ import clipboardy from 'clipboardy';
9
+ var FocusArea;
10
+ (function (FocusArea) {
11
+ FocusArea[FocusArea["Input"] = 0] = "Input";
12
+ FocusArea[FocusArea["Options"] = 1] = "Options";
13
+ FocusArea[FocusArea["History"] = 2] = "History";
14
+ })(FocusArea || (FocusArea = {}));
15
+ export const App = () => {
16
+ const { exit } = useApp();
17
+ const [expression, setExpression] = useState('');
18
+ const [timezone, setTimezone] = useState(undefined);
19
+ const [allowSeconds, setAllowSeconds] = useState(false);
20
+ const [focus, setFocus] = useState(FocusArea.Input);
21
+ const [history, setHistory] = useState([]);
22
+ const [historyIndex, setHistoryIndex] = useState(0);
23
+ const [showHelp, setShowHelp] = useState(false);
24
+ const [notification, setNotification] = useState(null);
25
+ useEffect(() => {
26
+ if (notification) {
27
+ const timer = setTimeout(() => setNotification(null), 2000);
28
+ return () => clearTimeout(timer);
29
+ }
30
+ }, [notification]);
31
+ useInput((input, key) => {
32
+ if (key.ctrl && input === 'c') {
33
+ exit();
34
+ return;
35
+ }
36
+ if (input === 'q' && !key.ctrl && focus !== FocusArea.Input) {
37
+ exit();
38
+ return;
39
+ }
40
+ if (key.tab) {
41
+ setFocus((prev) => {
42
+ if (prev === FocusArea.Input)
43
+ return FocusArea.Options;
44
+ if (prev === FocusArea.Options)
45
+ return FocusArea.History;
46
+ return FocusArea.Input;
47
+ });
48
+ }
49
+ if (key.ctrl && input === 'r') {
50
+ setExpression('');
51
+ setFocus(FocusArea.Input);
52
+ }
53
+ if (input === '?') {
54
+ }
55
+ if (key.ctrl && input === 'v') {
56
+ if (focus === FocusArea.Input) {
57
+ clipboardy.read().then(text => {
58
+ if (text) {
59
+ setExpression(text.trim());
60
+ setNotification('Pasted from clipboard!');
61
+ }
62
+ }).catch(err => {
63
+ setNotification(`Paste failed: ${err.message}`);
64
+ });
65
+ }
66
+ }
67
+ if (focus === FocusArea.Options) {
68
+ if (input === ' ') {
69
+ setAllowSeconds(prev => !prev);
70
+ }
71
+ }
72
+ if (focus === FocusArea.History) {
73
+ if (key.upArrow) {
74
+ setHistoryIndex(prev => Math.max(0, prev - 1));
75
+ }
76
+ if (key.downArrow) {
77
+ setHistoryIndex(prev => Math.min(history.length - 1, prev + 1));
78
+ }
79
+ if (key.return) {
80
+ if (history[historyIndex]) {
81
+ setExpression(history[historyIndex].expression);
82
+ setFocus(FocusArea.Input);
83
+ }
84
+ }
85
+ if (input === 'c') {
86
+ if (history[historyIndex]) {
87
+ clipboardy.write(history[historyIndex].expression).then(() => {
88
+ setNotification('Copied to clipboard!');
89
+ }).catch((err) => {
90
+ setNotification(`Copy failed: ${err.message}`);
91
+ });
92
+ }
93
+ }
94
+ }
95
+ });
96
+ const handleInputSubmit = (val) => {
97
+ if (val.trim()) {
98
+ const last = history[0];
99
+ if (!last || last.expression !== val) {
100
+ setHistory(prev => [{ expression: val, timestamp: new Date() }, ...prev].slice(0, 50));
101
+ }
102
+ }
103
+ };
104
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, width: "100%", height: "100%", children: [_jsxs(Box, { marginBottom: 1, justifyContent: "space-between", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, color: "magenta", children: "Cron Human TUI" }), _jsx(Text, { children: " | " }), _jsx(Text, { dimColor: true, children: "Ctrl+C to Quit" })] }), notification && _jsx(Text, { color: "yellow", bold: true, children: notification })] }), _jsx(InputSection, { value: expression, onChange: setExpression, onSubmit: handleInputSubmit, isFocused: focus === FocusArea.Input }), _jsx(Box, { marginY: 1, children: _jsx(OptionsPanel, { isFocused: focus === FocusArea.Options, timezone: timezone || "Local", allowSeconds: allowSeconds, onToggleSeconds: () => setAllowSeconds(!allowSeconds), onChangeTimezone: setTimezone }) }), _jsx(PreviewSection, { expression: expression, timezone: timezone, allowSeconds: allowSeconds }), _jsxs(Box, { marginTop: 1, children: [_jsx(HistoryPanel, { isFocused: focus === FocusArea.History, items: history.slice(0, 5), selectedIndex: historyIndex }), history.length > 5 && _jsxs(Text, { dimColor: true, children: ["... ", history.length - 5, " more"] })] })] }));
105
+ };
@@ -0,0 +1,2 @@
1
+ import React from 'react';
2
+ export declare const HelpScreen: React.FC;
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const HelpScreen = () => {
4
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "white", padding: 1, children: [_jsx(Text, { bold: true, underline: true, children: "Help & Keybindings" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { bold: true, children: "Tab" }), _jsxs(Text, { children: [": Cycle focus (Input -", '>', " Options -", '>', " History)"] })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Enter" }), _jsx(Text, { children: ": Run conversion / Save to history" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Up/Down" }), _jsx(Text, { children: ": Navigate history" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "c" }), _jsx(Text, { children: ": Copy selected history item" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Ctrl+V" }), _jsx(Text, { children: ": Paste from clipboard" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Ctrl+R" }), _jsx(Text, { children: ": Reset input" })] }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Ctrl+C / Q" }), _jsx(Text, { children: ": Quit" })] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { bold: true, underline: true, children: "Examples:" }) }), _jsx(Box, { children: _jsx(Text, { children: "* * * * * (Every minute)" }) }), _jsx(Box, { children: _jsx(Text, { children: "0 12 * * 1-5 (At 12:00 on weekdays)" }) }), _jsx(Box, { children: _jsx(Text, { children: "@daily (Run once a day at midnight)" }) })] }));
5
+ };
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ interface HistoryItem {
3
+ expression: string;
4
+ timestamp: Date;
5
+ }
6
+ interface HistoryPanelProps {
7
+ isFocused: boolean;
8
+ items: HistoryItem[];
9
+ selectedIndex: number;
10
+ }
11
+ export declare const HistoryPanel: React.FC<HistoryPanelProps>;
12
+ export {};
@@ -0,0 +1,8 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const HistoryPanel = ({ isFocused, items, selectedIndex }) => {
4
+ if (items.length === 0) {
5
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "History:" }), _jsx(Text, { dimColor: true, children: "No history yet. Press Enter to save successful runs." })] }));
6
+ }
7
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "History (Up/Down to navigate, Enter to load, 'c' to copy):" }), items.map((item, index) => (_jsx(Box, { children: _jsxs(Text, { color: index === selectedIndex ? "cyan" : "white", children: [index === selectedIndex ? "> " : " ", item.expression] }) }, index)))] }));
8
+ };
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ interface InputSectionProps {
3
+ value: string;
4
+ onChange: (value: string) => void;
5
+ onSubmit: (value: string) => void;
6
+ isFocused: boolean;
7
+ }
8
+ export declare const InputSection: React.FC<InputSectionProps>;
9
+ export {};
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import TextInput from 'ink-text-input';
4
+ export const InputSection = ({ value, onChange, onSubmit, isFocused }) => {
5
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, color: isFocused ? "green" : "white", children: "Cron Expression:" }), _jsx(Box, { children: _jsx(TextInput, { value: value, onChange: onChange, onSubmit: onSubmit, focus: isFocused, placeholder: "* * * * *" }) })] }));
6
+ };
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ interface OptionsPanelProps {
3
+ isFocused: boolean;
4
+ timezone: string;
5
+ allowSeconds: boolean;
6
+ onToggleSeconds: () => void;
7
+ onChangeTimezone: (tz: string) => void;
8
+ }
9
+ export declare const OptionsPanel: React.FC<OptionsPanelProps>;
10
+ export {};
@@ -0,0 +1,5 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ export const OptionsPanel = ({ isFocused, timezone, allowSeconds, onToggleSeconds, onChangeTimezone }) => {
4
+ return (_jsxs(Box, { borderStyle: "round", borderColor: isFocused ? "green" : "gray", paddingX: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Options (Press Tab to focus, then Space to toggle):" }), _jsxs(Box, { children: [_jsxs(Text, { color: isFocused ? "cyan" : "white", children: ["[ ", allowSeconds ? 'X' : ' ', " ] Allow Seconds"] }), _jsx(Box, { marginLeft: 2, children: _jsxs(Text, { dimColor: true, children: ["Timezone: ", timezone || 'Local', " (Ctrl+T to set)"] }) })] })] }));
5
+ };
@@ -0,0 +1,8 @@
1
+ import React from 'react';
2
+ interface PreviewSectionProps {
3
+ expression: string;
4
+ timezone?: string;
5
+ allowSeconds?: boolean;
6
+ }
7
+ export declare const PreviewSection: React.FC<PreviewSectionProps>;
8
+ export {};
@@ -0,0 +1,24 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { validateCron, explainCron, getNextRuns } from '../../lib.js';
4
+ export const PreviewSection = ({ expression, timezone, allowSeconds }) => {
5
+ const validationError = validateCron(expression, { timezone, allowSeconds });
6
+ let content;
7
+ let nextRuns = [];
8
+ let isError = false;
9
+ if (validationError) {
10
+ content = validationError;
11
+ isError = true;
12
+ }
13
+ else {
14
+ try {
15
+ content = explainCron(expression);
16
+ nextRuns = getNextRuns(expression, 3, timezone);
17
+ }
18
+ catch (e) {
19
+ content = e.message;
20
+ isError = true;
21
+ }
22
+ }
23
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: isError ? "red" : "blue", paddingX: 1, flexGrow: 1, children: [_jsx(Text, { bold: true, color: "white", children: "Human Readable:" }), _jsx(Text, { color: isError ? "red" : "green", children: content }), !isError && nextRuns.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "Next runs:" }), nextRuns.map((run, i) => (_jsxs(Text, { dimColor: true, children: [" - ", run] }, i)))] }))] }));
24
+ };
@@ -0,0 +1 @@
1
+ export declare function startTui(): Promise<void>;
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import { render } from 'ink';
3
+ import { App } from './app.js';
4
+ export function startTui() {
5
+ const { waitUntilExit } = render(React.createElement(App));
6
+ return waitUntilExit();
7
+ }
@@ -0,0 +1,5 @@
1
+ declare class CliError extends Error {
2
+ constructor(msg: string);
3
+ }
4
+ declare function handleErr(err: unknown): CliError;
5
+ export { CliError, handleErr };
@@ -0,0 +1,17 @@
1
+ class CliError extends Error {
2
+ constructor(msg) {
3
+ super(msg);
4
+ }
5
+ }
6
+ function handleErr(err) {
7
+ if (err instanceof Error) {
8
+ return new CliError(err.message);
9
+ }
10
+ else if (typeof err === "string") {
11
+ return new CliError(err);
12
+ }
13
+ else {
14
+ return new CliError("unknown error");
15
+ }
16
+ }
17
+ export { CliError, handleErr };
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "cron-human",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "A CLI that converts cron expressions to human-readable English and prints next run times",
5
5
  "main": "dist/lib.js",
6
6
  "types": "dist/lib.d.ts",
7
+ "type": "module",
7
8
  "bin": {
8
9
  "cron-human": "dist/cli.js"
9
10
  },
10
- "type": "module",
11
11
  "scripts": {
12
12
  "build": "tsc -p tsconfig.build.json",
13
- "start": "node dist/cli.js",
13
+ "start": "node dist/cli.js '*/5 * * * *'",
14
14
  "dev": "tsx src/cli.ts",
15
15
  "test": "vitest run",
16
16
  "test:watch": "vitest",
@@ -23,7 +23,6 @@
23
23
  "human-readable",
24
24
  "schedule"
25
25
  ],
26
- "author": "Akin Ibitoye",
27
26
  "license": "MIT",
28
27
  "homepage": "https://github.com/AKforCodes/cron-human#readme",
29
28
  "repository": {
@@ -39,21 +38,35 @@
39
38
  "LICENSE"
40
39
  ],
41
40
  "dependencies": {
41
+ "clipboardy": "^5.1.0",
42
42
  "commander": "^14.0.2",
43
43
  "cron-parser": "^5.5.0",
44
44
  "cronstrue": "^3.9.0",
45
- "luxon": "^3.7.2"
45
+ "ink": "^6.6.0",
46
+ "ink-select-input": "^6.2.0",
47
+ "ink-text-input": "^6.0.0",
48
+ "luxon": "^3.7.2",
49
+ "react": "^19.2.4"
46
50
  },
47
51
  "devDependencies": {
52
+ "@testing-library/react": "^16.3.2",
53
+ "@types/ink-select-input": "^3.0.5",
54
+ "@types/ink-text-input": "^2.0.5",
48
55
  "@types/luxon": "^3.7.1",
49
56
  "@types/node": "^25.0.10",
57
+ "@types/react": "^19.2.10",
50
58
  "@typescript-eslint/eslint-plugin": "^8.53.1",
51
59
  "@typescript-eslint/parser": "^8.53.1",
60
+ "chalk": "^5.6.2",
52
61
  "eslint": "^9.39.2",
53
62
  "eslint-config-prettier": "^10.1.8",
63
+ "ink-testing-library": "^4.0.0",
54
64
  "prettier": "^3.8.1",
55
65
  "tsx": "^4.21.0",
56
66
  "typescript": "^5.9.3",
57
67
  "vitest": "^4.0.17"
68
+ },
69
+ "overrides": {
70
+ "es-toolkit": "1.42.0"
58
71
  }
59
72
  }