@wonsukchoi/crondex 0.12.0 → 0.14.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.
@@ -0,0 +1,47 @@
1
+ // Catalog-building logic behind scripts/build-catalog.js — pulled out so it's unit
2
+ // testable directly (see test/catalog-summary.test.js).
3
+
4
+ export const MODES_BY_RUNNER = {
5
+ "agent-prompt": ["agent-prompt"],
6
+ shell: ["script"],
7
+ hybrid: ["script", "agent-prompt"],
8
+ };
9
+
10
+ export function modesForRunner(runner) {
11
+ return MODES_BY_RUNNER[runner] ?? [];
12
+ }
13
+
14
+ export function countByCategory(jobs) {
15
+ const counts = new Map();
16
+ for (const j of jobs) counts.set(j.category, (counts.get(j.category) ?? 0) + 1);
17
+ return counts;
18
+ }
19
+
20
+ export function findMissingDescriptions(categories, categoryDescriptions) {
21
+ return categories.filter((c) => !categoryDescriptions[c]);
22
+ }
23
+
24
+ // Builds the markdown lines that go between the BEGIN/END JOB SUMMARY markers.
25
+ export function buildSummaryLines(jobs, categoryDescriptions) {
26
+ const byCategory = countByCategory(jobs);
27
+ const categories = [...byCategory.keys()].sort();
28
+ return [
29
+ `${jobs.length} jobs across ${categories.length} categories:`,
30
+ "",
31
+ "| category | jobs | description |",
32
+ "|---|---|---|",
33
+ ...categories.map((c) => `| \`${c}\` | ${byCategory.get(c)} | ${categoryDescriptions[c] ?? ""} |`),
34
+ ];
35
+ }
36
+
37
+ const BEGIN = "<!-- BEGIN JOB SUMMARY -->";
38
+ const END = "<!-- END JOB SUMMARY -->";
39
+
40
+ // Splices summaryLines between the BEGIN/END markers in readmeText. Returns null if
41
+ // the markers aren't found (caller decides how to warn/handle that).
42
+ export function spliceReadmeSummary(readmeText, summaryLines) {
43
+ const start = readmeText.indexOf(BEGIN);
44
+ const end = readmeText.indexOf(END);
45
+ if (start === -1 || end === -1) return null;
46
+ return readmeText.slice(0, start + BEGIN.length) + "\n" + summaryLines.join("\n") + "\n" + readmeText.slice(end);
47
+ }
@@ -0,0 +1,50 @@
1
+ // One-line description per job category, for the README table and `crondex categories`.
2
+ // Keep entries short — this is a browsing aid, not a spec. Add a new entry whenever a
3
+ // job introduces a category that isn't listed here (a missing entry is a soft warning
4
+ // from scripts/build-catalog.js, not a hard failure).
5
+ export const CATEGORY_DESCRIPTIONS = {
6
+ agriculture: "Farm operations — weather risk, irrigation, equipment, market prices.",
7
+ automotive: "Repair shop workflow — repair orders, parts, loaners.",
8
+ childcare: "Daycare compliance and ops — ratios, immunizations, tuition.",
9
+ construction: "Job site compliance — permits, inspections, change orders, rentals.",
10
+ content: "Site/content health — SEO, broken links, freshness, repurposing.",
11
+ creator: "Influencer/creator ops — content calendar, cross-posting, sponsorships.",
12
+ crypto: "Wallets, gas prices, DeFi risk, and token unlock schedules.",
13
+ devops: "Infra health — backups, deploys, dependencies, monitoring.",
14
+ ecommerce: "Storefront ops — carts, stock, returns, reviews.",
15
+ education: "Classroom ops — grading, attendance, deadlines, permission slips.",
16
+ events: "Event planning — budget, RSVPs, staffing, vendors, day-of check-in.",
17
+ finance: "Personal/business finance — budgets, invoices, taxes, subscriptions.",
18
+ fitness: "Gym/studio ops — memberships, class utilization, equipment.",
19
+ fleet: "Vehicle fleet compliance — maintenance, registration, licenses, fuel.",
20
+ gaming: "Streaming and community server ops — schedules, patches, tournaments.",
21
+ government: "Public-sector ops — records requests, permits, constituent casework.",
22
+ growth: "Lifecycle marketing — churn, trials, onboarding, reviews.",
23
+ healthcare: "Clinic ops — appointments, recalls, licenses, lab results.",
24
+ hiring: "Recruiting pipeline — candidates, offers, interviews, reqs.",
25
+ home: "Household reminders — maintenance, warranties, plants, safety.",
26
+ hospitality: "Hotel ops — guest reviews, inspections, maintenance requests.",
27
+ hr: "People ops — payroll, onboarding, benefits, reviews, offboarding.",
28
+ insurance: "Policy tracking — renewals, claims, coverage, certificates.",
29
+ inventory: "Stock accuracy — counts, shrinkage, expiry, overstock.",
30
+ investing: "Portfolio tracking — prices, dividends, rebalancing, taxes.",
31
+ learning: "Personal learning — certs, courses, flashcards, reading.",
32
+ legal: "Contracts and deadlines — NDAs, trademarks, court, compliance filings.",
33
+ logistics: "Shipping ops — customs, freight, delays, fees.",
34
+ manufacturing: "Production ops — downtime, defects, maintenance, suppliers, materials.",
35
+ marketing: "Campaign ops — ad spend, SEO rank, deliverability, competitors.",
36
+ nonprofit: "Fundraising ops — grants, donors, volunteers, board follow-ups.",
37
+ personal: "Daily life reminders — bills, habits, meals, screen time.",
38
+ podcast: "Show ops — publish cadence, guests, sponsors, ratings.",
39
+ productivity: "Work habits — inbox, standups, focus, meetings, reports.",
40
+ realestate: "Property management — leases, rent, vacancy, inspections, tax.",
41
+ restaurant: "Kitchen/FOH ops — food cost, labor cost, waste, inspections.",
42
+ retail: "Store ops — till reconciliation, opening checklist, scheduling.",
43
+ sales: "Pipeline ops — leads, deals, quota, CRM sync.",
44
+ security: "Security posture — keys, certs, access, scans, firewalls.",
45
+ support: "Helpdesk ops — SLA, backlog, CSAT, agent workload.",
46
+ team: "Team ops — 1:1s, on-call, PTO, anniversaries.",
47
+ travel: "Trip logistics — flights, passports, visas, insurance, miles.",
48
+ veterinary: "Clinic ops for animals — vaccines, controlled substances, boarding.",
49
+ warehousing: "Warehouse ops — dock scheduling, pick/pack errors, climate control.",
50
+ };
package/lib/deploy.js ADDED
@@ -0,0 +1,84 @@
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
+ // Builds a ready-to-commit GitHub Actions workflow file for the job. GitHub Actions
47
+ // schedules always run in UTC regardless of the `cron:` string's intent, so a
48
+ // non-UTC job gets a visible warning comment rather than silently firing at the
49
+ // wrong hour.
50
+ export function buildGithubActionsWorkflow(job, { command, prompt, mode }) {
51
+ const lines = [];
52
+ lines.push(`name: ${job.name}`);
53
+ lines.push(`# Generated by \`crondex deploy ${job.id} --target github-actions\` from ${job.path}.`);
54
+ if (job.timezone && job.timezone !== "UTC") {
55
+ lines.push(`# NOTE: this job's schedule ("${job.schedule}") is defined in ${job.timezone}, but GitHub`);
56
+ lines.push(`# Actions cron always runs in UTC — adjust the hour field(s) below if timing matters.`);
57
+ }
58
+ lines.push("");
59
+ lines.push("on:");
60
+ lines.push(" schedule:");
61
+ lines.push(` - cron: "${job.schedule}"`);
62
+ lines.push(" workflow_dispatch: {}");
63
+ lines.push("");
64
+ lines.push("jobs:");
65
+ lines.push(" run:");
66
+ lines.push(" runs-on: ubuntu-latest");
67
+ lines.push(" steps:");
68
+ lines.push(" - uses: actions/checkout@v4");
69
+ if (mode === "script") {
70
+ lines.push(" - name: run job");
71
+ lines.push(" run: |");
72
+ for (const l of command.trimEnd().split("\n")) lines.push(` ${l}`);
73
+ } else {
74
+ lines.push(" - name: run job (agent-prompt)");
75
+ lines.push(
76
+ " # TODO: wire up your agent CLI/action here — this step only prints the resolved prompt."
77
+ );
78
+ lines.push(" run: |");
79
+ lines.push(" cat <<'CRONDEX_PROMPT'");
80
+ for (const l of prompt.trimEnd().split("\n")) lines.push(` ${l}`);
81
+ lines.push(" CRONDEX_PROMPT");
82
+ }
83
+ return lines.join("\n") + "\n";
84
+ }
@@ -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,29 @@
1
+ // Near-duplicate detection logic behind scripts/check-duplicates.js — pulled out so
2
+ // it's unit testable directly (see test/duplicates.test.js).
3
+
4
+ export function jaccard(a, b) {
5
+ if (a.size === 0 && b.size === 0) return 0;
6
+ let intersection = 0;
7
+ for (const x of a) if (b.has(x)) intersection++;
8
+ const union = a.size + b.size - intersection;
9
+ return union === 0 ? 0 : intersection / union;
10
+ }
11
+
12
+ // jobs: array of { id, tags: Set<string>, description: Set<string>, ...rest }.
13
+ // A pair is flagged only when BOTH tag and description overlap clear their
14
+ // threshold — tag overlap alone false-positives on jobs that just share a couple
15
+ // of generic tags, and description overlap alone false-positives on jobs
16
+ // following the same wording template for genuinely different systems.
17
+ export function findDuplicates(jobs, { tagThreshold = 0.6, descThreshold = 0.5 } = {}) {
18
+ const flagged = [];
19
+ for (let i = 0; i < jobs.length; i++) {
20
+ for (let j = i + 1; j < jobs.length; j++) {
21
+ const a = jobs[i];
22
+ const b = jobs[j];
23
+ const tagSim = jaccard(a.tags, b.tags);
24
+ const descSim = jaccard(a.description, b.description);
25
+ if (tagSim >= tagThreshold && descSim >= descThreshold) flagged.push({ a, b, tagSim, descSim });
26
+ }
27
+ }
28
+ return flagged.sort((x, y) => y.tagSim + y.descSim - (x.tagSim + x.descSim));
29
+ }
@@ -0,0 +1,20 @@
1
+ // Turns a crondex `command` field (with {{placeholder}} templating) into a script
2
+ // shellcheck can actually parse — pulled out of scripts/lint-shell.js so the
3
+ // substitution itself is unit testable (see test/shellcheck-prep.test.js).
4
+ //
5
+ // crondex substitutes {{placeholders}} as literal text before bash ever sees the
6
+ // script, so each placeholder is stood in as a real (braced) bash variable — braced
7
+ // so a literal suffix right after it (e.g. "{{days}}d") doesn't get glued into the
8
+ // variable name — just to let shellcheck catch actual quoting/syntax bugs.
9
+ const PLACEHOLDER_RE = /\{\{\s*(\w+)\s*\}\}/g;
10
+
11
+ export function extractPlaceholders(command) {
12
+ return [...new Set([...command.matchAll(PLACEHOLDER_RE)].map((m) => m[1]))];
13
+ }
14
+
15
+ export function buildShellcheckScript(command) {
16
+ const placeholders = extractPlaceholders(command);
17
+ const header = placeholders.map((name) => `${name}="42"`).join("\n");
18
+ const body = command.replace(PLACEHOLDER_RE, "$${$1}");
19
+ return `#!/usr/bin/env bash\n${header}\n${body}`;
20
+ }
@@ -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.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "A public directory of pre-made, agent-editable cron jobs.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "validate": "node scripts/validate-jobs.js",
37
37
  "lint-shell": "node scripts/lint-shell.js",
38
38
  "check-duplicates": "node scripts/check-duplicates.js",
39
+ "smoke-test": "node scripts/smoke-test.js",
39
40
  "test": "node --test"
40
41
  },
41
42
  "dependencies": {