@wonsukchoi/crondex 0.14.0 → 0.15.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 CHANGED
@@ -35,14 +35,22 @@ 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
- - `deploy <id> [--target crontab|github-actions] [--var name=value ...]`
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] [--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`), or writes
41
- a scheduled GitHub Actions workflow file. `--var` overrides a variable's
42
- default (repeatable). `hybrid` jobs deploy their `command` by default;
43
- pass `--mode prompt` to deploy the `prompt` side instead for a
44
- crontab target that means wiring in your own agent CLI via a
45
- `CRONDEX_AGENT_CLI` env var, since crondex can't guess its syntax.
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, or writes a Dockerfile + `/etc/cron.d`
45
+ entry that runs the job on its own schedule in a container. `--var`
46
+ overrides a variable's default (repeatable). `hybrid` jobs deploy their
47
+ `command` by default; pass `--mode prompt` to deploy the `prompt` side
48
+ instead — for crontab/systemd/docker targets that means wiring in your
49
+ own agent CLI via a `CRONDEX_AGENT_CLI` env var, since crondex can't
50
+ guess its syntax.
51
+ - `deploy --list-installed` — show every crondex-managed line in your
52
+ crontab (the ones left by `--install`)
53
+ - `uninstall <id>` — remove one of those installed crontab entries
46
54
 
47
55
  Add `--json` to `list`/`categories`/`show`/`recommend` for machine-readable
48
56
  output — useful when an agent is parsing the result programmatically
@@ -156,7 +164,7 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
156
164
  ```
157
165
  crondex/
158
166
  ├── llms.txt agent-discovery manifest (llms.txt convention)
159
- ├── bin/crondex.js CLI: list / categories / show / add / recommend / init / deploy
167
+ ├── bin/crondex.js CLI: list / categories / show / add / recommend / init / update / deploy / uninstall
160
168
  ├── lib/ recommend, deploy, and catalog-building logic (unit tested in test/)
161
169
  ├── catalog.json generated index of every job — read this first
162
170
  ├── schema/job.schema.json spec every job file follows
package/bin/crondex.js CHANGED
@@ -6,7 +6,15 @@ 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 { resolveVariables, substitutePlaceholders, pickMode, buildCrontabLine, buildGithubActionsWorkflow } from "../lib/deploy.js";
9
+ import {
10
+ resolveVariables,
11
+ substitutePlaceholders,
12
+ pickMode,
13
+ buildCrontabLine,
14
+ buildGithubActionsWorkflow,
15
+ buildSystemdUnits,
16
+ buildDockerArtifacts,
17
+ } from "../lib/deploy.js";
10
18
 
11
19
  const ROOT = new URL("..", import.meta.url).pathname;
12
20
  const CATALOG = JSON.parse(readFileSync(join(ROOT, "catalog.json"), "utf8"));
@@ -59,8 +67,11 @@ Usage:
59
67
  crondex add <id> [--dest <path>]
60
68
  crondex recommend "<what you want done>" [--limit <n>] [--json]
61
69
  crondex init <id> [--category <name>] [--dest <path>]
62
- crondex deploy <id> [--target crontab|github-actions] [--mode script|prompt]
70
+ crondex update <path>
71
+ crondex deploy <id> [--target crontab|github-actions|systemd|docker] [--mode script|prompt]
63
72
  [--var name=value ...] [--dest <path>] [--install]
73
+ crondex deploy --list-installed [--json]
74
+ crondex uninstall <id>
64
75
 
65
76
  Examples:
66
77
  crondex list --category devops
@@ -69,8 +80,13 @@ Examples:
69
80
  crondex add backup-reminder --dest ./cron/backup-reminder.yaml
70
81
  crondex recommend "warn me before my SSL cert expires"
71
82
  crondex init ssl-cert-expiry-check --category security
83
+ crondex update ./cron/backup-reminder.yaml
72
84
  crondex deploy ssl-cert-expiry-check --var host=example.com --var port=443
73
85
  crondex deploy repo-health-check --target github-actions
86
+ crondex deploy repo-health-check --target systemd --dest ./systemd
87
+ crondex deploy repo-health-check --target docker --dest ./docker/repo-health-check
88
+ crondex deploy --list-installed
89
+ crondex uninstall ssl-cert-expiry-check
74
90
 
75
91
  Add --json to list/categories/show/recommend for machine-readable output —
76
92
  useful when an agent is parsing crondex's output programmatically instead
@@ -79,9 +95,19 @@ of a human reading it.
79
95
  deploy turns a job into something you can actually run: --target crontab
80
96
  (default) prints a ready crontab line, or installs it into your own
81
97
  crontab with --install; --target github-actions writes a scheduled
82
- workflow file (default .github/workflows/<id>.yml). --var overrides a
83
- job's variable defaults (repeatable). hybrid jobs default to --mode script
84
- (zero tokens); pass --mode prompt to deploy the agent-prompt side instead.
98
+ workflow file (default .github/workflows/<id>.yml); --target systemd
99
+ writes a <id>.service + <id>.timer pair (default ./systemd/); --target
100
+ docker writes a Dockerfile + crontab pair that runs the job on its own
101
+ schedule in a container (default ./docker/<id>/). --var overrides a job's
102
+ variable defaults (repeatable). hybrid jobs default to --mode script (zero
103
+ tokens); pass --mode prompt to deploy the agent-prompt side instead.
104
+
105
+ deploy --list-installed shows every crondex-managed line in your crontab
106
+ (the ones with a "# crondex:<id>" marker, as left by --install). uninstall
107
+ removes one of those by id.
108
+
109
+ update re-pulls a job you already added/inited (matched by its "id" field)
110
+ against the current catalog and overwrites the local file in place.
85
111
  `);
86
112
  }
87
113
 
@@ -194,22 +220,80 @@ function init(id) {
194
220
  console.log(`wrote ${dest} — fill in the fields, then \`npm run validate\` (see CONTRIBUTING.md to submit it upstream).`);
