@wonsukchoi/crondex 0.13.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
@@ -25,7 +25,7 @@ access.
25
25
  ```bash
26
26
  npx @wonsukchoi/crondex recommend "warn me before my SSL cert expires"
27
27
  npx @wonsukchoi/crondex show ssl-cert-expiry-check
28
- npx @wonsukchoi/crondex add ssl-cert-expiry-check --dest ./cron/ssl-cert-expiry-check.yaml
28
+ npx @wonsukchoi/crondex deploy ssl-cert-expiry-check --var host=example.com
29
29
  ```
30
30
 
31
31
  - `recommend "<what you want>"` — find the closest matching job (zero
@@ -35,6 +35,22 @@ npx @wonsukchoi/crondex add ssl-cert-expiry-check --dest ./cron/ssl-cert-expiry-
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
+ - `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 ...]`
41
+ — turn a job into something that actually runs: prints a ready crontab
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
38
54
 
39
55
  Add `--json` to `list`/`categories`/`show`/`recommend` for machine-readable
40
56
  output — useful when an agent is parsing the result programmatically
@@ -91,13 +107,13 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
91
107
  `jobs/<category>/` directly.
92
108
 
93
109
  <!-- BEGIN JOB SUMMARY -->
94
- 257 jobs across 44 categories:
110
+ 271 jobs across 44 categories:
95
111
 
96
112
  | category | jobs | description |
97
113
  |---|---|---|
98
114
  | `agriculture` | 5 | Farm operations — weather risk, irrigation, equipment, market prices. |
99
- | `automotive` | 3 | Repair shop workflow — repair orders, parts, loaners. |
100
- | `childcare` | 3 | Daycare compliance and ops — ratios, immunizations, tuition. |
115
+ | `automotive` | 5 | Repair shop workflow — repair orders, parts, loaners. |
116
+ | `childcare` | 5 | Daycare compliance and ops — ratios, immunizations, tuition. |
101
117
  | `construction` | 5 | Job site compliance — permits, inspections, change orders, rentals. |
102
118
  | `content` | 13 | Site/content health — SEO, broken links, freshness, repurposing. |
103
119
  | `creator` | 5 | Influencer/creator ops — content calendar, cross-posting, sponsorships. |
@@ -107,10 +123,10 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
107
123
  | `education` | 5 | Classroom ops — grading, attendance, deadlines, permission slips. |
108
124
  | `events` | 5 | Event planning — budget, RSVPs, staffing, vendors, day-of check-in. |
109
125
  | `finance` | 10 | Personal/business finance — budgets, invoices, taxes, subscriptions. |
110
- | `fitness` | 3 | Gym/studio ops — memberships, class utilization, equipment. |
126
+ | `fitness` | 5 | Gym/studio ops — memberships, class utilization, equipment. |
111
127
  | `fleet` | 5 | Vehicle fleet compliance — maintenance, registration, licenses, fuel. |
112
128
  | `gaming` | 5 | Streaming and community server ops — schedules, patches, tournaments. |
113
- | `government` | 3 | Public-sector ops — records requests, permits, constituent casework. |
129
+ | `government` | 5 | Public-sector ops — records requests, permits, constituent casework. |
114
130
  | `growth` | 5 | Lifecycle marketing — churn, trials, onboarding, reviews. |
115
131
  | `healthcare` | 5 | Clinic ops — appointments, recalls, licenses, lab results. |
116
132
  | `hiring` | 5 | Recruiting pipeline — candidates, offers, interviews, reqs. |
@@ -131,14 +147,14 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
131
147
  | `productivity` | 9 | Work habits — inbox, standups, focus, meetings, reports. |
132
148
  | `realestate` | 5 | Property management — leases, rent, vacancy, inspections, tax. |
133
149
  | `restaurant` | 5 | Kitchen/FOH ops — food cost, labor cost, waste, inspections. |
134
- | `retail` | 3 | Store ops — till reconciliation, opening checklist, scheduling. |
150
+ | `retail` | 5 | Store ops — till reconciliation, opening checklist, scheduling. |
135
151
  | `sales` | 5 | Pipeline ops — leads, deals, quota, CRM sync. |
136
152
  | `security` | 14 | Security posture — keys, certs, access, scans, firewalls. |
137
153
  | `support` | 5 | Helpdesk ops — SLA, backlog, CSAT, agent workload. |
138
154
  | `team` | 5 | Team ops — 1:1s, on-call, PTO, anniversaries. |
139
155
  | `travel` | 5 | Trip logistics — flights, passports, visas, insurance, miles. |
140
- | `veterinary` | 3 | Clinic ops for animals — vaccines, controlled substances, boarding. |
141
- | `warehousing` | 3 | Warehouse ops — dock scheduling, pick/pack errors, climate control. |
156
+ | `veterinary` | 5 | Clinic ops for animals — vaccines, controlled substances, boarding. |
157
+ | `warehousing` | 5 | Warehouse ops — dock scheduling, pick/pack errors, climate control. |
142
158
  <!-- END JOB SUMMARY -->
143
159
 
144
160
  ---
@@ -148,12 +164,12 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
148
164
  ```
149
165
  crondex/
150
166
  ├── llms.txt agent-discovery manifest (llms.txt convention)
151
- ├── bin/crondex.js CLI: list / categories / show / add / recommend / init
152
- ├── lib/recommend.js recommend's scoring logic (unit tested in test/)
167
+ ├── bin/crondex.js CLI: list / categories / show / add / recommend / init / update / deploy / uninstall
168
+ ├── lib/ recommend, deploy, and catalog-building logic (unit tested in test/)
153
169
  ├── catalog.json generated index of every job — read this first
154
170
  ├── schema/job.schema.json spec every job file follows
155
171
  ├── jobs/ one YAML per job, grouped by category subdirectory
156
- └── scripts/ build-catalog.js, validate-jobs.js, lint-shell.js
172
+ └── scripts/ build-catalog.js, validate-jobs.js, lint-shell.js, check-duplicates.js, smoke-test.js
157
173
  ```
158
174
 
159
175
  ---
package/bin/crondex.js CHANGED
@@ -1,10 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  // CLI over catalog.json — browse jobs, read one, or pull one into your project.
3
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
4
- import { join } from "node:path";
3
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { execFileSync } from "node:child_process";
5
6
  import yaml from "js-yaml";
6
7
  import { tokenize, rankJobs } from "../lib/recommend.js";
7
8
  import { CATEGORY_DESCRIPTIONS } from "../lib/category-descriptions.js";
9
+ import {
10
+ resolveVariables,
11
+ substitutePlaceholders,
12
+ pickMode,
13
+ buildCrontabLine,
14
+ buildGithubActionsWorkflow,
15
+ buildSystemdUnits,
16
+ buildDockerArtifacts,
17
+ } from "../lib/deploy.js";
8
18
 
9
19
  const ROOT = new URL("..", import.meta.url).pathname;
10
20
  const CATALOG = JSON.parse(readFileSync(join(ROOT, "catalog.json"), "utf8"));
@@ -21,6 +31,19 @@ function hasFlag(name) {
21
31
  return args.includes(`--${name}`);
22
32
  }
23
33
 
34
+ // Collects every `--var name=value` occurrence (the flag can repeat).
35
+ function varOverrides() {
36
+ const overrides = {};
37
+ for (let i = 0; i < args.length; i++) {
38
+ if (args[i] !== "--var") continue;
39
+ const pair = args[i + 1] ?? "";
40
+ const eq = pair.indexOf("=");
41
+ if (eq === -1) continue;
42
+ overrides[pair.slice(0, eq)] = pair.slice(eq + 1);
43
+ }
44
+ return overrides;
45
+ }
46
+
24
47
  function printJson(value) {
25
48
  console.log(JSON.stringify(value, null, 2));
26
49
  }
@@ -44,6 +67,11 @@ Usage:
44
67
  crondex add <id> [--dest <path>]
45
68
  crondex recommend "<what you want done>" [--limit <n>] [--json]
46
69
  crondex init <id> [--category <name>] [--dest <path>]
70
+ crondex update <path>
71
+ crondex deploy <id> [--target crontab|github-actions|systemd|docker] [--mode script|prompt]
72
+ [--var name=value ...] [--dest <path>] [--install]
73
+ crondex deploy --list-installed [--json]
74
+ crondex uninstall <id>
47
75
 
48
76
  Examples:
49
77
  crondex list --category devops
@@ -52,10 +80,34 @@ Examples:
52
80
  crondex add backup-reminder --dest ./cron/backup-reminder.yaml
53
81
  crondex recommend "warn me before my SSL cert expires"
54
82
  crondex init ssl-cert-expiry-check --category security
83
+ crondex update ./cron/backup-reminder.yaml
84
+ crondex deploy ssl-cert-expiry-check --var host=example.com --var port=443
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
55
90
 
56
91
  Add --json to list/categories/show/recommend for machine-readable output —
57
92
  useful when an agent is parsing crondex's output programmatically instead
58
93
  of a human reading it.
94
+
95
+ deploy turns a job into something you can actually run: --target crontab
96
+ (default) prints a ready crontab line, or installs it into your own
97
+ crontab with --install; --target github-actions writes a scheduled
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.
59
111
  `);
60
112
  }
61
113
 
@@ -168,6 +220,150 @@ function init(id) {
168
220
  console.log(`wrote ${dest} — fill in the fields, then \`npm run validate\` (see CONTRIBUTING.md to submit it upstream).`);
