@wonsukchoi/crondex 0.14.0 → 0.16.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 +21 -8
- package/bin/crondex.js +164 -13
- package/lib/deploy.js +194 -9
- package/package.json +2 -1
- package/templates/job.template.yaml +49 -0
package/README.md
CHANGED
|
@@ -35,14 +35,27 @@ npx @wonsukchoi/crondex deploy ssl-cert-expiry-check --var host=example.com
|
|
|
35
35
|
- `show <id>` — print a job's full YAML
|
|
36
36
|
- `add <id> [--dest path]` — copy it into your project to edit
|
|
37
37
|
- `init <id> [--category x]` — scaffold a brand-new job from the template
|
|
38
|
-
- `
|
|
38
|
+
- `update <path>` — re-pull a job you already `add`ed/`init`ed (matched by
|
|
39
|
+
its `id` field) against the current catalog and overwrite it in place
|
|
40
|
+
- `deploy <id> [--target crontab|github-actions|systemd|docker|k8s-cronjob|eventbridge|cloud-scheduler] [--var name=value ...]`
|
|
39
41
|
— turn a job into something that actually runs: prints a ready crontab
|
|
40
|
-
line (or installs it into your own crontab with `--install`),
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
`
|
|
42
|
+
line (or installs it into your own crontab with `--install`), writes a
|
|
43
|
+
scheduled GitHub Actions workflow file, writes a systemd
|
|
44
|
+
`<id>.service` + `<id>.timer` pair, writes a Dockerfile + `/etc/cron.d`
|
|
45
|
+
entry that runs the job on its own schedule in a container, or writes a
|
|
46
|
+
self-contained `batch/v1` CronJob manifest (`k8s-cronjob`). `eventbridge`
|
|
47
|
+
and `cloud-scheduler` print a ready `aws`/`gcloud` CLI command instead —
|
|
48
|
+
those services invoke a target (Lambda/ECS/HTTP endpoint) rather than
|
|
49
|
+
running a shell command directly, so the command leaves that target as a
|
|
50
|
+
TODO for you to wire up. `--var` overrides a variable's default
|
|
51
|
+
(repeatable). `hybrid` jobs deploy their `command` by default; pass
|
|
52
|
+
`--mode prompt` to deploy the `prompt` side instead — for
|
|
53
|
+
crontab/systemd/docker/k8s targets that means wiring in your own agent
|
|
54
|
+
CLI via a `CRONDEX_AGENT_CLI` env var, since crondex can't guess its
|
|
55
|
+
syntax.
|
|
56
|
+
- `deploy --list-installed` — show every crondex-managed line in your
|
|
57
|
+
crontab (the ones left by `--install`)
|
|
58
|
+
- `uninstall <id>` — remove one of those installed crontab entries
|
|
46
59
|
|
|
47
60
|
Add `--json` to `list`/`categories`/`show`/`recommend` for machine-readable
|
|
48
61
|
output — useful when an agent is parsing the result programmatically
|
|
@@ -156,7 +169,7 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
|
|
|
156
169
|
```
|
|
157
170
|
crondex/
|
|
158
171
|
├── llms.txt agent-discovery manifest (llms.txt convention)
|
|
159
|
-
├── bin/crondex.js CLI: list / categories / show / add / recommend / init / deploy
|
|
172
|
+
├── bin/crondex.js CLI: list / categories / show / add / recommend / init / update / deploy / uninstall
|
|
160
173
|
├── lib/ recommend, deploy, and catalog-building logic (unit tested in test/)
|
|
161
174
|
├── catalog.json generated index of every job — read this first
|
|
162
175
|
├── schema/job.schema.json spec every job file follows
|
package/bin/crondex.js
CHANGED
|
@@ -6,7 +6,18 @@ import { execFileSync } from "node:child_process";
|
|
|
6
6
|
import yaml from "js-yaml";
|
|
7
7
|
import { tokenize, rankJobs } from "../lib/recommend.js";
|
|
8
8
|
import { CATEGORY_DESCRIPTIONS } from "../lib/category-descriptions.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
resolveVariables,
|
|
11
|
+
substitutePlaceholders,
|
|
12
|
+
pickMode,
|
|
13
|
+
buildCrontabLine,
|
|
14
|
+
buildGithubActionsWorkflow,
|
|
15
|
+
buildSystemdUnits,
|
|
16
|
+
buildDockerArtifacts,
|
|
17
|
+
buildK8sCronJob,
|
|
18
|
+
buildEventBridgeCommand,
|
|
19
|
+
buildCloudSchedulerCommand,
|
|
20
|
+
} from "../lib/deploy.js";
|
|
10
21
|
|
|
11
22
|
const ROOT = new URL("..", import.meta.url).pathname;
|
|
12
23
|
const CATALOG = JSON.parse(readFileSync(join(ROOT, "catalog.json"), "utf8"));
|
|
@@ -59,8 +70,12 @@ Usage:
|
|
|
59
70
|
crondex add <id> [--dest <path>]
|
|
60
71
|
crondex recommend "<what you want done>" [--limit <n>] [--json]
|
|
61
72
|
crondex init <id> [--category <name>] [--dest <path>]
|
|
62
|
-
crondex
|
|
73
|
+
crondex update <path>
|
|
74
|
+
crondex deploy <id> [--target crontab|github-actions|systemd|docker|k8s-cronjob|
|
|
75
|
+
eventbridge|cloud-scheduler] [--mode script|prompt]
|
|
63
76
|
[--var name=value ...] [--dest <path>] [--install]
|
|
77
|
+
crondex deploy --list-installed [--json]
|
|
78
|
+
crondex uninstall <id>
|
|
64
79
|
|
|
65
80
|
Examples:
|
|
66
81
|
crondex list --category devops
|
|
@@ -69,8 +84,16 @@ Examples:
|
|
|
69
84
|
crondex add backup-reminder --dest ./cron/backup-reminder.yaml
|
|
70
85
|
crondex recommend "warn me before my SSL cert expires"
|
|
71
86
|
crondex init ssl-cert-expiry-check --category security
|
|
87
|
+
crondex update ./cron/backup-reminder.yaml
|
|
72
88
|
crondex deploy ssl-cert-expiry-check --var host=example.com --var port=443
|
|
73
89
|
crondex deploy repo-health-check --target github-actions
|
|
90
|
+
crondex deploy repo-health-check --target systemd --dest ./systemd
|
|
91
|
+
crondex deploy repo-health-check --target docker --dest ./docker/repo-health-check
|
|
92
|
+
crondex deploy repo-health-check --target k8s-cronjob --dest ./k8s/repo-health-check.yaml
|
|
93
|
+
crondex deploy repo-health-check --target eventbridge
|
|
94
|
+
crondex deploy repo-health-check --target cloud-scheduler
|
|
95
|
+
crondex deploy --list-installed
|
|
96
|
+
crondex uninstall ssl-cert-expiry-check
|
|
74
97
|
|
|
75
98
|
Add --json to list/categories/show/recommend for machine-readable output —
|
|
76
99
|
useful when an agent is parsing crondex's output programmatically instead
|
|
@@ -79,9 +102,25 @@ of a human reading it.
|
|
|
79
102
|
deploy turns a job into something you can actually run: --target crontab
|
|
80
103
|
(default) prints a ready crontab line, or installs it into your own
|
|
81
104
|
crontab with --install; --target github-actions writes a scheduled
|
|
82
|
-
workflow file (default .github/workflows/<id>.yml)
|
|
83
|
-
|
|
84
|
-
|
|
105
|
+
workflow file (default .github/workflows/<id>.yml); --target systemd
|
|
106
|
+
writes a <id>.service + <id>.timer pair (default ./systemd/); --target
|
|
107
|
+
docker writes a Dockerfile + crontab pair that runs the job on its own
|
|
108
|
+
schedule in a container (default ./docker/<id>/); --target k8s-cronjob
|
|
109
|
+
writes a self-contained batch/v1 CronJob manifest (default
|
|
110
|
+
./k8s/<id>.cronjob.yaml). --target eventbridge and --target
|
|
111
|
+
cloud-scheduler print a ready aws/gcloud CLI command instead — those
|
|
112
|
+
services invoke a target (Lambda/ECS/HTTP endpoint) rather than running a
|
|
113
|
+
shell command directly, so the command leaves that target as a TODO for
|
|
114
|
+
you to wire up. --var overrides a job's variable defaults (repeatable).
|
|
115
|
+
hybrid jobs default to --mode script (zero tokens); pass --mode prompt to
|
|
116
|
+
deploy the agent-prompt side instead.
|
|
117
|
+
|
|
118
|
+
deploy --list-installed shows every crondex-managed line in your crontab
|
|
119
|
+
(the ones with a "# crondex:<id>" marker, as left by --install). uninstall
|
|
120
|
+
removes one of those by id.
|
|
121
|
+
|
|
122
|
+
update re-pulls a job you already added/inited (matched by its "id" field)
|
|
123
|
+
against the current catalog and overwrites the local file in place.
|
|
85
124
|
`);
|
|
86
125
|
}
|
|
87
126
|
|
|
@@ -194,22 +233,80 @@ function init(id) {
|
|
|
194
233
|
console.log(`wrote ${dest} — fill in the fields, then \`npm run validate\` (see CONTRIBUTING.md to submit it upstream).`);
|
|
195
234
|
}
|
|
196
235
|
|
|
197
|
-
function
|
|
198
|
-
let existing = "";
|
|
236
|
+
function readCrontab() {
|
|
199
237
|
try {
|
|
200
|
-
|
|
238
|
+
return execFileSync("crontab", ["-l"], { encoding: "utf8" });
|
|
201
239
|
} catch {
|
|
202
|
-
|
|
240
|
+
return "";
|
|
203
241
|
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function installCrontabLine(id, line) {
|
|
204
245
|
const marker = `# crondex:${id}`;
|
|
205
|
-
const kept =
|
|
246
|
+
const kept = readCrontab()
|
|
206
247
|
.split("\n")
|
|
207
248
|
.filter((l) => l.trim().length > 0 && !l.includes(marker));
|
|
208
249
|
const updated = [...kept, line].join("\n") + "\n";
|
|
209
250
|
execFileSync("crontab", ["-"], { input: updated });
|
|
210
251
|
}
|
|
211
252
|
|
|
253
|
+
function uninstall(id) {
|
|
254
|
+
const marker = `# crondex:${id}`;
|
|
255
|
+
const existing = readCrontab();
|
|
256
|
+
if (!existing.includes(marker)) {
|
|
257
|
+
console.error(`no installed crontab entry for "${id}" — run "crondex deploy --list-installed" to see what's there.`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
const kept = existing.split("\n").filter((l) => l.trim().length > 0 && !l.includes(marker));
|
|
261
|
+
execFileSync("crontab", ["-"], { input: kept.join("\n") + "\n" });
|
|
262
|
+
console.log(`removed "${id}" from your crontab.`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function listInstalled() {
|
|
266
|
+
const managed = readCrontab()
|
|
267
|
+
.split("\n")
|
|
268
|
+
.filter((l) => l.includes("# crondex:"));
|
|
269
|
+
if (hasFlag("json")) {
|
|
270
|
+
return printJson(
|
|
271
|
+
managed.map((l) => ({ id: l.match(/# crondex:(\S+)/)?.[1], line: l }))
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (!managed.length) {
|
|
275
|
+
console.log("no crondex-managed crontab entries installed.");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const l of managed) console.log(l);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function update(path) {
|
|
282
|
+
if (!existsSync(path)) {
|
|
283
|
+
console.error(`${path} does not exist.`);
|
|
284
|
+
process.exit(1);
|
|
285
|
+
}
|
|
286
|
+
const localRaw = readFileSync(path, "utf8");
|
|
287
|
+
let localDoc;
|
|
288
|
+
try {
|
|
289
|
+
localDoc = yaml.load(localRaw);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
console.error(`${path} isn't valid YAML: ${e.message}`);
|
|
292
|
+
process.exit(1);
|
|
293
|
+
}
|
|
294
|
+
if (!localDoc?.id) {
|
|
295
|
+
console.error(`${path} has no "id" field — can't match it to a catalog job.`);
|
|
296
|
+
process.exit(1);
|
|
297
|
+
}
|
|
298
|
+
const meta = findJob(localDoc.id);
|
|
299
|
+
const latestRaw = readFileSync(join(ROOT, meta.path), "utf8");
|
|
300
|
+
if (latestRaw === localRaw) {
|
|
301
|
+
console.log(`${path} is already up to date with "${localDoc.id}".`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
writeFileSync(path, latestRaw);
|
|
305
|
+
console.log(`updated ${path} to the latest "${localDoc.id}" from the catalog.`);
|
|
306
|
+
}
|
|
307
|
+
|
|
212
308
|
function deploy(id) {
|
|
309
|
+
if (id === undefined && hasFlag("list-installed")) return listInstalled();
|
|
213
310
|
const meta = findJob(id);
|
|
214
311
|
const doc = yaml.load(readFileSync(join(ROOT, meta.path), "utf8"));
|
|
215
312
|
doc.path = meta.path;
|
|
@@ -248,8 +345,48 @@ function deploy(id) {
|
|
|
248
345
|
mkdirSync(dirname(dest), { recursive: true });
|
|
249
346
|
writeFileSync(dest, workflow);
|
|
250
347
|
console.log(`wrote ${dest}`);
|
|
348
|
+
} else if (target === "systemd") {
|
|
349
|
+
const { service, timer } = buildSystemdUnits(doc, mode === "prompt" ? prompt : command, mode === "prompt");
|
|
350
|
+
const destDir = flag("dest") ?? "./systemd";
|
|
351
|
+
const serviceDest = join(destDir, `${id}.service`);
|
|
352
|
+
const timerDest = join(destDir, `${id}.timer`);
|
|
353
|
+
if (existsSync(serviceDest) || existsSync(timerDest)) {
|
|
354
|
+
console.error(`${serviceDest} or ${timerDest} already exists — refusing to overwrite. Pass --dest to choose another directory.`);
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
mkdirSync(destDir, { recursive: true });
|
|
358
|
+
writeFileSync(serviceDest, service);
|
|
359
|
+
writeFileSync(timerDest, timer);
|
|
360
|
+
console.log(`wrote ${serviceDest} and ${timerDest} — enable with:\n systemctl --user enable --now ${id}.timer`);
|
|
361
|
+
} else if (target === "k8s-cronjob") {
|
|
362
|
+
const manifest = buildK8sCronJob(doc, mode === "prompt" ? prompt : command, mode === "prompt");
|
|
363
|
+
const dest = flag("dest") ?? join("./k8s", `${id}.cronjob.yaml`);
|
|
364
|
+
if (existsSync(dest)) {
|
|
365
|
+
console.error(`${dest} already exists — refusing to overwrite. Pass --dest to choose another path.`);
|
|
366
|
+
process.exit(1);
|
|
367
|
+
}
|
|
368
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
369
|
+
writeFileSync(dest, manifest);
|
|
370
|
+
console.log(`wrote ${dest} — apply with:\n kubectl apply -f ${dest}`);
|
|
371
|
+
} else if (target === "eventbridge") {
|
|
372
|
+
console.log(buildEventBridgeCommand(doc, mode === "prompt" ? prompt : command, mode === "prompt"));
|
|
373
|
+
} else if (target === "cloud-scheduler") {
|
|
374
|
+
console.log(buildCloudSchedulerCommand(doc, mode === "prompt" ? prompt : command, mode === "prompt"));
|
|
375
|
+
} else if (target === "docker") {
|
|
376
|
+
const { dockerfile, crontab } = buildDockerArtifacts(doc, mode === "prompt" ? prompt : command, mode === "prompt");
|
|
377
|
+
const destDir = flag("dest") ?? join("./docker", id);
|
|
378
|
+
const dockerfileDest = join(destDir, "Dockerfile");
|
|
379
|
+
const crontabDest = join(destDir, "crontab");
|
|
380
|
+
if (existsSync(dockerfileDest) || existsSync(crontabDest)) {
|
|
381
|
+
console.error(`${dockerfileDest} or ${crontabDest} already exists — refusing to overwrite. Pass --dest to choose another directory.`);
|
|
382
|
+
process.exit(1);
|
|
383
|
+
}
|
|
384
|
+
mkdirSync(destDir, { recursive: true });
|
|
385
|
+
writeFileSync(dockerfileDest, dockerfile);
|
|
386
|
+
writeFileSync(crontabDest, crontab);
|
|
387
|
+
console.log(`wrote ${dockerfileDest} and ${crontabDest} — build with:\n docker build -t ${id} ${destDir}`);
|
|
251
388
|
} else {
|
|
252
|
-
console.error(`unknown --target "${target}" — use "crontab"
|
|
389
|
+
console.error(`unknown --target "${target}" — use "crontab", "github-actions", "systemd", "docker", "k8s-cronjob", "eventbridge", or "cloud-scheduler".`);
|
|
253
390
|
process.exit(1);
|
|
254
391
|
}
|
|
255
392
|
}
|
|
@@ -301,11 +438,25 @@ switch (cmd) {
|
|
|
301
438
|
recommend(args[0]);
|
|
302
439
|
break;
|
|
303
440
|
case "deploy":
|
|
441
|
+
if (!args[0] && !hasFlag("list-installed")) {
|
|
442
|
+
console.error("usage: crondex deploy <id> [--target crontab|github-actions|systemd|docker] [--mode script|prompt] [--var name=value ...] [--dest <path>] [--install]\n or: crondex deploy --list-installed [--json]");
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
deploy(hasFlag("list-installed") ? undefined : args[0]);
|
|
446
|
+
break;
|
|
447
|
+
case "uninstall":
|
|
448
|
+
if (!args[0]) {
|
|
449
|
+
console.error("usage: crondex uninstall <id>");
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
uninstall(args[0]);
|
|
453
|
+
break;
|
|
454
|
+
case "update":
|
|
304
455
|
if (!args[0]) {
|
|
305
|
-
console.error("usage: crondex
|
|
456
|
+
console.error("usage: crondex update <path>");
|
|
306
457
|
process.exit(1);
|
|
307
458
|
}
|
|
308
|
-
|
|
459
|
+
update(args[0]);
|
|
309
460
|
break;
|
|
310
461
|
default:
|
|
311
462
|
printHelp();
|
package/lib/deploy.js
CHANGED
|
@@ -30,17 +30,202 @@ function escapeSingleQuotes(text) {
|
|
|
30
30
|
return text.replace(/'/g, `'"'"'`);
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
33
|
+
// Collapses a (possibly multi-line) resolved command/prompt onto one line.
|
|
34
|
+
function flattenScript(resolvedText) {
|
|
35
|
+
return resolvedText.trim().replace(/\s*\n\s*/g, " ");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Builds a single-quoted shell invocation from a flattened script. isPrompt jobs can't
|
|
39
|
+
// assume any particular agent CLI syntax, so the body defers to a CRONDEX_AGENT_CLI env
|
|
40
|
+
// var the user sets themselves (e.g. `export CRONDEX_AGENT_CLI="claude -p"`) rather than
|
|
41
|
+
// guessing wrong. Shared by every deploy target that embeds the job as a single-quoted
|
|
42
|
+
// string inside a larger shell line (crontab, systemd, docker) — targets that build
|
|
43
|
+
// their own command array (k8s) flatten/quote the script directly instead.
|
|
44
|
+
function buildShellBody(resolvedText, isPrompt) {
|
|
45
|
+
const escaped = escapeSingleQuotes(flattenScript(resolvedText));
|
|
46
|
+
return isPrompt
|
|
41
47
|
? `\${CRONDEX_AGENT_CLI:?set CRONDEX_AGENT_CLI to your agent CLI invocation, e.g. "claude -p"} '${escaped}'`
|
|
42
48
|
: `bash -lc '${escaped}'`;
|
|
43
|
-
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Flattens a (possibly multi-line) resolved command/prompt into one crontab line.
|
|
52
|
+
export function buildCrontabLine(job, resolvedText, isPrompt) {
|
|
53
|
+
return `${job.schedule} ${buildShellBody(resolvedText, isPrompt)} # crondex:${job.id}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
57
|
+
|
|
58
|
+
// Numeric cron fields (minute/hour/day-of-month/month) map onto systemd calendar
|
|
59
|
+
// syntax almost unchanged — the one gotcha is `*/n`, which systemd requires written
|
|
60
|
+
// as `0/n` (a bare `*` can't carry a step). Comma lists and `a-b` ranges pass through.
|
|
61
|
+
function translateNumericField(field) {
|
|
62
|
+
return field.replace(/^\*\/(\d+)$/, "0/$1");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Cron's day-of-week field is numeric (0-6 or 7, both Sun); systemd wants weekday
|
|
66
|
+
// names. Only handles the shapes crondex jobs actually use (digit, list, a-b range) —
|
|
67
|
+
// not the rarer `a-b/n` step-in-range cron syntax.
|
|
68
|
+
function translateWeekdayField(field) {
|
|
69
|
+
if (field === "*") return null;
|
|
70
|
+
return field
|
|
71
|
+
.split(",")
|
|
72
|
+
.map((part) => {
|
|
73
|
+
const range = part.match(/^(\d)-(\d)$/);
|
|
74
|
+
if (range) return `${DOW[Number(range[1]) % 7]}-${DOW[Number(range[2]) % 7]}`;
|
|
75
|
+
return DOW[Number(part) % 7];
|
|
76
|
+
})
|
|
77
|
+
.join(",");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Converts a standard 5-field cron schedule into a systemd OnCalendar= expression.
|
|
81
|
+
export function cronToSystemdCalendar(schedule) {
|
|
82
|
+
const [minute, hour, dom, month, dow] = schedule.trim().split(/\s+/);
|
|
83
|
+
const weekday = translateWeekdayField(dow);
|
|
84
|
+
const date = `*-${translateNumericField(month)}-${translateNumericField(dom)}`;
|
|
85
|
+
const time = `${translateNumericField(hour)}:${translateNumericField(minute)}:00`;
|
|
86
|
+
return weekday ? `${weekday} ${date} ${time}` : `${date} ${time}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Builds a systemd service+timer unit pair for the job. Prompt-mode jobs still defer
|
|
90
|
+
// to CRONDEX_AGENT_CLI (see buildCrontabLine) — systemd services run with a minimal
|
|
91
|
+
// environment, so the unit points at EnvironmentFile as the place to set it.
|
|
92
|
+
export function buildSystemdUnits(job, resolvedText, isPrompt) {
|
|
93
|
+
const body = buildShellBody(resolvedText, isPrompt);
|
|
94
|
+
const service = `[Unit]
|
|
95
|
+
Description=${job.name} (crondex:${job.id})
|
|
96
|
+
|
|
97
|
+
[Service]
|
|
98
|
+
Type=oneshot
|
|
99
|
+
${isPrompt ? "# prompt-mode job — set CRONDEX_AGENT_CLI, e.g. via EnvironmentFile=/etc/crondex/${job.id}.env\n" : ""}ExecStart=/bin/bash -c '${body.replace(/'/g, `'"'"'`)}'
|
|
100
|
+
`;
|
|
101
|
+
const timer = `[Unit]
|
|
102
|
+
Description=${job.name} timer (crondex:${job.id})
|
|
103
|
+
|
|
104
|
+
[Timer]
|
|
105
|
+
OnCalendar=${cronToSystemdCalendar(job.schedule)}
|
|
106
|
+
Persistent=true
|
|
107
|
+
|
|
108
|
+
[Install]
|
|
109
|
+
WantedBy=timers.target
|
|
110
|
+
`;
|
|
111
|
+
return { service, timer };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Builds a Dockerfile + /etc/cron.d entry that runs the job on its own schedule
|
|
115
|
+
// inside a container — for teams that want the job shipped as an image rather than
|
|
116
|
+
// installed on a host crontab or run via CI.
|
|
117
|
+
export function buildDockerArtifacts(job, resolvedText, isPrompt) {
|
|
118
|
+
const body = buildShellBody(resolvedText, isPrompt);
|
|
119
|
+
const crontab = `${job.schedule} root ${body} # crondex:${job.id}\n`;
|
|
120
|
+
const dockerfile = `# Generated by \`crondex deploy ${job.id} --target docker\` from ${job.path}.
|
|
121
|
+
FROM debian:bookworm-slim
|
|
122
|
+
RUN apt-get update && apt-get install -y --no-install-recommends cron bash ca-certificates \\
|
|
123
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
124
|
+
COPY crontab /etc/cron.d/crondex-job
|
|
125
|
+
RUN chmod 0644 /etc/cron.d/crondex-job && crontab /etc/cron.d/crondex-job && touch /var/log/cron.log
|
|
126
|
+
${isPrompt ? "# NOTE: prompt-mode job — pass CRONDEX_AGENT_CLI via `docker run -e` so cron's environment has it.\n" : ""}CMD ["sh", "-c", "cron && tail -f /var/log/cron.log"]
|
|
127
|
+
`;
|
|
128
|
+
return { dockerfile, crontab };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// YAML double-quoted scalars only need backslash and the quote itself escaped —
|
|
132
|
+
// used for embedding an arbitrary shell body inside a k8s manifest's command array.
|
|
133
|
+
function yamlDoubleQuote(text) {
|
|
134
|
+
return `"${text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Builds a self-contained batch/v1 CronJob manifest — the one target here that
|
|
138
|
+
// actually runs the job (crontab/systemd/docker's sibling), rather than just
|
|
139
|
+
// scheduling an invocation of something else the user still has to build (see
|
|
140
|
+
// buildEventBridgeCommand/buildCloudSchedulerCommand below).
|
|
141
|
+
export function buildK8sCronJob(job, resolvedText, isPrompt) {
|
|
142
|
+
const flat = flattenScript(resolvedText);
|
|
143
|
+
const shell = isPrompt ? "sh" : "bash";
|
|
144
|
+
const command = isPrompt
|
|
145
|
+
? `\${CRONDEX_AGENT_CLI:?set CRONDEX_AGENT_CLI to your agent CLI invocation, e.g. "claude -p"} '${escapeSingleQuotes(flat)}'`
|
|
146
|
+
: flat;
|
|
147
|
+
return `# Generated by \`crondex deploy ${job.id} --target k8s-cronjob\` from ${job.path}.
|
|
148
|
+
apiVersion: batch/v1
|
|
149
|
+
kind: CronJob
|
|
150
|
+
metadata:
|
|
151
|
+
name: ${job.id}
|
|
152
|
+
labels:
|
|
153
|
+
app.kubernetes.io/managed-by: crondex
|
|
154
|
+
spec:
|
|
155
|
+
schedule: "${job.schedule}"
|
|
156
|
+
jobTemplate:
|
|
157
|
+
spec:
|
|
158
|
+
template:
|
|
159
|
+
spec:
|
|
160
|
+
restartPolicy: OnFailure
|
|
161
|
+
containers:
|
|
162
|
+
- name: ${job.id}
|
|
163
|
+
image: bash:5
|
|
164
|
+
${isPrompt ? ` env:
|
|
165
|
+
- name: CRONDEX_AGENT_CLI
|
|
166
|
+
value: "REPLACE_ME" # e.g. "claude -p"
|
|
167
|
+
` : ""} command: ["${shell}", "-lc", ${yamlDoubleQuote(command)}]
|
|
168
|
+
`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const AWS_DOW = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
|
|
172
|
+
|
|
173
|
+
// AWS's cron() requires exactly one of day-of-month/day-of-week to be "?" — the other
|
|
174
|
+
// carries the restriction. Cron's day-of-week is numeric (0-6 or 7, both Sun); AWS
|
|
175
|
+
// wants day names. Minute/hour/month pass through unchanged except `*/n` -> `0/n`
|
|
176
|
+
// (same systemd gotcha — a bare `*` can't carry a step).
|
|
177
|
+
export function cronToAwsCron(schedule) {
|
|
178
|
+
const [minute, hour, dom, month, dow] = schedule.trim().split(/\s+/);
|
|
179
|
+
const min = translateNumericField(minute);
|
|
180
|
+
const hr = translateNumericField(hour);
|
|
181
|
+
const mon = translateNumericField(month);
|
|
182
|
+
if (dow === "*") {
|
|
183
|
+
return `cron(${min} ${hr} ${translateNumericField(dom)} ${mon} ? *)`;
|
|
184
|
+
}
|
|
185
|
+
const awsDow = dow
|
|
186
|
+
.split(",")
|
|
187
|
+
.map((part) => {
|
|
188
|
+
const range = part.match(/^(\d)-(\d)$/);
|
|
189
|
+
if (range) return `${AWS_DOW[Number(range[1]) % 7]}-${AWS_DOW[Number(range[2]) % 7]}`;
|
|
190
|
+
return AWS_DOW[Number(part) % 7];
|
|
191
|
+
})
|
|
192
|
+
.join(",");
|
|
193
|
+
return `cron(${min} ${hr} ? ${mon} ${awsDow} *)`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// EventBridge Scheduler (unlike crontab/systemd/docker/k8s) can't run a shell command
|
|
197
|
+
// directly — it invokes a target ARN (Lambda/ECS/Step Functions). So this only gets
|
|
198
|
+
// the schedule right and leaves the target as a TODO, same spirit as the GitHub
|
|
199
|
+
// Actions prompt-mode TODO: get the user unstuck, don't guess their infra for them.
|
|
200
|
+
export function buildEventBridgeCommand(job, resolvedText, isPrompt) {
|
|
201
|
+
const body = buildShellBody(resolvedText, isPrompt);
|
|
202
|
+
return `# Generated by \`crondex deploy ${job.id} --target eventbridge\` from ${job.path}.
|
|
203
|
+
# EventBridge Scheduler invokes a target (Lambda/ECS/Step Functions) — it can't run a
|
|
204
|
+
# shell command directly. Point TODO_TARGET_ARN/TODO_ROLE_ARN at something that runs:
|
|
205
|
+
# ${body}
|
|
206
|
+
aws scheduler create-schedule \\
|
|
207
|
+
--name "${job.id}" \\
|
|
208
|
+
--schedule-expression "${cronToAwsCron(job.schedule)}" \\
|
|
209
|
+
--flexible-time-window '{"Mode":"OFF"}' \\
|
|
210
|
+
--target '{"Arn":"TODO_TARGET_ARN","RoleArn":"TODO_ROLE_ARN"}'
|
|
211
|
+
`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Cloud Scheduler accepts standard unix-cron directly (no conversion needed) but, like
|
|
215
|
+
// EventBridge, invokes an HTTP/Pub/Sub/App Engine target rather than running a shell
|
|
216
|
+
// command itself — --uri is left as a TODO for whatever endpoint actually runs it.
|
|
217
|
+
export function buildCloudSchedulerCommand(job, resolvedText, isPrompt) {
|
|
218
|
+
const body = buildShellBody(resolvedText, isPrompt);
|
|
219
|
+
return `# Generated by \`crondex deploy ${job.id} --target cloud-scheduler\` from ${job.path}.
|
|
220
|
+
# Cloud Scheduler invokes an HTTP endpoint (e.g. a Cloud Run job) — it can't run a
|
|
221
|
+
# shell command directly. Point TODO_HTTPS_ENDPOINT at something that runs:
|
|
222
|
+
# ${body}
|
|
223
|
+
gcloud scheduler jobs create http "${job.id}" \\
|
|
224
|
+
--schedule="${job.schedule}" \\
|
|
225
|
+
--uri="TODO_HTTPS_ENDPOINT" \\
|
|
226
|
+
--http-method=POST \\
|
|
227
|
+
--time-zone="${job.timezone ?? "UTC"}"
|
|
228
|
+
`;
|
|
44
229
|
}
|
|
45
230
|
|
|
46
231
|
// Builds a ready-to-commit GitHub Actions workflow file for the job. GitHub Actions
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wonsukchoi/crondex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "A public directory of pre-made, agent-editable cron jobs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"lib",
|
|
27
27
|
"jobs",
|
|
28
28
|
"schema",
|
|
29
|
+
"templates",
|
|
29
30
|
"catalog.json"
|
|
30
31
|
],
|
|
31
32
|
"engines": {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Copy this file to jobs/<category>/<id>.yaml and fill it in.
|
|
2
|
+
# Full field spec: ../schema/job.schema.json
|
|
3
|
+
# Delete any comment lines before submitting.
|
|
4
|
+
|
|
5
|
+
id: your-job-id # slug, matches filename without extension: ^[a-z0-9]+(-[a-z0-9]+)*$
|
|
6
|
+
version: 1 # bump when prompt/command behavior changes; not for wording-only edits
|
|
7
|
+
name: Human-Readable Job Name
|
|
8
|
+
description: >
|
|
9
|
+
Plain language, no jargon: what this does in one sentence, then
|
|
10
|
+
"Use this if you ..." in a second sentence. Write for someone who's
|
|
11
|
+
never seen a cron expression, not for another agent.
|
|
12
|
+
category: devops # or productivity, or a new folder name
|
|
13
|
+
tags: [tag-one, tag-two]
|
|
14
|
+
schedule: "0 9 * * *" # standard 5-field cron
|
|
15
|
+
timezone: "UTC" # IANA tz, e.g. America/Los_Angeles
|
|
16
|
+
|
|
17
|
+
# runner: pick one
|
|
18
|
+
# shell — command only, zero tokens, deterministic. Use when the
|
|
19
|
+
# check/action is fully scriptable and needs no judgment.
|
|
20
|
+
# agent-prompt — prompt only, costs tokens. Use when the job needs
|
|
21
|
+
# synthesis, prioritization, drafting, or varies too much
|
|
22
|
+
# by connector/provider for a generic script.
|
|
23
|
+
# hybrid — both. Use when a raw-data script exists but an LLM pass
|
|
24
|
+
# adds real value (interpretation, narrative, judgment).
|
|
25
|
+
# Hybrid REQUIRES script_note explaining the tradeoff.
|
|
26
|
+
runner: shell
|
|
27
|
+
|
|
28
|
+
command: >
|
|
29
|
+
echo "replace with your shell command; supports {{variable}} placeholders"
|
|
30
|
+
|
|
31
|
+
# prompt: |
|
|
32
|
+
# Only for runner: agent-prompt or hybrid.
|
|
33
|
+
# Task text handed to an LLM agent each run. Supports {{variable}}
|
|
34
|
+
# placeholders resolved from `variables` below.
|
|
35
|
+
|
|
36
|
+
# script_note: >
|
|
37
|
+
# Only for runner: hybrid. One or two sentences: what `command` gives you
|
|
38
|
+
# for free vs. what only `prompt` adds (e.g. "no narrative synthesis").
|
|
39
|
+
|
|
40
|
+
variables:
|
|
41
|
+
example_var:
|
|
42
|
+
default: "some-default"
|
|
43
|
+
description: What this variable controls.
|
|
44
|
+
|
|
45
|
+
compatible_agents: [claude, codex, hermes, openclaw, generic]
|
|
46
|
+
|
|
47
|
+
notes: >
|
|
48
|
+
Optional: caveats, prerequisites (e.g. requires `gh` CLI), safety notes
|
|
49
|
+
(e.g. destructive, draft-only, read-only).
|