diff --git a/bin/cronjob.ts b/bin/cronjob.ts new file mode 100644 index 0000000..732a133 --- /dev/null +++ b/bin/cronjob.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env node +/** + * cronjob — install, list and remove scheduled jobs without ever installing one twice. + * + * cronjob list --host dev2 + * cronjob get nightly-summary --host dev2 + * cronjob set nightly-summary --schedule '0 9 * * *' --command 'foo' --host dev2 + * cronjob remove nightly-summary --host dev2 + * + * Every mutation is idempotent: `set` run twice leaves a byte-identical + * crontab, and `remove` on a job that is not there succeeds quietly. That is + * the whole point — a deploy step that appends to a crontab gives you the job + * once per deploy, and nothing in the file afterwards tells you which copy was + * meant to be there. + * + * Only lines inside our marker blocks are ever touched. Hand-written entries, + * MAILTO, PATH and comments survive untouched, because this is a shared file. + */ + +import { execFileSync } from 'node:child_process'; + +import { UsageError, parseArgs } from '../src/args.ts'; +import { isMain } from '../src/is-main.ts'; +import { CronError, getJob, listJobs, removeJob, upsertJob } from '../src/cronjob.ts'; + +const USAGE = `Usage: + cronjob list [--host HOST] [--user USER] + cronjob get [--host HOST] [--user USER] + cronjob set --schedule S --command C [--host HOST] [--user USER] [-n] + cronjob remove [--host HOST] [--user USER] [-n] + +Manages the jobs it owns inside a user crontab, identified by a marker comment +so that re-running an install replaces a job instead of adding a second copy. + +Options: + --host HOST run against this ssh host (default: this machine) + --user USER crontab of this user (needs root for anyone but you) + --schedule S five cron fields, or an @shorthand + --command C the command line to run + -n, --dry-run print the resulting crontab, write nothing + -h, --help show this help + +Exit status is 0 when the requested state holds, whether or not this run had to +change anything, so it is safe in a deploy that reruns. +`; + +/** Read a crontab. "no crontab for x" is an empty crontab, not a failure. */ +function readCrontab(host: string | undefined, user: string | undefined): string { + const cronCmd = ['crontab', ...(user ? ['-u', user] : []), '-l']; + try { + return run(host, cronCmd); + } catch (error) { + const message = (error as { stderr?: Buffer; message?: string }).stderr?.toString() ?? ''; + if (/no crontab for/i.test(message)) return ''; + throw error; + } +} + +/** + * Write a crontab back, serialised by flock. + * + * `crontab -` replaces the whole file, so this is a read-modify-write over + * shared state. Two deploys landing together would otherwise let the later + * read win and silently drop the earlier job. The lock is per user and lives + * in /tmp, which is fine: it only has to outlive the write. + */ +function writeCrontab(host: string | undefined, user: string | undefined, text: string): void { + const asUser = user ? `-u ${shellQuote(user)} ` : ''; + const script = [ + 'set -e', + 'tmp="$(mktemp)"', + 'trap \'rm -f "$tmp"\' EXIT', + 'exec 9>"/tmp/.cronjob-$(id -u).lock"', + 'flock 9', + 'cat > "$tmp"', + `crontab ${asUser}"$tmp"`, + ].join('; '); + + const argv = host ? ['ssh', '-o', 'BatchMode=yes', host, script] : ['sh', '-c', script]; + execFileSync(argv[0]!, argv.slice(1), { input: text, stdio: ['pipe', 'inherit', 'inherit'] }); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function run(host: string | undefined, argv: readonly string[]): string { + if (host) { + const remote = argv.map(shellQuote).join(' '); + return execFileSync('ssh', ['-o', 'BatchMode=yes', host, remote], { encoding: 'utf8' }); + } + return execFileSync(argv[0]!, argv.slice(1), { encoding: 'utf8' }); +} + +function main(argv: string[]): number { + const { flags, values, positional } = parseArgs(argv, { + boolean: ['-h', '--help', '-n', '--dry-run'], + string: ['--host', '--user', '--schedule', '--command'], + }); + + if (flags.has('-h') || flags.has('--help') || positional.length === 0) { + process.stdout.write(USAGE); + return positional.length === 0 && !flags.has('-h') && !flags.has('--help') ? 2 : 0; + } + + const [action, id] = positional; + const host = values.get('--host'); + const user = values.get('--user'); + const dryRun = flags.has('-n') || flags.has('--dry-run'); + + const before = readCrontab(host, user); + + if (action === 'list') { + const jobs = listJobs(before); + if (jobs.length === 0) { + process.stdout.write('no managed jobs\n'); + return 0; + } + for (const job of jobs) process.stdout.write(`${job.id}\t${job.schedule}\t${job.command}\n`); + return 0; + } + + if (!id) throw new UsageError(`${action} needs a job id`); + + if (action === 'get') { + const job = getJob(before, id); + if (!job) { + process.stderr.write(`no such job: ${id}\n`); + return 1; + } + process.stdout.write(`${job.id}\t${job.schedule}\t${job.command}\n`); + return 0; + } + + let after: string; + if (action === 'set') { + const schedule = values.get('--schedule'); + const command = values.get('--command'); + if (!schedule) throw new UsageError('set needs --schedule'); + if (!command) throw new UsageError('set needs --command'); + after = upsertJob(before, { id, schedule, command }); + } else if (action === 'remove') { + after = removeJob(before, id); + } else { + throw new UsageError(`unknown command: ${action}`); + } + + if (after === before) { + process.stderr.write(`${id}: already as requested, nothing to do\n`); + return 0; + } + + if (dryRun) { + process.stdout.write(after); + return 0; + } + + writeCrontab(host, user, after); + process.stderr.write(`${id}: ${action === 'remove' ? 'removed' : 'installed'}\n`); + return 0; +} + +if (isMain(import.meta.url)) { + try { + process.exit(main(process.argv.slice(2))); + } catch (error) { + if (error instanceof UsageError || error instanceof CronError) { + process.stderr.write(`cronjob: ${error.message}\n`); + process.exit(2); + } + throw error; + } +} diff --git a/src/cronjob.ts b/src/cronjob.ts new file mode 100644 index 0000000..b092ad7 --- /dev/null +++ b/src/cronjob.ts @@ -0,0 +1,233 @@ +/** + * Idempotent CRUD over a user crontab. + * + * A crontab is an unordered list of lines with no identity: nothing in the file + * says "this line is the nightly summary". So the naive way to add a job is to + * append, and the naive way to run the installer twice is to get the job twice. + * Every scheduled thing we own then fires N times, where N is how many times + * somebody re-ran the deploy. + * + * The identity is a marker comment carrying an id. A managed job is exactly two + * lines: + * + * # cli-tools:cronjob nightly-summary + * 0 9 * * * /usr/bin/whatever + * + * `upsert` replaces those two lines IN PLACE when the id is already there, so + * running it a hundred times leaves a byte-identical file and a clean diff. + * `remove` on an absent id changes nothing and is not an error. Everything + * outside a marker block — hand-written entries, MAILTO, PATH, blank lines, + * somebody's comments — is preserved exactly, because this is a shared file and + * we are a guest in it. + */ + +/** The marker that makes a line ours. Changing this orphans every job. */ +export const MARKER = '# cli-tools:cronjob'; + +export interface CronJob { + id: string; + schedule: string; + command: string; +} + +export class CronError extends Error { + constructor(message: string) { + super(message); + this.name = 'CronError'; + } +} + +/** + * Ids go in a comment and are matched literally, so they may not contain + * whitespace (the id would silently truncate at the first space, and two jobs + * could collapse onto one identity). + */ +export function validateId(id: string): string { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(id)) { + throw new CronError( + `invalid job id: ${JSON.stringify(id)} — use letters, digits, dot, dash, underscore`, + ); + } + return id; +} + +/** + * Five fields, or one of the @shorthands cron accepts. + * + * This is a shape check, not a semantic one: it rejects the mistake that + * actually happens (a four-field schedule, because the command got glued onto + * the end of it) rather than trying to out-parse cron. + */ +export function validateSchedule(schedule: string): string { + const trimmed = schedule.trim(); + if (!trimmed) throw new CronError('schedule is empty'); + + if (trimmed.startsWith('@')) { + const allowed = ['@reboot', '@yearly', '@annually', '@monthly', '@weekly', '@daily', '@midnight', '@hourly']; + if (!allowed.includes(trimmed)) { + throw new CronError(`unknown schedule shorthand: ${trimmed}`); + } + return trimmed; + } + + const fields = trimmed.split(/\s+/); + if (fields.length !== 5) { + throw new CronError( + `schedule needs 5 fields (min hour dom mon dow), got ${fields.length}: ${JSON.stringify(trimmed)}`, + ); + } + return fields.join(' '); +} + +/** + * `%` is not an ordinary character in a crontab command. + * + * cron reads the first unescaped `%` as "end of command"; the rest of the line + * becomes the job's stdin, with further `%` as newlines. So a perfectly good + * `date +%Y-%m-%d` runs as `date +` and the job quietly does the wrong thing + * with no error anywhere. Escaping is not optional. + */ +export function escapeCommand(command: string): string { + const trimmed = command.trim(); + if (!trimmed) throw new CronError('command is empty'); + if (trimmed.includes('\n')) { + throw new CronError('command must be a single line; a crontab entry cannot span lines'); + } + // Escape only the ones not already escaped, so re-running over an + // already-escaped command does not double up the backslashes. + return trimmed.replace(/(^|[^\\])%/g, '$1\\%'); +} + +/** The two lines a managed job occupies. */ +export function renderJob(job: CronJob): string[] { + return [ + `${MARKER} ${validateId(job.id)}`, + `${validateSchedule(job.schedule)} ${escapeCommand(job.command)}`, + ]; +} + +/** Split a crontab into lines without inventing or losing a trailing newline. */ +function toLines(text: string): string[] { + if (text === '') return []; + return text.replace(/\n$/, '').split('\n'); +} + +/** + * A crontab must end in a newline — some crons silently ignore a last line + * without one, which is a job that looks installed and never runs. + */ +function fromLines(lines: string[]): string { + if (lines.length === 0) return ''; + return `${lines.join('\n')}\n`; +} + +/** The id on a marker line, or null if this is not one of ours. */ +function markerId(line: string): string | null { + if (!line.startsWith(MARKER)) return null; + const rest = line.slice(MARKER.length).trim(); + return rest === '' ? null : rest.split(/\s+/)[0]!; +} + +interface Block { + id: string; + /** Index of the marker line. */ + start: number; + /** Number of lines the block occupies (marker + entry, or just marker). */ + length: number; + schedule: string; + command: string; +} + +/** + * Find every managed block. + * + * A marker whose following line is missing or is itself a marker is a + * half-written block — from an interrupted write, or a hand edit. It is + * reported with an empty command so that `upsert` repairs it and `remove` + * clears it, rather than leaving a dangling comment forever. + */ +export function findBlocks(text: string): Block[] { + const lines = toLines(text); + const blocks: Block[] = []; + + for (let index = 0; index < lines.length; index += 1) { + const id = markerId(lines[index]!); + if (id === null) continue; + + const next = lines[index + 1]; + const orphan = next === undefined || markerId(next) !== null; + if (orphan) { + blocks.push({ id, start: index, length: 1, schedule: '', command: '' }); + continue; + } + + const entry = next.trim(); + const fields = entry.startsWith('@') + ? [entry.split(/\s+/)[0]!, entry.split(/\s+/).slice(1).join(' ')] + : [entry.split(/\s+/).slice(0, 5).join(' '), entry.split(/\s+/).slice(5).join(' ')]; + + blocks.push({ id, start: index, length: 2, schedule: fields[0]!, command: fields[1] ?? '' }); + index += 1; + } + + return blocks; +} + +/** The managed jobs, in file order. Unmanaged entries are not reported. */ +export function listJobs(text: string): CronJob[] { + return findBlocks(text).map((b) => ({ id: b.id, schedule: b.schedule, command: b.command })); +} + +export function getJob(text: string, id: string): CronJob | null { + const block = findBlocks(text).find((b) => b.id === validateId(id)); + return block ? { id: block.id, schedule: block.schedule, command: block.command } : null; +} + +/** + * Add or replace a job, leaving everything else untouched. + * + * Replacement is IN PLACE so the result is stable: the first run and the + * thousandth produce the same bytes in the same order. Appending instead would + * still converge, but it would reorder the file on the first run of every + * changed job and make the diff unreadable. + * + * Duplicate ids — which can only come from a hand edit or an older, dumber + * installer — collapse onto the first, because leaving them would mean the job + * still fires twice after a "fix". + */ +export function upsertJob(text: string, job: CronJob): string { + const rendered = renderJob(job); + const lines = toLines(text); + const blocks = findBlocks(text).filter((b) => b.id === job.id); + + if (blocks.length === 0) { + return fromLines([...lines, ...rendered]); + } + + // Later blocks first, so earlier indexes stay valid while splicing. + const [first, ...duplicates] = blocks; + for (const block of [...duplicates].reverse()) { + lines.splice(block.start, block.length); + } + lines.splice(first!.start, first!.length, ...rendered); + + return fromLines(lines); +} + +/** Drop a job. Absent is success: the requested end state already holds. */ +export function removeJob(text: string, id: string): string { + validateId(id); + const lines = toLines(text); + const blocks = findBlocks(text).filter((b) => b.id === id); + + for (const block of [...blocks].reverse()) { + lines.splice(block.start, block.length); + } + + return fromLines(lines); +} + +/** Whether applying this job would change anything. Drives --dry-run and quiet re-runs. */ +export function wouldChange(text: string, job: CronJob): boolean { + return upsertJob(text, job) !== text; +} diff --git a/src/registry.ts b/src/registry.ts index 89332eb..02f28b7 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -35,6 +35,7 @@ const SUMMARIES: Record = { cal: 'The calendar from the terminal, over CalDAV: agenda, one event, add, remove', 'cli-tools': 'This dispatcher: list, update and wire up the others', codeburn: 'See where your AI spend goes, by task, tool, model and project', + cronjob: 'Install, list and remove cron jobs without ever installing one twice', dl: 'Download a video, or just its audio, through yt-dlp', domainfree: 'Which of these domains can you actually register', domainjson: 'whois-style, JSON-first name lookup', diff --git a/test/cronjob.test.ts b/test/cronjob.test.ts new file mode 100644 index 0000000..cf7c297 --- /dev/null +++ b/test/cronjob.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest'; + +import { + CronError, + MARKER, + escapeCommand, + getJob, + listJobs, + removeJob, + upsertJob, + validateSchedule, + wouldChange, +} from '../src/cronjob.ts'; + +const JOB = { id: 'nightly-summary', schedule: '0 9 * * *', command: '/usr/bin/true' }; + +describe('upsertJob', () => { + it('adds the job to an empty crontab', () => { + expect(upsertJob('', JOB)).toBe(`${MARKER} nightly-summary\n0 9 * * * /usr/bin/true\n`); + }); + + it('is idempotent: the second run changes nothing', () => { + const once = upsertJob('', JOB); + const twice = upsertJob(once, JOB); + expect(twice).toBe(once); + expect(wouldChange(once, JOB)).toBe(false); + }); + + it('stays byte-identical over many runs', () => { + let text = ''; + for (let i = 0; i < 25; i += 1) text = upsertJob(text, JOB); + expect(listJobs(text)).toHaveLength(1); + expect(text).toBe(upsertJob('', JOB)); + }); + + it('replaces in place rather than appending, so order is stable', () => { + const start = upsertJob(upsertJob('', { ...JOB, id: 'first' }), { ...JOB, id: 'second' }); + const changed = upsertJob(start, { id: 'first', schedule: '30 2 * * *', command: '/usr/bin/false' }); + + expect(listJobs(changed).map((j) => j.id)).toEqual(['first', 'second']); + expect(getJob(changed, 'first')).toEqual({ + id: 'first', + schedule: '30 2 * * *', + command: '/usr/bin/false', + }); + }); + + it('preserves unmanaged entries, env lines and comments exactly', () => { + const existing = ['MAILTO=ops@example.com', '# hand written, do not touch', '*/5 * * * * /usr/bin/backup', ''].join( + '\n', + ); + const after = upsertJob(`${existing}\n`, JOB); + + expect(after).toContain('MAILTO=ops@example.com'); + expect(after).toContain('# hand written, do not touch'); + expect(after).toContain('*/5 * * * * /usr/bin/backup'); + expect(listJobs(after)).toHaveLength(1); + }); + + it('collapses duplicate ids left by a dumber installer', () => { + const doubled = `${MARKER} nightly-summary\n0 9 * * * /usr/bin/true\n${MARKER} nightly-summary\n0 9 * * * /usr/bin/true\n`; + const repaired = upsertJob(doubled, JOB); + expect(listJobs(repaired)).toHaveLength(1); + expect(repaired).toBe(upsertJob('', JOB)); + }); + + it('always ends with a newline, so cron does not drop the last line', () => { + expect(upsertJob('*/5 * * * * /usr/bin/backup', JOB).endsWith('\n')).toBe(true); + }); +}); + +describe('removeJob', () => { + it('removes both lines of the block', () => { + expect(removeJob(upsertJob('', JOB), 'nightly-summary')).toBe(''); + }); + + it('is a no-op for a job that is not there', () => { + const other = `*/5 * * * * /usr/bin/backup\n`; + expect(removeJob(other, 'nightly-summary')).toBe(other); + }); + + it('is idempotent', () => { + const once = removeJob(upsertJob('', JOB), 'nightly-summary'); + expect(removeJob(once, 'nightly-summary')).toBe(once); + }); + + it('leaves unmanaged entries behind', () => { + const text = upsertJob('*/5 * * * * /usr/bin/backup\n', JOB); + expect(removeJob(text, 'nightly-summary')).toBe('*/5 * * * * /usr/bin/backup\n'); + }); + + it('clears an orphaned marker left by an interrupted write', () => { + expect(removeJob(`${MARKER} nightly-summary\n`, 'nightly-summary')).toBe(''); + }); +}); + +describe('escapeCommand', () => { + it('escapes %, which cron otherwise reads as end-of-command', () => { + expect(escapeCommand('date +%Y-%m-%d')).toBe('date +\\%Y-\\%m-\\%d'); + }); + + it('does not double-escape an already-escaped %', () => { + expect(escapeCommand('date +\\%Y')).toBe('date +\\%Y'); + }); + + it('survives a round trip through upsert unchanged', () => { + const job = { id: 'stamp', schedule: '@daily', command: 'echo %H' }; + const once = upsertJob('', job); + expect(upsertJob(once, job)).toBe(once); + }); + + it('refuses a multi-line command', () => { + expect(() => escapeCommand('a\nb')).toThrow(CronError); + }); +}); + +describe('validateSchedule', () => { + it('accepts five fields and @shorthands', () => { + expect(validateSchedule(' 0 9 * * * ')).toBe('0 9 * * *'); + expect(validateSchedule('@daily')).toBe('@daily'); + }); + + it('rejects four fields, which is the command getting glued on', () => { + expect(() => validateSchedule('0 9 * *')).toThrow(CronError); + }); + + it('rejects an unknown shorthand', () => { + expect(() => validateSchedule('@fortnightly')).toThrow(CronError); + }); +}); + +describe('listJobs', () => { + it('reports only managed jobs', () => { + const text = upsertJob('*/5 * * * * /usr/bin/backup\n', JOB); + expect(listJobs(text).map((j) => j.id)).toEqual(['nightly-summary']); + }); + + it('parses an @shorthand entry back out', () => { + const text = upsertJob('', { id: 'x', schedule: '@daily', command: 'run me' }); + expect(getJob(text, 'x')).toEqual({ id: 'x', schedule: '@daily', command: 'run me' }); + }); +});