169
221
  }
170
222
 
223
+ function readCrontab() {
224
+ try {
225
+ return execFileSync("crontab", ["-l"], { encoding: "utf8" });
226
+ } catch {
227
+ return "";
228
+ }
229
+ }
230
+
231
+ function installCrontabLine(id, line) {
232
+ const marker = `# crondex:${id}`;
233
+ const kept = readCrontab()
234
+ .split("\n")
235
+ .filter((l) => l.trim().length > 0 && !l.includes(marker));
236
+ const updated = [...kept, line].join("\n") + "\n";
237
+ execFileSync("crontab", ["-"], { input: updated });
238
+ }
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
+
295
+ function deploy(id) {
296
+ if (id === undefined && hasFlag("list-installed")) return listInstalled();
297
+ const meta = findJob(id);
298
+ const doc = yaml.load(readFileSync(join(ROOT, meta.path), "utf8"));
299
+ doc.path = meta.path;
300
+ const target = flag("target") ?? "crontab";
301
+ const mode = pickMode(doc, flag("mode"));
302
+
303
+ if (mode === "prompt" && !doc.prompt) {
304
+ console.error(`"${id}" has no prompt to deploy (runner: ${doc.runner}). Try without --mode prompt.`);
305
+ process.exit(1);
306
+ }
307
+ if (mode === "script" && !doc.command) {
308
+ console.error(`"${id}" has no command to deploy (runner: ${doc.runner}). Try --mode prompt.`);
309
+ process.exit(1);
310
+ }
311
+
312
+ const values = resolveVariables(doc, varOverrides());
313
+ const command = doc.command ? substitutePlaceholders(doc.command, values) : undefined;
314
+ const prompt = doc.prompt ? substitutePlaceholders(doc.prompt, values) : undefined;
315
+
316
+ if (target === "crontab") {
317
+ const line = buildCrontabLine(doc, mode === "prompt" ? prompt : command, mode === "prompt");
318
+ if (hasFlag("install")) {
319
+ installCrontabLine(id, line);
320
+ console.log(`installed into your crontab (replacing any previous "${id}" entry):`);
321
+ console.log(line);
322
+ } else {
323
+ console.log(line);
324
+ }
325
+ } else if (target === "github-actions") {
326
+ const workflow = buildGithubActionsWorkflow(doc, { command, prompt, mode });
327
+ const dest = flag("dest") ?? join(".github/workflows", `${id}.yml`);
328
+ if (existsSync(dest)) {
329
+ console.error(`${dest} already exists — refusing to overwrite. Pass --dest to choose another path.`);
330
+ process.exit(1);
331
+ }
332
+ mkdirSync(dirname(dest), { recursive: true });
333
+ writeFileSync(dest, workflow);
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}`);
361
+ } else {
362
+ console.error(`unknown --target "${target}" — use "crontab", "github-actions", "systemd", or "docker".`);
363
+ process.exit(1);
364
+ }
365
+ }
366
+
171
367
  function add(id) {
172
368
  const meta = findJob(id);
173
369
  const dest = flag("dest") ?? `./${id}.yaml`;
@@ -214,6 +410,27 @@ switch (cmd) {
214
410
  }
215
411
  recommend(args[0]);
216
412
  break;
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":
428
+ if (!args[0]) {
429
+ console.error("usage: crondex update <path>");
430
+ process.exit(1);
431
+ }
432
+ update(args[0]);
433
+ break;
217
434
  default:
218
435
  printHelp();
219
436
  }