@wonsukchoi/crondex 0.16.0 → 0.18.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
@@ -30,13 +30,18 @@ npx @wonsukchoi/crondex deploy ssl-cert-expiry-check --var host=example.com
30
30
 
31
31
  - `recommend "<what you want>"` — find the closest matching job (zero
32
32
  tokens, no network call, so an agent can check before writing one from
33
- scratch)
33
+ scratch). Matching handles plurals, a small catalog-grounded synonym set
34
+ (e.g. "notify"/"warn"/"remind" all match jobs tagged `reminder`), and
35
+ falls back to fuzzy (edit-distance) matching on typos when nothing
36
+ matches exactly.
34
37
  - `list [--category x] [--tag y]` / `categories` — browse everything
35
38
  - `show <id>` — print a job's full YAML
36
39
  - `add <id> [--dest path]` — copy it into your project to edit
37
40
  - `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
41
+ - `update <path> [--dry-run]` — re-pull a job you already `add`ed/`init`ed
42
+ (matched by its `id` field) against the current catalog, print a diff of
43
+ what changed, and overwrite it in place. `--dry-run` shows the diff
44
+ without applying it.
40
45
  - `deploy <id> [--target crontab|github-actions|systemd|docker|k8s-cronjob|eventbridge|cloud-scheduler] [--var name=value ...]`
41
46
  — turn a job into something that actually runs: prints a ready crontab
42
47
  line (or installs it into your own crontab with `--install`), writes a
@@ -170,7 +175,7 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
170
175
  crondex/
171
176
  ├── llms.txt agent-discovery manifest (llms.txt convention)
172
177
  ├── bin/crondex.js CLI: list / categories / show / add / recommend / init / update / deploy / uninstall
173
- ├── lib/ recommend, deploy, and catalog-building logic (unit tested in test/)
178
+ ├── lib/ recommend, deploy, diff, and catalog-building logic (unit tested in test/)
174
179
  ├── catalog.json generated index of every job — read this first
175
180
  ├── schema/job.schema.json spec every job file follows
176
181
  ├── jobs/ one YAML per job, grouped by category subdirectory
package/bin/crondex.js CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  buildEventBridgeCommand,
19
19
  buildCloudSchedulerCommand,
20
20
  } from "../lib/deploy.js";
21
+ import { formatDiff } from "../lib/diff.js";
21
22
 
22
23
  const ROOT = new URL("..", import.meta.url).pathname;
23
24
  const CATALOG = JSON.parse(readFileSync(join(ROOT, "catalog.json"), "utf8"));
@@ -70,7 +71,7 @@ Usage:
70
71
  crondex add <id> [--dest <path>]
71
72
  crondex recommend "<what you want done>" [--limit <n>] [--json]
72
73
  crondex init <id> [--category <name>] [--dest <path>]
73
- crondex update <path>
74
+ crondex update <path> [--dry-run]
74
75
  crondex deploy <id> [--target crontab|github-actions|systemd|docker|k8s-cronjob|
75
76
  eventbridge|cloud-scheduler] [--mode script|prompt]
76
77
  [--var name=value ...] [--dest <path>] [--install]
@@ -85,6 +86,7 @@ Examples:
85
86
  crondex recommend "warn me before my SSL cert expires"
86
87
  crondex init ssl-cert-expiry-check --category security
87
88
  crondex update ./cron/backup-reminder.yaml
89
+ crondex update ./cron/backup-reminder.yaml --dry-run
88
90
  crondex deploy ssl-cert-expiry-check --var host=example.com --var port=443
89
91
  crondex deploy repo-health-check --target github-actions
90
92
  crondex deploy repo-health-check --target systemd --dest ./systemd
@@ -120,7 +122,8 @@ deploy --list-installed shows every crondex-managed line in your crontab
120
122
  removes one of those by id.
121
123
 
122
124
  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.
125
+ against the current catalog, prints a diff of what changed, and overwrites
126
+ the local file in place. Pass --dry-run to see the diff without applying it.
124
127
  `);
125
128
  }
126
129
 
@@ -297,10 +300,17 @@ function update(path) {
297
300
  }
298
301
  const meta = findJob(localDoc.id);
299
302
  const latestRaw = readFileSync(join(ROOT, meta.path), "utf8");
