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 +1 -1
- package/README.md +94 -216
- package/dist/cli.js +26 -8
- package/dist/lib.js +12 -19
- package/dist/ui/app.d.ts +2 -0
- package/dist/ui/app.js +105 -0
- package/dist/ui/components/HelpScreen.d.ts +2 -0
- package/dist/ui/components/HelpScreen.js +5 -0
- package/dist/ui/components/HistoryPanel.d.ts +12 -0
- package/dist/ui/components/HistoryPanel.js +8 -0
- package/dist/ui/components/InputSection.d.ts +9 -0
- package/dist/ui/components/InputSection.js +6 -0
- package/dist/ui/components/OptionsPanel.d.ts +10 -0
- package/dist/ui/components/OptionsPanel.js +5 -0
- package/dist/ui/components/PreviewSection.d.ts +8 -0
- package/dist/ui/components/PreviewSection.js +24 -0
- package/dist/ui/launcher.d.ts +1 -0
- package/dist/ui/launcher.js +7 -0
- package/dist/utils/error.d.ts +5 -0
- package/dist/utils/error.js +17 -0
- package/package.json +18 -5
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,282 +1,160 @@
|
|
|
1
1
|
# cron-human
|
|
2
2
|
|
|
3
|
-
>
|
|
3
|
+
> Convert cron expressions into human-readable English and see when they'll run next.
|
|
4
4
|
|
|
5
5
|
[](https://www.npmjs.com/package/cron-human)
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
|
|
8
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
##
|
|
53
|
-
|
|
54
|
-
### Basic Usage
|
|
26
|
+
## Installation
|
|
55
27
|
|
|
56
28
|
```bash
|
|
57
|
-
|
|
29
|
+
npm install -g cron-human
|
|
58
30
|
```
|
|
59
31
|
|
|
60
|
-
|
|
61
|
-
```bash
|
|
62
|
-
$ cron-human "30 4 * * *"
|
|
63
|
-
At 04:30
|
|
32
|
+
## Features
|
|
64
33
|
|
|
65
|
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
70
|
-
-
|
|
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
|
-
|
|
41
|
+
## Options
|
|
74
42
|
|
|
75
43
|
| Option | Alias | Description | Default |
|
|
76
44
|
|---|---|---|---|
|
|
77
|
-
| `--next <
|
|
78
|
-
| `--tz <
|
|
79
|
-
| `--json` | |
|
|
80
|
-
| `--quiet` | `-q` |
|
|
81
|
-
| `--seconds` | | Enable 6-field cron
|
|
82
|
-
| `--help` | `-h` | Show help
|
|
83
|
-
| `--version` | `-v` | Show
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
**Every 5 minutes:**
|
|
55
|
+
**Common patterns:**
|
|
90
56
|
```bash
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
**
|
|
62
|
+
**With timezone:**
|
|
96
63
|
```bash
|
|
97
|
-
|
|
98
|
-
At 00:00
|
|
64
|
+
cron-human "0 9 * * *" --tz America/New_York --next 3
|
|
99
65
|
```
|
|
100
66
|
|
|
101
|
-
**
|
|
67
|
+
**JSON output:**
|
|
102
68
|
```bash
|
|
103
|
-
|
|
104
|
-
At 09:00, Monday through Friday
|
|
69
|
+
cron-human "*/15 * * * *" --json --quiet
|
|
105
70
|
```
|
|
106
71
|
|
|
107
|
-
**
|
|
108
|
-
```
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
│ │ │
|
|
200
|
-
│ │
|
|
201
|
-
│
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
219
|
-
- `*`
|
|
220
|
-
- `,`
|
|
221
|
-
- `-`
|
|
222
|
-
- `/`
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
MIT © Akin Ibitoye
|
|
241
108
|
|
|
242
|
-
|
|
109
|
+
## Interactive Mode (TUI)
|
|
243
110
|
|
|
244
|
-
|
|
111
|
+
Launch the interactive Terminal UI with:
|
|
245
112
|
|
|
246
113
|
```bash
|
|
247
|
-
cron-human
|
|
114
|
+
cron-human tui
|
|
115
|
+
# or
|
|
116
|
+
cron-human --interactive
|
|
248
117
|
```
|
|
249
118
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
Want to contribute or run locally?
|
|
119
|
+

|
|
253
120
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
```bash
|
|
262
|
-
npm install
|
|
263
|
-
```
|
|
127
|
+
### Keybindings
|
|
264
128
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
143
|
+
**Options Panel:**
|
|
144
|
+
| Key | Action |
|
|
145
|
+
|---|---|
|
|
146
|
+
| `Space` | Toggle checkbox |
|
|
279
147
|
|
|
280
|
-
##
|
|
148
|
+
## Manual Test Cases
|
|
281
149
|
|
|
282
|
-
|
|
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
|
-
|
|
7
|
-
|
|
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('
|
|
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
|
-
.
|
|
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
|
-
|
|
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 (
|
|
72
|
-
|
|
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
|
|
6
|
-
"@annually": "0 0
|
|
7
|
-
"@monthly": "0 0
|
|
8
|
-
"@weekly": "0 0
|
|
9
|
-
"@daily": "0 0
|
|
10
|
-
"@hourly": "0
|
|
11
|
-
"@minutely": "
|
|
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
|
|
14
|
-
"@weekends": "0 0
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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);
|
package/dist/ui/app.d.ts
ADDED
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,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,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,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,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
|
|
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
|
-
"
|
|
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
|
}
|