195
221
  }
196
222
 
197
- function installCrontabLine(id, line) {
198
- let existing = "";
223
+ function readCrontab() {
199
224
  try {
200
- existing = execFileSync("crontab", ["-l"], { encoding: "utf8" });
225
+ return execFileSync("crontab", ["-l"], { encoding: "utf8" });
201
226
  } catch {
202
- existing = "";
227
+ return "";
203
228
  }
229
+ }
230
+
231
+ function installCrontabLine(id, line) {
204
232
  const marker = `# crondex:${id}`;
205
- const kept = existing
233
+ const kept = readCrontab()
206
234
  .split("\n")
207
235
  .filter((l) => l.trim().length > 0 && !l.includes(marker));
208
236
  const updated = [...kept, line].join("\n") + "\n";
209
237
  execFileSync("crontab", ["-"], { input: updated });
210
238
  }
211
239
 
240
+ function uninstall(id) {
241
+ const marker = `# crondex:${id}`;
242
+ const existing = readCrontab();
243
+ if (!existing.includes(marker)) {
244
+ console.error(`no installed crontab entry for "${id}" — run "crondex deploy --list-installed" to see what's there.`);
245
+ process.exit(1);
246
+ }
247
+ const kept = existing.split("\n").filter((l) => l.trim().length > 0 && !l.includes(marker));
248
+ execFileSync("crontab", ["-"], { input: kept.join("\n") + "\n" });
249
+ console.log(`removed "${id}" from your crontab.`);
250
+ }
251
+
252
+ function listInstalled() {
253
+ const managed = readCrontab()
254
+ .split("\n")
255
+ .filter((l) => l.includes("# crondex:"));
256
+ if (hasFlag("json")) {
257
+ return printJson(
258
+ managed.map((l) => ({ id: l.match(/# crondex:(\S+)/)?.[1], line: l }))
259
+ );
260
+ }
261
+ if (!managed.length) {
262
+ console.log("no crondex-managed crontab entries installed.");
263
+ return;
264
+ }
265
+ for (const l of managed) console.log(l);
266
+ }
267
+
268
+ function update(path) {
269
+ if (!existsSync(path)) {
270
+ console.error(`${path} does not exist.`);
271
+ process.exit(1);
272
+ }
273
+ const localRaw = readFileSync(path, "utf8");
274
+ let localDoc;
275
+ try {
276
+ localDoc = yaml.load(localRaw);
277
+ } catch (e) {
278
+ console.error(`${path} isn't valid YAML: ${e.message}`);
279
+ process.exit(1);
280
+ }
281
+ if (!localDoc?.id) {
282
+ console.error(`${path} has no "id" field — can't match it to a catalog job.`);
283
+ process.exit(1);
284
+ }
285
+ const meta = findJob(localDoc.id);
286
+ const latestRaw = readFileSync(join(ROOT, meta.path), "utf8");
287
+ if (latestRaw === localRaw) {
288
+ console.log(`${path} is already up to date with "${localDoc.id}".`);
289
+ return;
290
+ }
291
+ writeFileSync(path, latestRaw);
292
+ console.log(`updated ${path} to the latest "${localDoc.id}" from the catalog.`);
293
+ }
294
+
212
295
  function deploy(id) {
296
+ if (id === undefined && hasFlag("list-installed")) return listInstalled();
213
297
  const meta = findJob(id);
214
298
  const doc = yaml.load(readFileSync(join(ROOT, meta.path), "utf8"));
215
299
  doc.path = meta.path;
@@ -248,8 +332,34 @@ function deploy(id) {
248
332
  mkdirSync(dirname(dest), { recursive: true });
249
333
  writeFileSync(dest, workflow);
250
334
  console.log(`wrote ${dest}`);
335
+ } else if (target === "systemd") {
336
+ const { service, timer } = buildSystemdUnits(doc, mode === "prompt" ? prompt : command, mode === "prompt");
337
+ const destDir = flag("dest") ?? "./systemd";
338
+ const serviceDest = join(destDir, `${id}.service`);
339
+ const timerDest = join(destDir, `${id}.timer`);
340
+ if (existsSync(serviceDest) || existsSync(timerDest)) {
341
+ console.error(`${serviceDest} or ${timerDest} already exists — refusing to overwrite. Pass --dest to choose another directory.`);
342
+ process.exit(1);
343
+ }
344
+ mkdirSync(destDir, { recursive: true });
345
+ writeFileSync(serviceDest, service);
346
+ writeFileSync(timerDest, timer);
347
+ console.log(`wrote ${serviceDest} and ${timerDest} — enable with:\n systemctl --user enable --now ${id}.timer`);
348
+ } else if (target === "docker") {
349
+ const { dockerfile, crontab } = buildDockerArtifacts(doc, mode === "prompt" ? prompt : command, mode === "prompt");
350
+ const destDir = flag("dest") ?? join("./docker", id);
351
+ const dockerfileDest = join(destDir, "Dockerfile");
352
+ const crontabDest = join(destDir, "crontab");
353
+ if (existsSync(dockerfileDest) || existsSync(crontabDest)) {
354
+ console.error(`${dockerfileDest} or ${crontabDest} already exists — refusing to overwrite. Pass --dest to choose another directory.`);
355
+ process.exit(1);
356
+ }
357
+ mkdirSync(destDir, { recursive: true });
358
+ writeFileSync(dockerfileDest, dockerfile);
359
+ writeFileSync(crontabDest, crontab);
360
+ console.log(`wrote ${dockerfileDest} and ${crontabDest} — build with:\n docker build -t ${id} ${destDir}`);
251
361
  } else {
252
- console.error(`unknown --target "${target}" — use "crontab" or "github-actions".`);
362
+ console.error(`unknown --target "${target}" — use "crontab", "github-actions", "systemd", or "docker".`);
253
363
  process.exit(1);
254
364
  }
255
365
  }
@@ -301,11 +411,25 @@ switch (cmd) {
301
411
  recommend(args[0]);
302
412
  break;
303
413
  case "deploy":
414
+ if (!args[0] && !hasFlag("list-installed")) {
415
+ 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]");
416
+ process.exit(1);
417
+ }
418
+ deploy(hasFlag("list-installed") ? undefined : args[0]);
419
+ break;
420
+ case "uninstall":
421
+ if (!args[0]) {
422
+ console.error("usage: crondex uninstall <id>");
423
+ process.exit(1);
424
+ }
425
+ uninstall(args[0]);
426
+ break;
427
+ case "update":
304
428
  if (!args[0]) {
305
- console.error("usage: crondex deploy <id> [--target crontab|github-actions] [--mode script|prompt] [--var name=value ...] [--dest <path>] [--install]");
429
+ console.error("usage: crondex update <path>");
306
430
  process.exit(1);
307
431
  }
308
- deploy(args[0]);
432
+ update(args[0]);
309
433
  break;
310
434
  default:
311
435
  printHelp();
package/lib/deploy.js CHANGED
@@ -43,6 +43,89 @@ export function buildCrontabLine(job, resolvedText, isPrompt) {
43
43
  return `${job.schedule} ${body} # crondex:${job.id}`;
44
44
  }
45
45
 
46
+ const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
47
+
48
+ // Numeric cron fields (minute/hour/day-of-month/month) map onto systemd calendar
49
+ // syntax almost unchanged — the one gotcha is `*/n`, which systemd requires written
50
+ // as `0/n` (a bare `*` can't carry a step). Comma lists and `a-b` ranges pass through.
51
+ function translateNumericField(field) {
52
+ return field.replace(/^\*\/(\d+)$/, "0/$1");
53
+ }
54
+
55
+ // Cron's day-of-week field is numeric (0-6 or 7, both Sun); systemd wants weekday
56
+ // names. Only handles the shapes crondex jobs actually use (digit, list, a-b range) —
57
+ // not the rarer `a-b/n` step-in-range cron syntax.
58
+ function translateWeekdayField(field) {
59
+ if (field === "*") return null;
60
+ return field
61
+ .split(",")
62
+ .map((part) => {
63
+ const range = part.match(/^(\d)-(\d)$/);
64
+ if (range) return `${DOW[Number(range[1]) % 7]}-${DOW[Number(range[2]) % 7]}`;
65
+ return DOW[Number(part) % 7];
66
+ })
67
+ .join(",");
68
+ }
69
+
70
+ // Converts a standard 5-field cron schedule into a systemd OnCalendar= expression.
71
+ export function cronToSystemdCalendar(schedule) {
72
+ const [minute, hour, dom, month, dow] = schedule.trim().split(/\s+/);
73
+ const weekday = translateWeekdayField(dow);
74
+ const date = `*-${translateNumericField(month)}-${translateNumericField(dom)}`;
75
+ const time = `${translateNumericField(hour)}:${translateNumericField(minute)}:00`;
76
+ return weekday ? `${weekday} ${date} ${time}` : `${date} ${time}`;
77
+ }
78
+
79
+ // Builds a systemd service+timer unit pair for the job. Prompt-mode jobs still defer
80
+ // to CRONDEX_AGENT_CLI (see buildCrontabLine) — systemd services run with a minimal
81
+ // environment, so the unit points at EnvironmentFile as the place to set it.
82
+ export function buildSystemdUnits(job, resolvedText, isPrompt) {
83
+ const flat = resolvedText.trim().replace(/\s*\n\s*/g, " ");
84
+ const escaped = escapeSingleQuotes(flat);
85
+ const body = isPrompt
86
+ ? `\${CRONDEX_AGENT_CLI:?set CRONDEX_AGENT_CLI to your agent CLI invocation, e.g. "claude -p"} '${escaped}'`
87
+ : `bash -lc '${escaped}'`;
88
+ const service = `[Unit]
89
+ Description=${job.name} (crondex:${job.id})
90
+
91
+ [Service]
92
+ Type=oneshot
93
+ ${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, `'"'"'`)}'
94
+ `;
95
+ const timer = `[Unit]
96
+ Description=${job.name} timer (crondex:${job.id})
97
+
98
+ [Timer]
99
+ OnCalendar=${cronToSystemdCalendar(job.schedule)}
100
+ Persistent=true
101
+
102
+ [Install]
103
+ WantedBy=timers.target
104
+ `;
105
+ return { service, timer };
106
+ }
107
+
108
+ // Builds a Dockerfile + /etc/cron.d entry that runs the job on its own schedule
109
+ // inside a container — for teams that want the job shipped as an image rather than
110
+ // installed on a host crontab or run via CI.
111
+ export function buildDockerArtifacts(job, resolvedText, isPrompt) {
112
+ const flat = resolvedText.trim().replace(/\s*\n\s*/g, " ");
113
+ const escaped = escapeSingleQuotes(flat);
114
+ const body = isPrompt
115
+ ? `\${CRONDEX_AGENT_CLI:?set CRONDEX_AGENT_CLI to your agent CLI invocation, e.g. "claude -p"} '${escaped}'`
116
+ : `bash -lc '${escaped}'`;
117
+ const crontab = `${job.schedule} root ${body} # crondex:${job.id}\n`;
118
+ const dockerfile = `# Generated by \`crondex deploy ${job.id} --target docker\` from ${job.path}.
119
+ FROM debian:bookworm-slim
120
+ RUN apt-get update && apt-get install -y --no-install-recommends cron bash ca-certificates \\
121
+ && rm -rf /var/lib/apt/lists/*
122
+ COPY crontab /etc/cron.d/crondex-job
123
+ RUN chmod 0644 /etc/cron.d/crondex-job && crontab /etc/cron.d/crondex-job && touch /var/log/cron.log
124
+ ${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"]
125
+ `;
126
+ return { dockerfile, crontab };
127
+ }
128
+
46
129
  // Builds a ready-to-commit GitHub Actions workflow file for the job. GitHub Actions
47
130
  // schedules always run in UTC regardless of the `cron:` string's intent, so a
48
131
  // non-UTC job gets a visible warning comment rather than silently firing at the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonsukchoi/crondex",
3
- "version": "0.14.0",
3
+ "version": "0.15.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).