300
- if (latestRaw === localRaw) {
303
+ const diff = formatDiff(localRaw, latestRaw);
304
+ if (!diff) {
301
305
  console.log(`${path} is already up to date with "${localDoc.id}".`);
302
306
  return;
303
307
  }
308
+ console.log(diff);
309
+ console.log();
310
+ if (hasFlag("dry-run")) {
311
+ console.log(`${path} differs from the catalog (shown above) — rerun without --dry-run to apply.`);
312
+ return;
313
+ }
304
314
  writeFileSync(path, latestRaw);
305
315
  console.log(`updated ${path} to the latest "${localDoc.id}" from the catalog.`);
306
316
  }
package/lib/diff.js ADDED
@@ -0,0 +1,66 @@
1
+ // Dependency-free line diff — used by `crondex update` to show what actually changed
2
+ // in a catalog job instead of just "it changed". Job files are small (tens of lines),
3
+ // so a plain O(n*m) LCS table is plenty fast; no need for a real Myers-diff package.
4
+
5
+ // Returns the line-by-line edit script as {type: "context"|"add"|"remove", line}.
6
+ export function diffLines(oldText, newText) {
7
+ const a = oldText.split("\n");
8
+ const b = newText.split("\n");
9
+ const n = a.length;
10
+ const m = b.length;
11
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
12
+ for (let i = n - 1; i >= 0; i--) {
13
+ for (let j = m - 1; j >= 0; j--) {
14
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
15
+ }
16
+ }
17
+ const ops = [];
18
+ let i = 0;
19
+ let j = 0;
20
+ while (i < n && j < m) {
21
+ if (a[i] === b[j]) {
22
+ ops.push({ type: "context", line: a[i] });
23
+ i++;
24
+ j++;
25
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
26
+ ops.push({ type: "remove", line: a[i] });
27
+ i++;
28
+ } else {
29
+ ops.push({ type: "add", line: b[j] });
30
+ j++;
31
+ }
32
+ }
33
+ while (i < n) ops.push({ type: "remove", line: a[i++] });
34
+ while (j < m) ops.push({ type: "add", line: b[j++] });
35
+ return ops;
36
+ }
37
+
38
+ // Renders the edit script as a compact +/-/context diff, collapsing unchanged runs
39
+ // longer than 2*context into a single "..." marker so a one-line change in a
40
+ // 100-line job doesn't dump the whole file. Returns "" when the texts are identical.
41
+ export function formatDiff(oldText, newText, { context = 2 } = {}) {
42
+ const ops = diffLines(oldText, newText);
43
+ const added = ops.filter((o) => o.type === "add").length;
44
+ const removed = ops.filter((o) => o.type === "remove").length;
45
+ if (!added && !removed) return "";
46
+
47
+ const keep = new Array(ops.length).fill(false);
48
+ ops.forEach((op, idx) => {
49
+ if (op.type === "context") return;
50
+ for (let k = Math.max(0, idx - context); k <= Math.min(ops.length - 1, idx + context); k++) keep[k] = true;
51
+ });
52
+
53
+ const lines = [`${added} added, ${removed} removed`];
54
+ let skipping = false;
55
+ ops.forEach((op, idx) => {
56
+ if (!keep[idx]) {
57
+ if (!skipping) lines.push(" ...");
58
+ skipping = true;
59
+ return;
60
+ }
61
+ skipping = false;
62
+ const prefix = op.type === "add" ? "+ " : op.type === "remove" ? "- " : " ";
63
+ lines.push(prefix + op.line);
64
+ });
65
+ return lines.join("\n");
66
+ }
package/lib/recommend.js CHANGED
@@ -16,13 +16,81 @@ export function stem(word) {
16
16
  return word;
17
17
  }
18
18
 
