@wonsukchoi/crondex 0.15.0 → 0.17.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 +18 -11
- package/bin/crondex.js +45 -8
- package/lib/deploy.js +121 -19
- package/lib/diff.js +66 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -35,19 +35,26 @@ 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
|
-
- `update <path
|
|
39
|
-
its `id` field) against the current catalog
|
|
40
|
-
|
|
38
|
+
- `update <path> [--dry-run]` — re-pull a job you already `add`ed/`init`ed
|
|
39
|
+
(matched by its `id` field) against the current catalog, print a diff of
|
|
40
|
+
what changed, and overwrite it in place. `--dry-run` shows the diff
|
|
41
|
+
without applying it.
|
|
42
|
+
- `deploy <id> [--target crontab|github-actions|systemd|docker|k8s-cronjob|eventbridge|cloud-scheduler] [--var name=value ...]`
|
|
41
43
|
— turn a job into something that actually runs: prints a ready crontab
|
|
42
44
|
line (or installs it into your own crontab with `--install`), writes a
|
|
43
45
|
scheduled GitHub Actions workflow file, writes a systemd
|
|
44
|
-
`<id>.service` + `<id>.timer` pair,
|
|
45
|
-
entry that runs the job on its own schedule in a container
|
|
46
|
-
|
|
47
|
-
`
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
`<id>.service` + `<id>.timer` pair, writes a Dockerfile + `/etc/cron.d`
|
|
47
|
+
entry that runs the job on its own schedule in a container, or writes a
|
|
48
|
+
self-contained `batch/v1` CronJob manifest (`k8s-cronjob`). `eventbridge`
|
|
49
|
+
and `cloud-scheduler` print a ready `aws`/`gcloud` CLI command instead —
|
|
50
|
+
those services invoke a target (Lambda/ECS/HTTP endpoint) rather than
|
|
51
|
+
running a shell command directly, so the command leaves that target as a
|
|
52
|
+
TODO for you to wire up. `--var` overrides a variable's default
|
|
53
|
+
(repeatable). `hybrid` jobs deploy their `command` by default; pass
|
|
54
|
+
`--mode prompt` to deploy the `prompt` side instead — for
|
|
55
|
+
crontab/systemd/docker/k8s targets that means wiring in your own agent
|
|
56
|
+
CLI via a `CRONDEX_AGENT_CLI` env var, since crondex can't guess its
|
|
57
|
+
syntax.
|
|
51
58
|
- `deploy --list-installed` — show every crondex-managed line in your
|
|
52
59
|
crontab (the ones left by `--install`)
|
|
53
60
|
- `uninstall <id>` — remove one of those installed crontab entries
|
|
@@ -165,7 +172,7 @@ tags, variables) use `crondex list`, `crondex recommend`, or browse
|
|
|
165
172
|
crondex/
|
|
166
173
|
├── llms.txt agent-discovery manifest (llms.txt convention)
|
|
167
174
|
├── 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/)
|
|
175
|
+
├── lib/ recommend, deploy, diff, and catalog-building logic (unit tested in test/)
|
|
169
176
|
├── catalog.json generated index of every job — read this first
|
|
170
177
|
├── schema/job.schema.json spec every job file follows
|
|
171
178
|
├── jobs/ one YAML per job, grouped by category subdirectory
|
package/bin/crondex.js
CHANGED
|
@@ -14,7 +14,11 @@ import {
|
|
|
14
14
|
buildGithubActionsWorkflow,
|
|
15
15
|
buildSystemdUnits,
|
|
16
16
|
buildDockerArtifacts,
|
|
17
|
+
buildK8sCronJob,
|
|
18
|
+
buildEventBridgeCommand,
|
|
19
|
+
buildCloudSchedulerCommand,
|
|
17
20
|
} from "../lib/deploy.js";
|
|
21
|
+
import { formatDiff } from "../lib/diff.js";
|
|
18
22
|
|
|
19
23
|
const ROOT = new URL("..", import.meta.url).pathname;
|
|
20
24
|
const CATALOG = JSON.parse(readFileSync(join(ROOT, "catalog.json"), "utf8"));
|
|
@@ -67,8 +71,9 @@ Usage:
|
|
|
67
71
|
crondex add <id> [--dest <path>]
|
|
68
72
|
crondex recommend "<what you want done>" [--limit <n>] [--json]
|
|
69
73
|
crondex init <id> [--category <name>] [--dest <path>]
|
|
70
|
-
crondex update <path>
|
|
71
|
-
crondex deploy <id> [--target crontab|github-actions|systemd|docker
|
|
74
|
+
crondex update <path> [--dry-run]
|
|
75
|
+
crondex deploy <id> [--target crontab|github-actions|systemd|docker|k8s-cronjob|
|
|
76
|
+
eventbridge|cloud-scheduler] [--mode script|prompt]
|
|
72
77
|
[--var name=value ...] [--dest <path>] [--install]
|
|
73
78
|
crondex deploy --list-installed [--json]
|
|
74
79
|
crondex uninstall <id>
|
|
@@ -81,10 +86,14 @@ Examples:
|
|
|
81
86
|
crondex recommend "warn me before my SSL cert expires"
|
|
82
87
|
crondex init ssl-cert-expiry-check --category security
|
|
83
88
|
crondex update ./cron/backup-reminder.yaml
|
|
89
|
+
crondex update ./cron/backup-reminder.yaml --dry-run
|
|
84
90
|
crondex deploy ssl-cert-expiry-check --var host=example.com --var port=443
|
|
85
91
|
crondex deploy repo-health-check --target github-actions
|
|
86
92
|
crondex deploy repo-health-check --target systemd --dest ./systemd
|
|
87
93
|
crondex deploy repo-health-check --target docker --dest ./docker/repo-health-check
|
|
94
|
+
crondex deploy repo-health-check --target k8s-cronjob --dest ./k8s/repo-health-check.yaml
|
|
95
|
+
crondex deploy repo-health-check --target eventbridge
|
|
96
|
+
crondex deploy repo-health-check --target cloud-scheduler
|
|
88
97
|
crondex deploy --list-installed
|
|
89
98
|
crondex uninstall ssl-cert-expiry-check
|
|
90
99
|
|
|
@@ -98,16 +107,23 @@ crontab with --install; --target github-actions writes a scheduled
|
|
|
98
107
|
workflow file (default .github/workflows/<id>.yml); --target systemd
|
|
99
108
|
writes a <id>.service + <id>.timer pair (default ./systemd/); --target
|
|
100
109
|
docker writes a Dockerfile + crontab pair that runs the job on its own
|
|
101
|
-
schedule in a container (default ./docker/<id>/)
|
|
102
|
-
|
|
103
|
-
|
|
110
|
+
schedule in a container (default ./docker/<id>/); --target k8s-cronjob
|
|
111
|
+
writes a self-contained batch/v1 CronJob manifest (default
|
|
112
|
+
./k8s/<id>.cronjob.yaml). --target eventbridge and --target
|
|
113
|
+
cloud-scheduler print a ready aws/gcloud CLI command instead — those
|
|
114
|
+
services invoke a target (Lambda/ECS/HTTP endpoint) rather than running a
|
|
115
|
+
shell command directly, so the command leaves that target as a TODO for
|
|
116
|
+
you to wire up. --var overrides a job's variable defaults (repeatable).
|
|
117
|
+
hybrid jobs default to --mode script (zero tokens); pass --mode prompt to
|
|
118
|
+
deploy the agent-prompt side instead.
|
|
104
119
|
|
|
105
120
|
deploy --list-installed shows every crondex-managed line in your crontab
|
|
106
121
|
(the ones with a "# crondex:<id>" marker, as left by --install). uninstall
|
|
107
122
|
removes one of those by id.
|
|
108
123
|
|
|
109
124
|
update re-pulls a job you already added/inited (matched by its "id" field)
|
|
110
|
-
against the current catalog
|
|
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.
|
|
111
127
|
`);
|
|
112
128
|
}
|
|
113
129
|
|
|
@@ -284,10 +300,17 @@ function update(path) {
|
|
|
284
300
|
}
|
|
285
301
|
const meta = findJob(localDoc.id);
|
|
286
302
|
const latestRaw = readFileSync(join(ROOT, meta.path), "utf8");
|
|
287
|
-
|
|
303
|
+
const diff = formatDiff(localRaw, latestRaw);
|
|
304
|
+
if (!diff) {
|
|
288
305
|
console.log(`${path} is already up to date with "${localDoc.id}".`);
|
|
289
306
|
return;
|
|
290
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
|
+
}
|
|
291
314
|
writeFileSync(path, latestRaw);
|
|
292
315
|
console.log(`updated ${path} to the latest "${localDoc.id}" from the catalog.`);
|
|
293
316
|
}
|
|
@@ -345,6 +368,20 @@ function deploy(id) {
|
|
|
345
368
|
writeFileSync(serviceDest, service);
|
|
346
369
|
writeFileSync(timerDest, timer);
|
|
347
370
|
console.log(`wrote ${serviceDest} and ${timerDest} — enable with:\n systemctl --user enable --now ${id}.timer`);
|
|
371
|
+
} else if (target === "k8s-cronjob") {
|
|
372
|
+
const manifest = buildK8sCronJob(doc, mode === "prompt" ? prompt : command, mode === "prompt");
|
|
373
|
+
const dest = flag("dest") ?? join("./k8s", `${id}.cronjob.yaml`);
|
|
374
|
+
if (existsSync(dest)) {
|
|
375
|
+
console.error(`${dest} already exists — refusing to overwrite. Pass --dest to choose another path.`);
|
|
376
|
+
process.exit(1);
|
|
377
|
+
}
|
|
378
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
379
|
+
writeFileSync(dest, manifest);
|
|
380
|
+
console.log(`wrote ${dest} — apply with:\n kubectl apply -f ${dest}`);
|
|
381
|
+
} else if (target === "eventbridge") {
|
|
382
|
+
console.log(buildEventBridgeCommand(doc, mode === "prompt" ? prompt : command, mode === "prompt"));
|
|
383
|
+
} else if (target === "cloud-scheduler") {
|
|
384
|
+
console.log(buildCloudSchedulerCommand(doc, mode === "prompt" ? prompt : command, mode === "prompt"));
|
|
348
385
|
} else if (target === "docker") {
|
|
349
386
|
const { dockerfile, crontab } = buildDockerArtifacts(doc, mode === "prompt" ? prompt : command, mode === "prompt");
|
|
350
387
|
const destDir = flag("dest") ?? join("./docker", id);
|
|
@@ -359,7 +396,7 @@ function deploy(id) {
|
|
|
359
396
|
writeFileSync(crontabDest, crontab);
|
|
360
397
|
console.log(`wrote ${dockerfileDest} and ${crontabDest} — build with:\n docker build -t ${id} ${destDir}`);
|
|
361
398
|
} else {
|
|
362
|
-
console.error(`unknown --target "${target}" — use "crontab", "github-actions", "systemd", or "
|
|
399
|
+
console.error(`unknown --target "${target}" — use "crontab", "github-actions", "systemd", "docker", "k8s-cronjob", "eventbridge", or "cloud-scheduler".`);
|
|
363
400
|
process.exit(1);
|
|
364
401
|
}
|
|
365
402
|
}
|
package/lib/deploy.js
CHANGED
|
@@ -30,17 +30,27 @@ 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}`;
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
@@ -80,11 +90,7 @@ export function cronToSystemdCalendar(schedule) {
|
|
|
80
90
|
// to CRONDEX_AGENT_CLI (see buildCrontabLine) — systemd services run with a minimal
|
|
81
91
|
// environment, so the unit points at EnvironmentFile as the place to set it.
|
|
82
92
|
export function buildSystemdUnits(job, resolvedText, isPrompt) {
|
|
83
|
-
const
|
|
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}'`;
|
|
93
|
+
const body = buildShellBody(resolvedText, isPrompt);
|
|
88
94
|
const service = `[Unit]
|
|
89
95
|
Description=${job.name} (crondex:${job.id})
|
|
90
96
|
|
|
@@ -109,11 +115,7 @@ WantedBy=timers.target
|
|
|
109
115
|
// inside a container — for teams that want the job shipped as an image rather than
|
|
110
116
|
// installed on a host crontab or run via CI.
|
|
111
117
|
export function buildDockerArtifacts(job, resolvedText, isPrompt) {
|
|
112
|
-
const
|
|
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}'`;
|
|
118
|
+
const body = buildShellBody(resolvedText, isPrompt);
|
|
117
119
|
const crontab = `${job.schedule} root ${body} # crondex:${job.id}\n`;
|
|
118
120
|
const dockerfile = `# Generated by \`crondex deploy ${job.id} --target docker\` from ${job.path}.
|
|
119
121
|
FROM debian:bookworm-slim
|
|
@@ -126,6 +128,106 @@ ${isPrompt ? "# NOTE: prompt-mode job — pass CRONDEX_AGENT_CLI via `docker run
|
|
|
126
128
|
return { dockerfile, crontab };
|
|
127
129
|
}
|
|
128
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
|
+
`;
|
|
229
|
+
}
|
|
230
|
+
|
|
129
231
|
// Builds a ready-to-commit GitHub Actions workflow file for the job. GitHub Actions
|
|
130
232
|
// schedules always run in UTC regardless of the `cron:` string's intent, so a
|
|
131
233
|
// non-UTC job gets a visible warning comment rather than silently firing at the
|
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
|
+
}
|