@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.
@@ -0,0 +1,35 @@
1
+ id: trainer-session-package-expiry-check
2
+ version: 1
3
+ name: Trainer Session Package Expiry Check
4
+ description: >
5
+ Flags members with a paid personal-training session package that's
6
+ expiring soon with sessions still unused. Use this if a client has ever
7
+ paid for ten sessions, used three, and let the rest expire because
8
+ nobody nudged them to book before the window closed.
9
+ category: fitness
10
+ tags: [fitness, personal-training, sessions, expiry]
11
+ schedule: "0 9 * * 1"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ awk -F',' -v days="{{warn_days_before}}" '
16
+ NR==1 {next}
17
+ $4 > 0 {
18
+ cmd = "date -d \"" $3 "\" +%s 2>/dev/null || date -j -f %Y-%m-%d \"" $3 "\" +%s 2>/dev/null";
19
+ cmd | getline expiry_epoch; close(cmd);
20
+ "date +%s" | getline now_epoch; close("date +%s");
21
+ diff_days = (expiry_epoch - now_epoch) / 86400;
22
+ if (diff_days <= days && diff_days >= 0) {
23
+ printf "EXPIRING: %s — %d session(s) left, expires %s (%d days)\n", $1, $4, $3, diff_days;
24
+ }
25
+ }
26
+ ' "{{packages_csv_path}}"
27
+ variables:
28
+ packages_csv_path:
29
+ default: "./training-packages.csv"
30
+ description: "Path to a CSV with columns: member_name,package_size,expiry_date (YYYY-MM-DD),sessions_remaining."
31
+ warn_days_before:
32
+ default: 14
33
+ description: How many days before expiry to start warning, while there's still time to book remaining sessions.
34
+ compatible_agents: [generic, claude, codex]
35
+ notes: An expiring package with several sessions left is also a good moment to offer an extension or renewal, not just a "use it or lose it" notice.
@@ -0,0 +1,35 @@
1
+ id: budget-appropriation-deadline-check
2
+ version: 1
3
+ name: Budget Appropriation Submission Deadline Check
4
+ description: >
5
+ Warns you before an agency's budget appropriation submission is due.
6
+ Use this if a department has ever missed its window to request funding
7
+ for the next cycle because the internal deadline snuck up during budget
8
+ season chaos.
9
+ category: government
10
+ tags: [government, budget, appropriation, deadline]
11
+ schedule: "0 8 * * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ awk -F',' -v days="{{warn_days_before}}" '
16
+ NR==1 {next}
17
+ {
18
+ cmd = "date -d \"" $2 "\" +%s 2>/dev/null || date -j -f %Y-%m-%d \"" $2 "\" +%s 2>/dev/null";
19
+ cmd | getline due_epoch; close(cmd);
20
+ "date +%s" | getline now_epoch; close("date +%s");
21
+ diff_days = (due_epoch - now_epoch) / 86400;
22
+ if (diff_days <= days && diff_days >= 0) {
23
+ printf "SUBMISSION DUE: %s — due %s (%d days)\n", $1, $2, diff_days;
24
+ }
25
+ }
26
+ ' "{{budget_deadlines_csv_path}}"
27
+ variables:
28
+ budget_deadlines_csv_path:
29
+ default: "./budget-deadlines.csv"
30
+ description: "Path to a CSV with columns: department_name,submission_due_date (YYYY-MM-DD)."
31
+ warn_days_before:
32
+ default: 21
33
+ description: How many days before the submission deadline to start warning.
34
+ compatible_agents: [generic, claude, codex]
35
+ notes: Keep department names distinct if you're tracking multiple agencies' cycles in one CSV — otherwise run one instance of this job per department.
@@ -0,0 +1,39 @@
1
+ id: public-meeting-notice-compliance-check
2
+ version: 1
3
+ name: Public Meeting Notice Compliance Check
4
+ description: >
5
+ Checks that an upcoming public meeting's notice was posted with enough
6
+ lead time to meet open-meetings law requirements. Use this if a meeting
7
+ has ever gotten challenged, or had to be redone, because notice went out
8
+ a day late.
9
+ category: government
10
+ tags: [government, open-meetings, notice, compliance]
11
+ schedule: "0 8 * * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ meeting_epoch=$(date -d "{{meeting_date}}" +%s 2>/dev/null || date -jf "%Y-%m-%d" "{{meeting_date}}" +%s);
16
+ now_epoch=$(date +%s);
17
+ days_until=$(( (meeting_epoch - now_epoch) / 86400 ));
18
+ if [ ! -f "{{notice_posted_marker}}" ]; then
19
+ if [ "$days_until" -le "{{required_notice_days}}" ]; then
20
+ echo "COMPLIANCE RISK: meeting on {{meeting_date}} is in $days_until days with no notice-posted marker found (requires {{required_notice_days}} days notice)";
21
+ exit 1;
22
+ else
23
+ echo "OK: meeting is $days_until days out, still within the notice window";
24
+ fi
25
+ else
26
+ echo "OK: notice marker found for meeting on {{meeting_date}}";
27
+ fi
28
+ variables:
29
+ meeting_date:
30
+ default: "YYYY-MM-DD"
31
+ description: Date of the upcoming public meeting.
32
+ required_notice_days:
33
+ default: 3
34
+ description: Minimum days of advance notice required by your jurisdiction's open-meetings law.
35
+ notice_posted_marker:
36
+ default: "./notice-posted.marker"
37
+ description: Path to a file you create once notice has actually been posted (physically and/or online) — its existence is the "done" signal.
38
+ compatible_agents: [generic, claude, codex]
39
+ notes: Required notice periods and posting locations vary significantly by state/locality — confirm your specific statute rather than relying on the example default.
@@ -0,0 +1,36 @@
1
+ id: gift-card-liability-reconciliation-check
2
+ version: 1
3
+ name: Gift Card Liability Reconciliation Check
4
+ description: >
5
+ Compares your POS's outstanding gift card balance against your
6
+ accounting ledger and flags a mismatch. Use this if outstanding gift
7
+ card liability has ever drifted out of sync with your books for months
8
+ before anyone reconciled it at year-end and found a gap.
9
+ category: retail
10
+ tags: [retail, gift-cards, reconciliation, finance]
11
+ schedule: "0 7 1 * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ pos="{{pos_outstanding_balance_usd}}";
16
+ ledger="{{ledger_outstanding_balance_usd}}";
17
+ tolerance="{{tolerance_usd}}";
18
+ diff=$(awk -v p="$pos" -v l="$ledger" 'BEGIN{d=p-l; if (d<0) d=-d; printf "%.2f", d}');
19
+ if awk -v d="$diff" -v t="$tolerance" 'BEGIN{exit !(d > t)}'; then
20
+ echo "MISMATCH: POS shows \$$pos outstanding, ledger shows \$$ledger (diff \$$diff)";
21
+ exit 1;
22
+ else
23
+ echo "OK: POS and ledger outstanding gift card balances match within tolerance";
24
+ fi
25
+ variables:
26
+ pos_outstanding_balance_usd:
27
+ default: 0
28
+ description: Total outstanding gift card balance per your POS system's report.
29
+ ledger_outstanding_balance_usd:
30
+ default: 0
31
+ description: Total outstanding gift card liability per your accounting ledger.
32
+ tolerance_usd:
33
+ default: 25.00
34
+ description: Acceptable discrepancy in dollars before flagging.
35
+ compatible_agents: [generic, claude, codex]
36
+ notes: A growing gap over several months usually points at a systemic sync issue between POS and accounting, not a one-time error — worth escalating rather than just re-running this each month.
@@ -0,0 +1,39 @@
1
+ id: return-fraud-pattern-check
2
+ version: 1
3
+ name: Return Fraud Pattern Check
4
+ description: >
5
+ Checks recent returns for patterns that look like abuse — the same
6
+ customer returning unusually often, or a spike in receiptless returns —
7
+ instead of treating every return as an isolated, no-questions-asked
8
+ event. Use this if return fraud has ever quietly eaten margin for
9
+ months because nobody was looking at returns in aggregate.
10
+ category: retail
11
+ tags: [retail, returns, fraud, loss-prevention]
12
+ schedule: "0 7 * * 1"
13
+ timezone: "UTC"
14
+ runner: shell
15
+ command: >
16
+ awk -F',' -v max_returns="{{max_returns_per_customer}}" '
17
+ NR==1 {next}
18
+ { count[$2]++; if ($3 == "no-receipt") noreceipt++; total++ }
19
+ END {
20
+ for (cust in count) {
21
+ if (count[cust] >= max_returns) {
22
+ printf "FREQUENT RETURNER: %s — %d returns in the period\n", cust, count[cust];
23
+ }
24
+ }
25
+ if (total > 0) {
26
+ pct = (noreceipt / total) * 100;
27
+ printf "no-receipt returns: %.0f%% of %d total\n", pct, total;
28
+ }
29
+ }
30
+ ' "{{returns_csv_path}}"
31
+ variables:
32
+ returns_csv_path:
33
+ default: "./returns-log.csv"
34
+ description: "Path to a CSV with columns: return_id,customer_identifier,receipt_status (receipt/no-receipt) — one row per return in the period."
35
+ max_returns_per_customer:
36
+ default: 5
37
+ description: Number of returns from the same customer in the period that counts as worth a closer look.
38
+ compatible_agents: [generic, claude, codex]
39
+ notes: A flagged pattern is a prompt to look closer, not proof of fraud — plenty of frequent returners are just picky shoppers, not scammers.
@@ -0,0 +1,35 @@
1
+ id: diagnostic-equipment-calibration-check
2
+ version: 1
3
+ name: Diagnostic Equipment Calibration Check
4
+ description: >
5
+ Tracks calibration due dates for in-house diagnostic equipment (x-ray,
6
+ in-house lab analyzers, ultrasound) and warns before any lapse. Use this
7
+ if a lab result has ever been called into question because equipment
8
+ calibration had quietly lapsed and nobody noticed until an audit.
9
+ category: veterinary
10
+ tags: [veterinary, equipment, calibration, compliance]
11
+ schedule: "0 7 * * 1"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ awk -F',' -v days="{{warn_days_before}}" '
16
+ NR==1 {next}
17
+ {
18
+ cmd = "date -d \"" $2 "\" +%s 2>/dev/null || date -j -f %Y-%m-%d \"" $2 "\" +%s 2>/dev/null";
19
+ cmd | getline due_epoch; close(cmd);
20
+ "date +%s" | getline now_epoch; close("date +%s");
21
+ diff_days = (due_epoch - now_epoch) / 86400;
22
+ if (diff_days <= days) {
23
+ printf "CALIBRATION DUE: %s — due %s (%d days)\n", $1, $2, diff_days;
24
+ }
25
+ }
26
+ ' "{{equipment_csv_path}}"
27
+ variables:
28
+ equipment_csv_path:
29
+ default: "./diagnostic-equipment-calibration.csv"
30
+ description: "Path to a CSV with columns: equipment_name,calibration_due_date (YYYY-MM-DD)."
31
+ warn_days_before:
32
+ default: 14
33
+ description: How many days before calibration is due to start warning.
34
+ compatible_agents: [generic, claude, codex]
35
+ notes: Take equipment out of clinical use once calibration lapses rather than continuing to trust its readings until the service tech arrives.
@@ -0,0 +1,30 @@
1
+ id: surgery-prep-check
2
+ version: 1
3
+ name: Surgery Prep Check
4
+ description: >
5
+ Checks tomorrow's scheduled surgeries for missing pre-op bloodwork or
6
+ signed consent forms. Use this if a surgery has ever had to be
7
+ rescheduled the morning of because consent wasn't signed or bloodwork
8
+ hadn't been run, and nobody checked the night before.
9
+ category: veterinary
10
+ tags: [veterinary, surgery, pre-op, consent]
11
+ schedule: "0 17 * * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ tomorrow=$(date -d "+1 day" +%Y-%m-%d 2>/dev/null || date -v+1d +%Y-%m-%d);
16
+ awk -F',' -v tomorrow="$tomorrow" '
17
+ NR==1 {next}
18
+ $2 == tomorrow {
19
+ missing = "";
20
+ if ($3 != "done") missing = missing " bloodwork";
21
+ if ($4 != "signed") missing = missing " consent";
22
+ if (missing != "") printf "NOT READY: %s (%s) — missing:%s\n", $1, $5, missing;
23
+ }
24
+ ' "{{surgery_schedule_csv_path}}"
25
+ variables:
26
+ surgery_schedule_csv_path:
27
+ default: "./surgery-schedule.csv"
28
+ description: "Path to a CSV with columns: pet_name,surgery_date (YYYY-MM-DD),bloodwork_status (done/pending),consent_status (signed/pending),owner_name."
29
+ compatible_agents: [generic, claude, codex]
30
+ notes: Run this the evening before so there's still time to call the owner and get bloodwork done or consent signed before the surgery slot.
@@ -0,0 +1,35 @@
1
+ id: cycle-count-schedule-check
2
+ version: 1
3
+ name: Cycle Count Schedule Adherence Check
4
+ description: >
5
+ Checks whether scheduled cycle counts actually happened on time, zone by
6
+ zone. Use this if a cycle count program has ever quietly stopped
7
+ happening for a whole section of the warehouse because nobody was
8
+ tracking adherence to the schedule itself, only the count results.
9
+ category: warehousing
10
+ tags: [warehousing, cycle-count, schedule, inventory]
11
+ schedule: "0 8 * * 1"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ today=$(date +%s);
16
+ awk -F',' -v days="{{overdue_after_days}}" -v today="$today" '
17
+ NR==1 {next}
18
+ {
19
+ cmd = "date -d \"" $2 "\" +%s 2>/dev/null || date -j -f %Y-%m-%d \"" $2 "\" +%s 2>/dev/null";
20
+ cmd | getline last_count_epoch; close(cmd);
21
+ age_days = (today - last_count_epoch) / 86400;
22
+ if (age_days >= days) {
23
+ printf "OVERDUE: zone %s — last counted %s (%d days ago)\n", $1, $2, age_days;
24
+ }
25
+ }
26
+ ' "{{cycle_count_log_csv_path}}"
27
+ variables:
28
+ cycle_count_log_csv_path:
29
+ default: "./cycle-count-log.csv"
30
+ description: "Path to a CSV with columns: zone_name,last_count_date (YYYY-MM-DD)."
31
+ overdue_after_days:
32
+ default: 30
33
+ description: Days since the last cycle count before a zone counts as overdue for its next one.
34
+ compatible_agents: [generic, claude, codex]
35
+ notes: This checks schedule adherence only — pair with `inventory-count-discrepancy-check` for what the counts themselves turn up.
@@ -0,0 +1,36 @@
1
+ id: forklift-inspection-check
2
+ version: 1
3
+ name: Forklift Daily Safety Inspection Check
4
+ description: >
5
+ Checks that each forklift's required daily pre-use inspection was
6
+ actually logged today. Use this if a forklift has ever been operated
7
+ on a shift with no inspection logged, which is exactly what an OSHA
8
+ audit will ask to see records of.
9
+ category: warehousing
10
+ tags: [warehousing, forklift, safety, compliance]
11
+ schedule: "0 10 * * *"
12
+ timezone: "UTC"
13
+ runner: shell
14
+ command: >
15
+ today=$(date +%Y-%m-%d);
16
+ missing=$(awk -F',' -v today="$today" -v fleet="{{forklift_ids}}" '
17
+ BEGIN { n = split(fleet, ids, ","); }
18
+ $2 == today { logged[$1] = 1 }
19
+ END {
20
+ for (i = 1; i <= n; i++) {
21
+ id = ids[i];
22
+ gsub(/^ +| +$/, "", id);
23
+ if (!(id in logged)) print "NOT INSPECTED TODAY: " id;
24
+ }
25
+ }
26
+ ' "{{inspection_log_csv_path}}");
27
+ if [ -n "$missing" ]; then echo "$missing"; exit 1; else echo "OK: all forklifts inspected today"; fi
28
+ variables:
29
+ inspection_log_csv_path:
30
+ default: "./forklift-inspection-log.csv"
31
+ description: "Path to a CSV with columns: forklift_id,inspection_date (YYYY-MM-DD)."
32
+ forklift_ids:
33
+ default: "FL-1,FL-2"
34
+ description: Comma-separated list of forklift IDs that must each have a logged inspection today.
35
+ compatible_agents: [generic, claude, codex]
36
+ notes: A forklift with a failed inspection item should be pulled from service immediately — this job only checks that the inspection was logged, not what it found.
package/lib/deploy.js ADDED
@@ -0,0 +1,167 @@
1
+ // Turns a resolved crondex job into a deployment artifact — pulled out of
2
+ // bin/crondex.js's `deploy` command so it's unit testable (see test/deploy.test.js).
3
+
4
+ const PLACEHOLDER_RE = /\{\{\s*(\w+)\s*\}\}/g;
5
+
6
+ // Merges a job's `variables` defaults with any user-supplied overrides.
7
+ export function resolveVariables(job, overrides = {}) {
8
+ const values = {};
9
+ for (const [name, spec] of Object.entries(job.variables ?? {})) {
10
+ values[name] = name in overrides ? overrides[name] : spec.default;
11
+ }
12
+ return values;
13
+ }
14
+
15
+ // crondex substitutes {{placeholders}} as literal text — this mirrors that exactly,
16
+ // leaving a placeholder untouched if no value was resolved for it (rather than
17
+ // silently dropping it, which would produce a broken script with no warning).
18
+ export function substitutePlaceholders(text, values) {
19
+ return text.replace(PLACEHOLDER_RE, (match, name) => (name in values ? String(values[name]) : match));
20
+ }
21
+
22
+ // hybrid jobs support both — shell/agent-prompt jobs only support their one mode.
23
+ export function pickMode(job, requestedMode) {
24
+ if (job.runner === "shell") return "script";
25
+ if (job.runner === "agent-prompt") return "prompt";
26
+ return requestedMode === "prompt" ? "prompt" : "script";
27
+ }
28
+
29
+ function escapeSingleQuotes(text) {
30
+ return text.replace(/'/g, `'"'"'`);
31
+ }
32
+
33
+ // Flattens a (possibly multi-line) resolved command/prompt into one crontab line.
34
+ // isPrompt jobs can't assume any particular agent CLI syntax, so the line defers to
35
+ // a CRONDEX_AGENT_CLI env var the user sets themselves (e.g. `export
36
+ // CRONDEX_AGENT_CLI="claude -p"`) rather than guessing wrong.
37
+ export function buildCrontabLine(job, resolvedText, isPrompt) {
38
+ const flat = resolvedText.trim().replace(/\s*\n\s*/g, " ");
39
+ const escaped = escapeSingleQuotes(flat);
40
+ const body = isPrompt
41
+ ? `\${CRONDEX_AGENT_CLI:?set CRONDEX_AGENT_CLI to your agent CLI invocation, e.g. "claude -p"} '${escaped}'`
42
+ : `bash -lc '${escaped}'`;
43
+ return `${job.schedule} ${body} # crondex:${job.id}`;
44
+ }
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
+
129
+ // Builds a ready-to-commit GitHub Actions workflow file for the job. GitHub Actions
130
+ // schedules always run in UTC regardless of the `cron:` string's intent, so a
131
+ // non-UTC job gets a visible warning comment rather than silently firing at the
132
+ // wrong hour.
133
+ export function buildGithubActionsWorkflow(job, { command, prompt, mode }) {
134
+ const lines = [];
135
+ lines.push(`name: ${job.name}`);
136
+ lines.push(`# Generated by \`crondex deploy ${job.id} --target github-actions\` from ${job.path}.`);
137
+ if (job.timezone && job.timezone !== "UTC") {
138
+ lines.push(`# NOTE: this job's schedule ("${job.schedule}") is defined in ${job.timezone}, but GitHub`);
139
+ lines.push(`# Actions cron always runs in UTC — adjust the hour field(s) below if timing matters.`);
140
+ }
141
+ lines.push("");
142
+ lines.push("on:");
143
+ lines.push(" schedule:");
144
+ lines.push(` - cron: "${job.schedule}"`);
145
+ lines.push(" workflow_dispatch: {}");
146
+ lines.push("");
147
+ lines.push("jobs:");
148
+ lines.push(" run:");
149
+ lines.push(" runs-on: ubuntu-latest");
150
+ lines.push(" steps:");
151
+ lines.push(" - uses: actions/checkout@v4");
152
+ if (mode === "script") {
153
+ lines.push(" - name: run job");
154
+ lines.push(" run: |");
155
+ for (const l of command.trimEnd().split("\n")) lines.push(` ${l}`);
156
+ } else {
157
+ lines.push(" - name: run job (agent-prompt)");
158
+ lines.push(
159
+ " # TODO: wire up your agent CLI/action here — this step only prints the resolved prompt."
160
+ );
161
+ lines.push(" run: |");
162
+ lines.push(" cat <<'CRONDEX_PROMPT'");
163
+ for (const l of prompt.trimEnd().split("\n")) lines.push(` ${l}`);
164
+ lines.push(" CRONDEX_PROMPT");
165
+ }
166
+ return lines.join("\n") + "\n";
167
+ }
@@ -0,0 +1,18 @@
1
+ // Job pairs that score above the near-duplicate thresholds but were reviewed and
2
+ // confirmed genuinely distinct — recorded here so scripts/check-duplicates.js stops
3
+ // re-flagging (and CI stops failing on) the same reviewed pair every run. Order of
4
+ // ids within a pair doesn't matter.
5
+ //
6
+ // Add an entry only after actually comparing the two jobs — this is a record of a
7
+ // human decision, not a way to silence the check.
8
+ export const ALLOWED_DUPLICATE_PAIRS = [
9
+ [
10
+ "food-cost-percentage-watch",
11
+ "labor-cost-percentage-watch",
12
+ "Same benchmark-against-sales pattern, different cost categories (food vs. labor) — intentionally parallel jobs, not a copy-paste duplicate.",
13
+ ],
14
+ ];
15
+
16
+ export function isAllowedPair(idA, idB) {
17
+ return ALLOWED_DUPLICATE_PAIRS.some(([x, y]) => (x === idA && y === idB) || (x === idB && y === idA));
18
+ }
@@ -0,0 +1,32 @@
1
+ // Pure logic behind scripts/smoke-test.js: resolving a job's command with its real
2
+ // default variable values, and wrapping it for sandboxed execution. Split out so the
3
+ // resolution step is unit testable without actually spawning a shell.
4
+ import { substitutePlaceholders } from "./deploy.js";
5
+
6
+ // Unlike lint-shell.js (which stands placeholders in as bash variables to run
7
+ // shellcheck's static analysis), this resolves them exactly like a real deployment
8
+ // would — using each variable's actual `default` — so the smoke test exercises the
9
+ // real behavior a user would get, not a synthetic stand-in.
10
+ export function resolveJobCommand(job) {
11
+ const values = {};
12
+ for (const [name, spec] of Object.entries(job.variables ?? {})) values[name] = spec.default;
13
+ return substitutePlaceholders(job.command, values);
14
+ }
15
+
16
+ // Wraps a resolved command for sandboxed execution:
17
+ // - `set -u` so an unbound variable (a real bug) aborts instead of silently
18
+ // expanding to empty string.
19
+ // - HOME repointed into the sandbox dir, so a job whose default touches
20
+ // "$HOME/..." (e.g. downloads-folder-auto-organize) can't reach the real user's
21
+ // home directory.
22
+ // - a `command_not_found_handle` fallback so any external tool not installed on
23
+ // this machine (aws, gh, kubectl, redis-cli, ...) resolves to a harmless no-op
24
+ // instead of a hard "command not found" — this lets the job's own control flow
25
+ // run for real without requiring every possible external tool to be present.
26
+ export function buildSandboxScript(resolvedCommand) {
27
+ return [
28
+ "set -u",
29
+ "command_not_found_handle() { return 0; }",
30
+ resolvedCommand,
31
+ ].join("\n");
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wonsukchoi/crondex",
3
- "version": "0.13.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": {
@@ -36,6 +37,7 @@
36
37
  "validate": "node scripts/validate-jobs.js",
37
38
  "lint-shell": "node scripts/lint-shell.js",
38
39
  "check-duplicates": "node scripts/check-duplicates.js",
40
+ "smoke-test": "node scripts/smoke-test.js",
39
41
  "test": "node --test"
40
42
  },
41
43
  "dependencies": {
@@ -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).