19
+ // Groups of interchangeable words, grounded in the catalog's actual vocabulary (its
20
+ // most common tags/description verbs) rather than a generic thesaurus — this closes
21
+ // the gap where a query uses a reasonable synonym the job authors didn't happen to
22
+ // write (e.g. "notify" vs. a job tagged "reminder"). Every word in a group maps to
23
+ // the group's first entry, applied on both the query and the job's own fields so
24
+ // either side using a different synonym still lines up.
25
+ const SYNONYM_GROUPS = [
26
+ ["alert", "warn", "notify", "notification", "remind", "reminder"],
27
+ ["check", "monitor", "watch", "scan", "inspect", "verify"],
28
+ ["audit", "review", "compliance"],
29
+ ["expire", "expiry", "expiration"],
30
+ ["backup", "snapshot"],
31
+ ["cost", "spend", "spending", "budget", "expense", "billing"],
32
+ ["fail", "failure", "error", "crash", "outage"],
33
+ ["delete", "remove", "cleanup", "purge", "clear"],
34
+ ["update", "upgrade", "refresh", "sync"],
35
+ ["duplicate", "dedupe", "dup"],
36
+ ["deadline", "due"],
37
+ ["schedule", "scheduling", "calendar", "planning"],
38
+ ["drift", "diff", "change", "changed"],
39
+ ["renew", "renewal"],
40
+ ["followup", "follow-up"],
41
+ ["invoice", "billing", "payment"],
42
+ ["stale", "outdated", "old"],
43
+ ];
44
+
45
+ const SYNONYM_MAP = new Map();
46
+ for (const group of SYNONYM_GROUPS) {
47
+ for (const word of group) SYNONYM_MAP.set(word, group[0]);
48
+ }
49
+
50
+ export function canonicalize(word) {
51
+ return SYNONYM_MAP.get(word) ?? word;
52
+ }
53
+
19
54
  export function tokenize(text) {
20
55
  return text
21
56
  .toLowerCase()
22
57
  .replace(/[^a-z0-9\s-]/g, " ")
23
58
  .split(/[\s-]+/)
24
59
  .filter((w) => w.length > 1 && !STOPWORDS.has(w))
25
- .map(stem);
60
+ .map(stem)
61
+ .map(canonicalize);
62
+ }
63
+
64
+ // Bounded edit distance — used only as a fallback when no exact token match exists,
65
+ // to catch typos/near-misses (e.g. "expiery" vs "expiry") without embeddings or a
66
+ // spellcheck dependency. Short-circuits past `max` so it stays cheap across a whole
67
+ // catalog scan.
68
+ export function editDistance(a, b, max = 2) {
69
+ if (Math.abs(a.length - b.length) > max) return max + 1;
70
+ const prev = new Array(b.length + 1);
71
+ const curr = new Array(b.length + 1);
72
+ for (let j = 0; j <= b.length; j++) prev[j] = j;
73
+ for (let i = 1; i <= a.length; i++) {
74
+ curr[0] = i;
75
+ let rowMin = curr[0];
76
+ for (let j = 1; j <= b.length; j++) {
77
+ curr[j] = a[i - 1] === b[j - 1] ? prev[j - 1] : 1 + Math.min(prev[j - 1], prev[j], curr[j - 1]);
78
+ rowMin = Math.min(rowMin, curr[j]);
79
+ }
80
+ if (rowMin > max) return max + 1;
81
+ for (let j = 0; j <= b.length; j++) prev[j] = curr[j];
82
+ }
83
+ return prev[b.length];
84
+ }
85
+
86
+ // A query token counts as a fuzzy hit against a field token if they're a short edit
87
+ // distance apart — only tried once no exact match exists (see scoreJob), and only for
88
+ // words long enough that a 1-2 character slip is plausibly a typo rather than a
89
+ // genuinely different short word ("ssl" vs "sql" would otherwise false-positive).
90
+ function isFuzzyMatch(queryToken, fieldToken) {
91
+ if (queryToken.length < 5 || fieldToken.length < 5) return false;
92
+ const maxDistance = queryToken.length >= 8 ? 2 : 1;
93
+ return editDistance(queryToken, fieldToken, maxDistance) <= maxDistance;
26
94
  }
27
95
 
28
96
  // id and name are near-duplicates of each other (id is just the slugified
@@ -33,7 +101,7 @@ export const RECOMMEND_WEIGHTS = { tags: 4, title: 3, category: 2, description:
33
101
 
34
102
  export function scoreJob(queryTokens, job) {
35
103
  const fields = {
36
- tags: job.tags.map((t) => stem(t.toLowerCase())),
104
+ tags: job.tags.map((t) => canonicalize(stem(t.toLowerCase()))),
37
105
  title: [...new Set([...tokenize(job.name), ...tokenize(job.id.replace(/-/g, " "))])],
38
106
  category: tokenize(job.category ?? ""),
39
107
  description: tokenize(job.description ?? ""),
@@ -45,6 +113,11 @@ export function scoreJob(queryTokens, job) {
45
113
  if (fields[field].includes(qt)) {
46
114
  score += weight;
47
115
  matched.add(qt);
116
+ } else if (fields[field].some((ft) => isFuzzyMatch(qt, ft))) {
117
+ // Fuzzy hits count for half weight (rounded up) — a real match still
118
+ // outranks a typo-distance one for the same field.
119
+ score += Math.ceil(weight / 2);
120
+ matched.add(qt);
48
121
  }
49
122
  }
50
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonsukchoi/crondex",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "A public directory of pre-made, agent-editable cron jobs.",
5
5
  "type": "module",
6
6
  "license": "MIT",