From b72d78b2fbda682624a77879188689ce5523cd14 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 6 Sep 2026 00:04:58 +0000 Subject: [PATCH] 0.26.0: cal, the calendar from the terminal over CalDAV MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `cal ls` for today, the week or a range, `cal show `, `cal add`, `cal rm`, and `cal calendars`, against any CalDAV host that still takes a password. No library: PROPFIND finds the principal and the calendars, REPORT reads a window with the server expanding recurrences, PUT and DELETE write. Nine providers are built in (Forward Email, iCloud, Fastmail, Zoho, Yahoo, AOL, GMX, mailbox.org, Posteo) plus `custom` by URL for Nextcloud, Radicale, Baïkal and the like; Google, Outlook and Proton are listed as unreachable with the reason, since Google's CalDAV takes only OAuth2 and the other two have none. `cal login` mirrors `mail login`: it says which kind of password the host wants before asking, discovers the calendars, and stores nothing on a refusal. `--like ` borrows the address and password of a mail account, because iCloud, Fastmail and Forward Email use one password for both. Accounts live in ~/.config/cli-tools/cal.json (0600) or the cli-tools-cal vault as CAL__EMAIL / _PROVIDER / _PASSWORD. Times as people type them: 2026-09-06 14:00, tomorrow 9:30, friday 2pm, or a bare day for all-day. Zoned iCalendar times are converted with Intl, so a TZID needs no tz database. Plugin `cal` (/cal:login, /cal:agenda, /cal:add). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_013h8jopY81BGQ4Pn22NfZTu --- .claude-plugin/marketplace.json | 19 + README.md | 38 + bin/cal.ts | 482 +++++++++++ package.json | 2 +- plugins/cal/.claude-plugin/plugin.json | 20 + plugins/cal/README.md | 44 + plugins/cal/commands/add.md | 37 + plugins/cal/commands/agenda.md | 42 + plugins/cal/commands/login.md | 40 + src/cal.ts | 1093 ++++++++++++++++++++++++ src/registry.ts | 1 + test/cal.test.ts | 555 ++++++++++++ 12 files changed, 2372 insertions(+), 1 deletion(-) create mode 100755 bin/cal.ts create mode 100644 plugins/cal/.claude-plugin/plugin.json create mode 100644 plugins/cal/README.md create mode 100644 plugins/cal/commands/add.md create mode 100644 plugins/cal/commands/agenda.md create mode 100644 plugins/cal/commands/login.md create mode 100644 src/cal.ts create mode 100644 test/cal.test.ts diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 1cc2248..ba63292 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -138,6 +138,25 @@ "resend", "inbox" ] + }, + { + "name": "cal", + "description": "The calendar from the terminal, over CalDAV: sign in to iCloud, Fastmail, Zoho, Yahoo, Forward Email, Nextcloud or any CalDAV host with the right kind of password, then read the agenda for today, the week or a range, look at one event, add one, or remove one.", + "source": "./plugins/cal", + "category": "productivity", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#cal", + "keywords": [ + "calendar", + "caldav", + "agenda", + "icloud", + "fastmail", + "nextcloud" + ] } ] } diff --git a/README.md b/README.md index a551de6..54aae8e 100644 --- a/README.md +++ b/README.md @@ -687,6 +687,44 @@ retried on the other path. A webmail address (gmail.com and friends) cannot be verified at Resend, so a Gmail account sends over SMTP only. `--via smtp|resend` pins one and refuses rather than swapping; `--draft` files to Drafts instead. +### `cal` + +The calendar from the terminal, over CalDAV: the agenda for today, the week or +a range, one event in full, add one, remove one. + +```sh +cal providers # every host built in, and the password each wants +cal login icloud you@icloud.com # says "app password", finds the calendars, stores it +cal login forwardemail --like work # borrow the `mail` account's address and password +cal login custom you@x.org --url https://cloud.x.org/remote.php/dav # Nextcloud, Radicale, Baïkal … +cal accounts pull # or import from the cli-tools-cal vault + +cal calendars +cal ls # the next 7 days, every calendar +cal ls --today | --tomorrow | --week | --days 30 | --from 2026-10-01 --to 2026-10-15 +cal ls -c Work --json # one calendar; uids are in the JSON +cal show +cal add "Dentist" --at "tomorrow 9:30" # one hour, first calendar +cal add "Offsite" --at 2026-09-10 --all-day --for 2d --where "Lake house" +cal rm # shows it, asks, then deletes; --yes skips the question +``` + +Nine providers are built in — Forward Email, iCloud, Fastmail, Zoho, Yahoo, +AOL, GMX, mailbox.org, Posteo — plus `custom` with a URL for any CalDAV server. +iCloud and Fastmail refuse the account password and want the app password their +mail already uses, which is what `--like` is for. Google Calendar, Outlook and +Proton are listed as unreachable: Google's CalDAV takes only OAuth2 (a Gmail app +password opens the mailbox, not the calendar), Microsoft has no CalDAV, Proton +has none on any plan. + +No library: CalDAV here is PROPFIND for the principal and calendars, REPORT for +a window (the server expands recurring events, so a weekly standup lists on +every day it happens), PUT and DELETE to write. Accounts live in +`~/.config/cli-tools/cal.json` (0600) or the `cli-tools-cal` vault as +`CAL__EMAIL` / `_PROVIDER` / `_PASSWORD` (optional `_USER`, `_URL`) and +`CAL_DEFAULT`; `CAL__PASSWORD` in the environment wins. Times print in the +machine's zone. + Search takes `from:`, `to:`, `cc:`, `subject:`, `body:`, `since:`/`before:`/`on:` (`YYYY-MM-DD`), `is:unread|read|flagged|answered`, and bare words for the text; `--gmail` hands the whole query to Gmail's own search language instead. diff --git a/bin/cal.ts b/bin/cal.ts new file mode 100755 index 0000000..43908a7 --- /dev/null +++ b/bin/cal.ts @@ -0,0 +1,482 @@ +#!/usr/bin/env node +/** + * cal — the calendar from the terminal, over CalDAV. + * + * What is on today, this week, or between two dates; one event in full; + * add one; remove one. Accounts are configuration, imported from the + * `cli-tools-cal` team vault or signed in with `cal login`, which says which + * kind of password the provider wants before asking for it — the same shape + * as `mail`, because it is the same problem. + */ + +import { isMain } from '../src/is-main.ts'; +import { UsageError, parseArgs } from '../src/args.ts'; +import { + type Account, + type AccountConfig, + type CalConfig, + type Calendar, + type CalEvent, + type ProviderName, + CAL_VAULT_PROJECT, + CalError, + PROVIDER_NAMES, + accountsFromVault, + buildIcs, + calConfigPath, + durationMinutes, + formatAccounts, + formatAgenda, + formatCalendars, + formatEvent, + formatProviders, + guessProvider, + isProviderName, + loadConfig, + loginHint, + mergeVaultAccounts, + openCalDav, + parseWhen, + passwordVariable, + providerFor, + resolveAccount, + saveConfig, + selectAccount, + unsupportedProvider, + windowFrom, +} from '../src/cal.ts'; +import { loadConfig as loadMailConfig, resolveAccount as resolveMailAccount } from '../src/mail.ts'; +import { confirm, promptLine, promptSecret } from '../src/prompt.ts'; +import { pullVault, vaultTarget } from '../src/vault.ts'; + +const USAGE = `Usage: + cal login [email] [--as NAME] sign in: says which password the provider wants, + finds the calendars, then stores the account + cal login the same, with the provider read off the address + cal login … --like reuse the address and password of a \`mail\` account + cal providers every provider built in, and what each wants + + cal accounts the configured accounts + cal accounts password store or replace a password + cal accounts default which account a bare command means + cal accounts rm + cal accounts pull import accounts from the team vault + + cal calendars [-a ACCOUNT] the calendars in the account + cal ls [--today|--tomorrow|--week|--days N|--from W --to W] [-c CALENDAR] [-a ACCOUNT] [--json] + cal show [-a ACCOUNT] [--json] + cal add --at W [--end W | --for D] [--all-day] [-c CALENDAR] [--where P] [--notes T] [--link U] + cal rm <uid> [-a ACCOUNT] [--yes] + +Options: + -a, --account A which account: its name or its address (default: \`cal accounts default\`) + -c, --calendar C which calendar, by name (ls: all of them; add: the first, or the one named) + --today, --tomorrow, --week, --days N, --from W, --to W + the window for ls; default is the next 7 days + --at W add: when it starts — 2026-09-06 14:00, tomorrow 9:30, friday 2pm, or a bare day + --end W add: when it ends; --for D a duration instead (30m, 1h30m, 2d); default 1h + --all-day add: a whole-day event on the day of --at (a bare day implies it) + --where P add: the location + --notes T add: the description + --link U add: a URL + --json machine-readable output + --yes rm: skip the confirmation + -h, --help show this help + +Options for \`login\`: + --as NAME the account name to store it under (default: the provider's name) + --like NAME take the address and password from the \`mail\` account of that name + --default make it the account a bare command means + --no-verify store without finding the calendars first + --user LOGIN when the login is not the address + --url U the CalDAV URL, for custom (Nextcloud: https://host/remote.php/dav) or a regional host + +Accounts live in ${calConfigPath()} (0600). A password exported as +CAL_<NAME>_PASSWORD wins over the stored one. \`cal accounts pull\` imports +CAL_<NAME>_EMAIL / _PROVIDER / _PASSWORD (and optional _USER, _URL) plus +CAL_DEFAULT from the \`${CAL_VAULT_PROJECT}\` vault; CLI_TOOLS_CAL_VAULT_PROJECT / +_ENV point it elsewhere. Times print in this machine's zone. +`; + +function fail(message: string, code = 2): never { + process.stderr.write(`cal: ${message}\n`); + process.exit(code); +} + +function out(text: string): void { + process.stdout.write(text.endsWith('\n') ? text : `${text}\n`); +} + +function json(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function integer(values: Map<string, string>, flag: string, fallback: number, range: { min: number; max: number }): number { + const raw = values.get(flag); + if (raw === undefined) return fallback; + const value = Number(raw); + if (!Number.isInteger(value) || value < range.min || value > range.max) { + throw new UsageError(`${flag} must be a whole number from ${range.min} to ${range.max}, got "${raw}"`); + } + return value; +} + +function pickCalendars(calendars: Calendar[], wanted: string | undefined): Calendar[] { + if (!wanted) return calendars; + const lower = wanted.toLowerCase(); + const found = calendars.filter((calendar) => calendar.name.toLowerCase() === lower); + if (found.length === 0) { + const partial = calendars.filter((calendar) => calendar.name.toLowerCase().includes(lower)); + if (partial.length === 1) return partial; + throw new CalError(`no calendar "${wanted}". Available: ${calendars.map((calendar) => calendar.name).join(', ')}`); + } + return found; +} + +async function findEvent(account: Account, uid: string, calendarName: string | undefined): Promise<CalEvent> { + const dav = openCalDav(account); + const calendars = pickCalendars(await dav.calendars(), calendarName); + for (const calendar of calendars) { + const event = await dav.find(calendar, uid); + if (event) return event; + } + throw new CalError(`no event with uid ${uid} in ${calendars.map((calendar) => calendar.name).join(', ')}`); +} + +async function loginVerb(config: CalConfig, args: string[], parsed: ReturnType<typeof parseArgs>): Promise<number> { + const [first, second] = args; + const like = parsed.values.get('--like'); + let likeAccount: { email: string; password: string | null } | null = null; + if (like) { + const mail = loadMailConfig(); + const entry = mail.accounts[like.toLowerCase()]; + if (!entry) fail(`no mail account "${like}" to borrow from — \`mail accounts\` lists them`, 1); + const resolved = resolveMailAccount(like.toLowerCase(), entry); + likeAccount = { email: resolved.email, password: resolved.password }; + if (!likeAccount.password) fail(`mail account "${like}" has no password to borrow`, 1); + } + if (!first && !likeAccount) throw new UsageError('login needs a provider or an address: `cal login icloud you@icloud.com`'); + + let provider: ProviderName; + let email: string | undefined; + const nameOrAddress = first ?? likeAccount!.email; + if (nameOrAddress.includes('@')) { + email = nameOrAddress; + const blocked = unsupportedProvider(email); + if (blocked) fail(`${blocked.label} cannot be reached with a password: ${blocked.reason}`, 1); + const guessed = guessProvider(email); + if (guessed) provider = guessed; + else if (parsed.values.has('--url')) provider = 'custom'; + else { + fail( + `"${email}" is not on a domain that names its calendar host. Say which: ` + + `\`cal login <provider> ${email}\` with one of ${PROVIDER_NAMES.join(', ')}, ` + + `or \`cal login custom ${email} --url https://…\`. \`cal providers\` lists them.`, + 1, + ); + } + } else { + const requested = nameOrAddress.toLowerCase(); + if (isProviderName(requested)) provider = requested; + else { + const blocked = unsupportedProvider(requested); + if (blocked) fail(`${blocked.label} cannot be reached with a password: ${blocked.reason}`, 1); + fail(`no provider "${first}". Built in: ${PROVIDER_NAMES.join(', ')}, custom — \`cal providers\` for details.`, 1); + } + email = second ?? likeAccount?.email; + } + if (!email) { + if (!process.stdin.isTTY) throw new UsageError(`login needs the address: \`cal login ${provider} you@example.com\``); + email = await promptLine('address: '); + } + if (!email.includes('@')) throw new UsageError(`"${email}" is not an address`); + email = email.toLowerCase(); + + const name = (parsed.values.get('--as') ?? provider).toLowerCase(); + if (!/^[a-z0-9][a-z0-9_-]*$/.test(name)) { + throw new UsageError('an account name is letters, digits, - and _ — it becomes CAL_<NAME>_PASSWORD'); + } + const existing = config.accounts[name]; + const account: AccountConfig = { ...(existing ?? {}), email, provider }; + const user = parsed.values.get('--user'); + if (user !== undefined) account.user = user; + const url = parsed.values.get('--url'); + if (url !== undefined) account.url = url; + if (provider === 'custom' && !account.url) throw new UsageError('`login custom` needs --url'); + + const preset = providerFor(provider); + if (likeAccount?.password) { + account.password = likeAccount.password; + process.stderr.write(`using the password of mail account "${like}"\n`); + } else { + if (preset) process.stderr.write(`${loginHint(preset)}\n`); + const password = await promptSecret(`password for ${email}: `); + if (!password) fail('empty — nothing stored', 1); + account.password = password; + } + + if (!parsed.flags.has('--no-verify')) { + const resolved = resolveAccount(name, account, {}); + process.stderr.write(`finding calendars at ${resolved.url}…\n`); + let calendars: Calendar[]; + try { + calendars = await openCalDav(resolved).calendars(); + } catch (error) { + fail(`${(error as Error).message}\nNothing stored. --no-verify stores it anyway.`, 1); + } + if (calendars.length === 0) process.stderr.write('warning: the login works but the account has no calendars yet\n'); + else process.stderr.write(`${calendars.length} calendar${calendars.length === 1 ? '' : 's'}: ${calendars.map((calendar) => calendar.name).join(', ')}\n`); + } + + config.accounts[name] = account; + if (parsed.flags.has('--default') || Object.keys(config.accounts).length === 1) config.default = name; + const path = saveConfig(config); + out(`${existing ? 'updated' : 'logged in'}: ${name} (${email}, ${provider}) in ${path}`); + if (config.default === name) out(`${name} is the default account`); + else out(`\`cal ls -a ${name}\` reads it; \`cal accounts default ${name}\` makes it the default`); + return 0; +} + +async function accountsVerb(config: CalConfig, args: string[], parsed: ReturnType<typeof parseArgs>): Promise<number> { + const [verb, ...rest] = args; + if (!verb || verb === 'ls' || verb === 'list') { + const accounts = Object.entries(config.accounts).map(([name, entry]) => { + try { + return resolveAccount(name, entry); + } catch { + return { + name, + email: entry.email, + user: entry.user ?? entry.email, + password: null, + passwordSource: 'unset' as const, + provider: entry.provider, + url: entry.url ?? '?', + }; + } + }); + if (parsed.flags.has('--json')) { + json({ default: config.default ?? null, accounts: accounts.map(({ password: _password, ...account }) => account) }); + } else out(formatAccounts(accounts, config.default)); + return 0; + } + + if (verb === 'password') { + const name = rest[0]?.toLowerCase(); + if (!name) throw new UsageError('accounts password needs the account name'); + const account = config.accounts[name]; + if (!account) fail(`no account "${name}". Configured: ${Object.keys(config.accounts).join(', ') || 'none'}`, 1); + const preset = providerFor(account.provider); + if (preset) process.stderr.write(`(${preset.passwordHint})\n`); + const password = await promptSecret(`password for ${account.email}: `); + if (!password) fail('empty — nothing stored', 1); + account.password = password; + out(`stored the password for ${name} in ${saveConfig(config)}`); + return 0; + } + + if (verb === 'default') { + const name = rest[0]?.toLowerCase(); + if (!name) throw new UsageError('accounts default needs the account name'); + if (!config.accounts[name]) fail(`no account "${name}"`, 1); + config.default = name; + saveConfig(config); + out(`${name} is the default account`); + return 0; + } + + if (verb === 'rm') { + const name = rest[0]?.toLowerCase(); + if (!name) throw new UsageError('accounts rm needs the account name'); + if (!config.accounts[name]) fail(`no account "${name}"`, 1); + delete config.accounts[name]; + if (config.default === name) delete config.default; + saveConfig(config); + out(`removed ${name}`); + return 0; + } + + if (verb === 'pull') { + const base = vaultTarget(); + const target = { + team: base.team, + project: process.env.CLI_TOOLS_CAL_VAULT_PROJECT || CAL_VAULT_PROJECT, + env: process.env.CLI_TOOLS_CAL_VAULT_ENV || base.env, + }; + const label = `${target.team}/${target.project}--${target.env}`; + process.stderr.write(`accounts: pulling ${label}…\n`); + const fromVault = accountsFromVault(pullVault(target)); + if (Object.keys(fromVault.accounts).length === 0) { + fail( + `${label} holds no CAL_<NAME>_EMAIL keys. Push accounts there as\n` + + ' CAL_WORK_EMAIL=… CAL_WORK_PROVIDER=forwardemail CAL_WORK_PASSWORD=… CAL_DEFAULT=work', + 1, + ); + } + const { merged, changed, unchanged } = mergeVaultAccounts(config, fromVault); + if (changed.length > 0) { + const path = saveConfig(merged); + for (const name of changed) { + const account = merged.accounts[name]!; + out(`accounts: imported ${name} (${account.email}, ${account.provider}${account.password ? '' : ', no password'})`); + } + out(`accounts: written to ${path}`); + } + for (const name of unchanged) out(`accounts: ${name} already matches the vault`); + return 0; + } + + throw new UsageError(`unknown accounts verb "${verb}" (password, default, rm, pull)`); +} + +async function main(argv: string[]): Promise<number> { + const parsed = parseArgs(argv, { + boolean: ['--json', '--today', '--tomorrow', '--week', '--all-day', '--yes', '--default', '--no-verify', '-h', '--help'], + string: [ + '-a', '--account', '-c', '--calendar', '--days', '--from', '--to', '--at', '--end', '--for', '--where', '--notes', + '--link', '--as', '--like', '--user', '--url', + ], + }); + if (parsed.flags.has('-h') || parsed.flags.has('--help') || parsed.positional.length === 0) { + process.stdout.write(USAGE); + return 0; + } + const [command, ...rest] = parsed.positional; + const isJson = parsed.flags.has('--json'); + const selector = parsed.values.get('-a') ?? parsed.values.get('--account'); + const calendarName = parsed.values.get('-c') ?? parsed.values.get('--calendar'); + const config = loadConfig(); + + switch (command) { + case 'accounts': + case 'account': + return accountsVerb(config, rest, parsed); + + case 'login': + return loginVerb(config, rest, parsed); + + case 'providers': + out(formatProviders()); + return 0; + + case 'calendars': { + const account = selectAccount(config, selector); + const calendars = await openCalDav(account).calendars(); + if (isJson) json(calendars); + else out(formatCalendars(calendars)); + return 0; + } + + case 'ls': + case 'list': + case 'agenda': { + const account = selectAccount(config, selector); + const window = windowFrom({ + today: parsed.flags.has('--today'), + tomorrow: parsed.flags.has('--tomorrow'), + week: parsed.flags.has('--week'), + days: integer(parsed.values, '--days', 7, { min: 1, max: 366 }), + ...(parsed.values.has('--from') ? { from: parsed.values.get('--from')! } : {}), + ...(parsed.values.has('--to') ? { to: parsed.values.get('--to')! } : {}), + }); + const dav = openCalDav(account); + const calendars = pickCalendars(await dav.calendars(), calendarName); + const events = (await Promise.all(calendars.map((calendar) => dav.events(calendar, window.from, window.to)))) + .flat() + .sort((a, b) => a.start.localeCompare(b.start)); + if (isJson) json(events); + else { + process.stderr.write(`${account.name}: ${window.label}\n`); + out(formatAgenda(events, { showCalendar: calendars.length > 1, width: process.stdout.columns || 100 })); + } + return 0; + } + + case 'show': { + const uid = rest[0]; + if (!uid) throw new UsageError('show needs the event uid — `cal ls --json` shows them'); + const account = selectAccount(config, selector); + const event = await findEvent(account, uid, calendarName); + if (isJson) json(event); + else out(formatEvent(event)); + return 0; + } + + case 'add': { + const title = rest.join(' ').trim(); + if (!title) throw new UsageError('add needs a title: `cal add "Dentist" --at "tomorrow 9:30"`'); + const at = parsed.values.get('--at'); + if (!at) throw new UsageError('add needs --at: 2026-09-06 14:00, tomorrow 9:30, friday 2pm, or a bare day for all day'); + const start = parseWhen(at); + const allDay = parsed.flags.has('--all-day') || start.allDay; + let end: string; + if (allDay) { + const day = start.allDay ? start.value : new Date(start.value).toISOString().slice(0, 10); + const endFlag = parsed.values.get('--end'); + const days = parsed.values.has('--for') ? Math.max(1, Math.round(durationMinutes(parsed.values.get('--for')!) / 1440)) : 1; + const [y, m, d] = day.split('-').map(Number); + const last = endFlag ? parseWhen(endFlag).value.slice(0, 10) : null; + end = last + ? new Date(Date.UTC(Number(last.slice(0, 4)), Number(last.slice(5, 7)) - 1, Number(last.slice(8, 10)) + 1)).toISOString().slice(0, 10) + : new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10); + start.value = day; + start.allDay = true; + } else { + const endFlag = parsed.values.get('--end'); + const minutes = parsed.values.has('--for') ? durationMinutes(parsed.values.get('--for')!) : 60; + end = endFlag ? parseWhen(endFlag).value : new Date(new Date(start.value).getTime() + minutes * 60_000).toISOString(); + if (new Date(end) <= new Date(start.value)) throw new UsageError('--end must come after --at'); + } + const account = selectAccount(config, selector); + const dav = openCalDav(account); + const calendars = await dav.calendars(); + if (calendars.length === 0) fail(`${account.name} has no calendars to add to`, 1); + const calendar = calendarName ? pickCalendars(calendars, calendarName)[0]! : calendars[0]!; + const { uid, ics } = buildIcs({ + summary: title, + start: start.value, + end, + allDay, + ...(parsed.values.has('--where') ? { location: parsed.values.get('--where')! } : {}), + ...(parsed.values.has('--notes') ? { description: parsed.values.get('--notes')! } : {}), + ...(parsed.values.has('--link') ? { url: parsed.values.get('--link')! } : {}), + }); + const href = await dav.put(calendar, ics, uid); + if (isJson) json({ uid, href, calendar: calendar.name, start: start.value, end, allDay }); + else out(`added to ${calendar.name}: ${title}\n${formatAgenda([{ uid, summary: title, location: parsed.values.get('--where') ?? '', description: '', url: '', start: start.value, end, allDay, status: '', recurring: false, href, etag: null, calendar: null }])}\nuid ${uid}`); + return 0; + } + + case 'rm': { + const uid = rest[0]; + if (!uid) throw new UsageError('rm needs the event uid — `cal ls --json` shows them'); + const account = selectAccount(config, selector); + const event = await findEvent(account, uid, calendarName); + if (event.recurring) process.stderr.write('note: this event repeats; removing it removes every occurrence\n'); + if (!parsed.flags.has('--yes')) { + const ok = await confirm(`remove "${event.summary}" (${event.allDay ? event.start : event.start.slice(0, 16)})?`); + if (!ok) fail('not removed (pass --yes to skip the question)', 1); + } + await openCalDav(account).remove(event.href!, event.etag); + out(`removed: ${event.summary}`); + return 0; + } + + default: + throw new UsageError(`unknown command "${command}" — see --help`); + } +} + +if (isMain(import.meta.url)) { + main(process.argv.slice(2)).then( + (code) => process.exit(code), + (error: unknown) => { + if (error instanceof UsageError) { + process.stderr.write(`${USAGE}\ncal: ${error.message}\n`); + process.exit(2); + } + if (error instanceof CalError) fail(error.message, 1); + fail((error as Error).stack ?? String(error), 1); + }, + ); +} diff --git a/package.json b/package.json index 5c89d4a..117d7f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/cli-tools", - "version": "0.25.0", + "version": "0.26.0", "private": true, "description": "Local command-line tools, in TypeScript, exposed on PATH.", "type": "module", diff --git a/plugins/cal/.claude-plugin/plugin.json b/plugins/cal/.claude-plugin/plugin.json new file mode 100644 index 0000000..3b2c48c --- /dev/null +++ b/plugins/cal/.claude-plugin/plugin.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://anthropic.com/claude-code/plugin.schema.json", + "name": "cal", + "description": "The calendar from the terminal, over CalDAV: sign in to iCloud, Fastmail, Zoho, Yahoo, Forward Email, Nextcloud or any CalDAV host with the right kind of password, then read the agenda for today, the week or a range, look at one event, add one, or remove one.", + "version": "0.1.0", + "author": { + "name": "profullstack", + "url": "https://profullstack.com" + }, + "homepage": "https://github.com/profullstack/cli-tools#cal", + "license": "MIT", + "keywords": [ + "calendar", + "caldav", + "agenda", + "icloud", + "fastmail", + "nextcloud" + ] +} diff --git a/plugins/cal/README.md b/plugins/cal/README.md new file mode 100644 index 0000000..4485ce9 --- /dev/null +++ b/plugins/cal/README.md @@ -0,0 +1,44 @@ +# cal + +The calendar from the terminal, over CalDAV. + +`/cal:login` signs in to iCloud, Fastmail, Zoho, Yahoo, AOL, GMX, +mailbox.org, Posteo, Forward Email, or any CalDAV server by URL (Nextcloud, +Radicale, Baïkal, Stalwart), saying which kind of password it wants and +finding the calendars before storing anything. `/cal:agenda` reads today, +the week or a range, and one event in full. `/cal:add` puts an event on and +takes one off. + +## Install + +```bash +moshcode plugin marketplace add profullstack/cli-tools +moshcode plugin install cal@cli-tools +``` + +Or install the command directly, without the plugin: + +```bash +curl -fsSL https://raw.githubusercontent.com/profullstack/cli-tools/master/install.sh | sh +cal accounts pull # accounts from the cli-tools-cal team vault +cal login forwardemail --like work # or borrow the mail account's password +``` + +## The thing worth knowing + +**No library, three requests.** CalDAV is PROPFIND to find the principal +and its calendars, REPORT to read a window, PUT and DELETE to write. The +server expands recurring events (`<C:expand>`), so a weekly standup shows on +every day it happens without this code implementing RRULE, and a removal is +of the whole series, which the command says before asking. + +**The password is the same problem as mail.** iCloud and Fastmail refuse the +account password and want the app password their mail already uses, so +`--like <mail-account>` borrows it. Google is different: an app password +opens Gmail over IMAP, but Google's CalDAV endpoint takes only OAuth2, so +`cal login google` explains rather than fails. + +**Nothing here names a person.** Accounts live in +`~/.config/cli-tools/cal.json` (0600) or in the vault as `CAL_<NAME>_EMAIL` +/ `_PROVIDER` / `_PASSWORD`; the environment's `CAL_<NAME>_PASSWORD` wins +over the stored one, and `cal accounts` never prints a password. diff --git a/plugins/cal/commands/add.md b/plugins/cal/commands/add.md new file mode 100644 index 0000000..48e9e4b --- /dev/null +++ b/plugins/cal/commands/add.md @@ -0,0 +1,37 @@ +--- +description: Put an event on the calendar, or take one off. +allowed-tools: Bash(cal:*), Read +--- + +## Task + +Add an event, or remove one by uid. + +```bash +cal add "Dentist" --at "tomorrow 9:30" # one hour, first calendar +cal add "Standup" --at "2026-09-07 09:00" --for 30m -c Work +cal add "Offsite" --at 2026-09-10 --all-day --for 2d --where "Lake house" +cal add "Call Sam" --at "fri 2pm" --end "fri 2:45pm" --notes "re: invoice" --link https://… +cal rm <uid> # asks first; --yes skips it +``` + +`$ARGUMENTS` is passed through: `/cal:add "Dentist" --at "tomorrow 9:30"`. + +## Before adding on someone's behalf + +Say back the title, the day and the time you are about to send, in their +words, and get a yes. `--at` takes `2026-09-06 14:00`, `tomorrow 9:30`, +`friday 2pm`, `14:00` (today), or a bare day, which makes the event all-day. +A weekday name means the next one, never today. The default length is one +hour; `--for 30m`, `--for 1h30m`, `--for 2d`, or `--end`. + +The event goes to the first calendar unless `-c` names one — check +`cal calendars` when the account has several, because "first" is the +server's order, not the user's favourite. + +## Removing + +`cal rm` looks the uid up first and shows the title and time before asking, +so a wrong uid is caught before anything happens. A recurring event is +removed whole — every occurrence — and the command says so first. There is +no undo; when in doubt, show it (`cal show <uid>`) and ask. diff --git a/plugins/cal/commands/agenda.md b/plugins/cal/commands/agenda.md new file mode 100644 index 0000000..23def2d --- /dev/null +++ b/plugins/cal/commands/agenda.md @@ -0,0 +1,42 @@ +--- +description: What is on the calendar today, this week, or between two dates, and the details of one event. +allowed-tools: Bash(cal:*), Read +--- + +## Task + +Read the calendar from the terminal. Times print in this machine's zone. + +```bash +cal ls # the next 7 days, every calendar in the default account +cal ls --today +cal ls --tomorrow +cal ls --week -c Work # one calendar, by name +cal ls --from 2026-10-01 --to 2026-10-15 +cal ls --days 30 --json # uids are in the JSON +cal show <uid> # title, when, where, notes, uid +cal calendars # what the account has +cal ls -a home # another account +``` + +`$ARGUMENTS` is passed through: `/cal:agenda --today -a home`. + +## How to answer with it + +For "what's on today / this week", run `cal ls --today` or `cal ls --week` +and relay the lines as they are — they are already grouped by day with the +time first. Recurring events arrive as one line per occurrence, because the +server expands them; do not add up occurrences as if they were separate +events. `(cancelled)` at the end of a line is the event's status, not a +change you made. + +## Setting up + +```bash +cal accounts pull # import from the cli-tools-cal team vault +cal login icloud you@icloud.com # or sign in; says which password it wants +cal login forwardemail --like work # reuse the `mail` account's address and password +``` + +Google Calendar cannot be added: its CalDAV takes only OAuth2, and an app +password opens Gmail but not the calendar. `cal providers` lists what can. diff --git a/plugins/cal/commands/login.md b/plugins/cal/commands/login.md new file mode 100644 index 0000000..86cd0b6 --- /dev/null +++ b/plugins/cal/commands/login.md @@ -0,0 +1,40 @@ +--- +description: Sign in to a calendar provider — iCloud, Fastmail, Zoho, Yahoo, Forward Email, Nextcloud and more — with the right kind of password, verified before it is stored. +allowed-tools: Bash(cal:*), Read +--- + +## Task + +Add a calendar account. Name the provider, or the address when its domain +gives the provider away. + +```bash +cal providers # everything built in, and what each wants +cal login icloud you@icloud.com # says "app password", finds the calendars, stores it +cal login you@fastmail.com # provider read off the address +cal login forwardemail --like work # reuse the `mail` account's address and password +cal login zoho you@yourco.com --url https://calendar.zoho.eu/caldav/ +cal login custom you@x.org --url https://cloud.x.org/remote.php/dav # Nextcloud, Radicale, Baïkal … +``` + +`$ARGUMENTS` is passed through: `/cal:login icloud you@icloud.com --as home`. + +## What happens + +1. The provider's password rule is printed first. iCloud, Fastmail, Yahoo, + AOL and (with two-factor on) Zoho want a generated app password — for + iCloud and Fastmail the same one their mail takes. Forward Email wants the + per-alias password. The rest take the account password. +2. The password is read without echo, or borrowed from a `mail` account with + `--like`, since most providers use one password for both. +3. The calendars are discovered. A failed login stores nothing and names the + likely cause. +4. The account lands in `~/.config/cli-tools/cal.json` (0600) under the + provider's name, or `--as NAME`. The first account becomes the default. + +## Not reachable with a password + +Google Calendar takes only OAuth2 at its CalDAV endpoint, so a Gmail app +password opens the mailbox but not the calendar. Outlook.com and Microsoft +365 have no CalDAV. Proton Calendar has no CalDAV on any plan. `cal login +google` says so instead of failing a login. diff --git a/src/cal.ts b/src/cal.ts new file mode 100644 index 0000000..cbeb44a --- /dev/null +++ b/src/cal.ts @@ -0,0 +1,1093 @@ +/** + * The calendar from the terminal, over CalDAV. + * + * Every host that still takes a password speaks the same protocol: PROPFIND + * to find the principal and its calendars, REPORT to read a window of events, + * PUT and DELETE to write. Nothing here depends on a library, because the + * three requests involved are short and the XML they return is shallow. + * + * Accounts are configuration (`~/.config/cli-tools/cal.json`, 0600, or the + * `cli-tools-cal` team vault) and mirror `mail`: a provider names the server + * and the kind of password it wants, and `cal login` says which before + * asking. Google Calendar is listed but not reachable — its CalDAV endpoint + * takes only OAuth2 — for the same reason `mail` lists Outlook. + * + * Recurring events are expanded by the server (`<C:expand>` in the REPORT), + * so a weekly standup shows up on every day it happens without this file + * implementing RRULE. + */ + +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export class CalError extends Error { + constructor(message: string) { + super(message); + this.name = 'CalError'; + } +} + +// --------------------------------------------------------------------------- +// Providers +// --------------------------------------------------------------------------- + +export type PasswordKind = 'account' | 'app' | 'alias'; + +export interface Provider { + label: string; + /** Where discovery starts. The principal and calendars may live elsewhere. */ + url: string; + passwordKind: PasswordKind; + passwordHint: string; + passwordUrl?: string; + domains: string[]; + note?: string; +} + +export type BuiltInProvider = + | 'forwardemail' + | 'icloud' + | 'fastmail' + | 'zoho' + | 'yahoo' + | 'aol' + | 'gmx' + | 'mailbox' + | 'posteo'; + +export type ProviderName = BuiltInProvider | 'custom'; + +export const PROVIDERS: Record<BuiltInProvider, Provider> = { + forwardemail: { + label: 'Forward Email', + url: 'https://caldav.forwardemail.net', + passwordKind: 'alias', + passwordHint: + 'the alias password generated in the Forward Email dashboard (Aliases → the address → ' + + 'Generate Password) — the same one IMAP takes', + passwordUrl: 'https://forwardemail.net/my-account/domains', + domains: ['forwardemail.net'], + }, + icloud: { + label: 'iCloud Calendar', + url: 'https://caldav.icloud.com', + passwordKind: 'app', + passwordHint: + 'an app-specific password from the Apple Account page (Sign-In and Security → App-Specific ' + + 'Passwords) — the same one iCloud Mail takes', + passwordUrl: 'https://account.apple.com/account/manage', + domains: ['icloud.com', 'me.com', 'mac.com'], + note: 'Log in with the Apple Account address; the calendars live on a numbered pNN-caldav host that discovery finds.', + }, + fastmail: { + label: 'Fastmail', + url: 'https://caldav.fastmail.com/dav/', + passwordKind: 'app', + passwordHint: + 'an app password from Settings → Privacy & Security → Integrations → New app password, ' + + 'with calendar access', + passwordUrl: 'https://app.fastmail.com/settings/security/devices', + domains: ['fastmail.com', 'fastmail.fm', 'fastmail.us', 'sent.com'], + }, + zoho: { + label: 'Zoho Calendar', + url: 'https://calendar.zoho.com/caldav/', + passwordKind: 'app', + passwordHint: + 'the account password, or an application-specific password when two-factor authentication is on', + passwordUrl: 'https://accounts.zoho.com/home#security/security_password', + domains: ['zoho.com', 'zohomail.com', 'zoho.eu', 'zoho.in'], + note: 'An EU or IN data centre uses calendar.zoho.eu / calendar.zoho.in — pass --url.', + }, + yahoo: { + label: 'Yahoo Calendar', + url: 'https://caldav.calendar.yahoo.com', + passwordKind: 'app', + passwordHint: 'an app password from Account Security → Generate app password', + passwordUrl: 'https://login.yahoo.com/account/security', + domains: ['yahoo.com', 'yahoo.co.uk', 'yahoo.ca', 'yahoo.com.au', 'yahoo.fr', 'yahoo.de', 'ymail.com', 'rocketmail.com'], + }, + aol: { + label: 'AOL Calendar', + url: 'https://caldav.aol.com', + passwordKind: 'app', + passwordHint: 'an app password from Account Security → Generate app password', + passwordUrl: 'https://login.aol.com/account/security', + domains: ['aol.com', 'aim.com'], + }, + gmx: { + label: 'GMX', + url: 'https://caldav.gmx.net', + passwordKind: 'account', + passwordHint: 'the account password, once CalDAV is enabled (Settings → Calendar → CalDAV)', + domains: ['gmx.com', 'gmx.us', 'gmx.net', 'gmx.de', 'gmx.at', 'gmx.ch'], + }, + mailbox: { + label: 'mailbox.org', + url: 'https://dav.mailbox.org', + passwordKind: 'account', + passwordHint: 'the account password, or an app password when two-factor authentication is on', + domains: ['mailbox.org'], + }, + posteo: { + label: 'Posteo', + url: 'https://posteo.de:8443', + passwordKind: 'account', + passwordHint: 'the account password', + domains: ['posteo.de', 'posteo.net', 'posteo.eu', 'posteo.org'], + }, +}; + +export const PROVIDER_NAMES = Object.keys(PROVIDERS) as BuiltInProvider[]; + +export function isProviderName(value: unknown): value is ProviderName { + return value === 'custom' || (typeof value === 'string' && Object.hasOwn(PROVIDERS, value)); +} + +export function providerFor(name: ProviderName): Provider | null { + return name === 'custom' ? null : PROVIDERS[name]; +} + +export interface UnsupportedProvider { + label: string; + domains: string[]; + reason: string; +} + +/** Calendars a password cannot reach, so `cal login google` explains itself. */ +export const UNSUPPORTED_PROVIDERS: Record<string, UnsupportedProvider> = { + google: { + label: 'Google Calendar', + domains: ['gmail.com', 'googlemail.com'], + reason: + "Google's CalDAV endpoint takes only OAuth2 tokens; an app password opens Gmail over IMAP but not the calendar. " + + 'Use a client with Google sign-in.', + }, + outlook: { + label: 'Outlook.com / Microsoft 365', + domains: ['outlook.com', 'hotmail.com', 'live.com', 'msn.com'], + reason: 'no CalDAV at all; the calendar is only reachable through the Microsoft Graph API with OAuth2', + }, + proton: { + label: 'Proton Calendar', + domains: ['proton.me', 'protonmail.com', 'protonmail.ch', 'pm.me'], + reason: 'no CalDAV on any plan, paid or free; the Bridge carries mail only', + }, +}; + +export function domainOf(email: string): string { + const at = email.lastIndexOf('@'); + return at === -1 ? '' : email.slice(at + 1).toLowerCase(); +} + +export function guessProvider(email: string): BuiltInProvider | null { + const domain = domainOf(email); + for (const name of PROVIDER_NAMES) { + if (PROVIDERS[name].domains.includes(domain)) return name; + } + return null; +} + +export function unsupportedProvider(nameOrEmail: string): (UnsupportedProvider & { name: string }) | null { + const key = nameOrEmail.toLowerCase(); + const domain = domainOf(key); + for (const [name, provider] of Object.entries(UNSUPPORTED_PROVIDERS)) { + if (name === key || (domain && provider.domains.includes(domain))) return { name, ...provider }; + } + return null; +} + +export function loginHint(provider: Provider): string { + const kind: Record<PasswordKind, string> = { + account: `${provider.label} takes the account password.`, + app: `${provider.label} takes an app password, not the account password.`, + alias: `${provider.label} takes a password generated per address.`, + }; + const lines = [kind[provider.passwordKind], ` ${provider.passwordHint}`]; + if (provider.passwordUrl) lines.push(` ${provider.passwordUrl}`); + if (provider.note) lines.push(` ${provider.note}`); + return lines.join('\n'); +} + +export function formatProviders(): string { + const kinds: Record<PasswordKind, string> = { + account: 'account password', + app: 'app password', + alias: 'per-address password', + }; + const width = Math.max(...PROVIDER_NAMES.map((name) => name.length), 'custom'.length); + const rows = PROVIDER_NAMES.map((name) => { + const provider = PROVIDERS[name]; + return ` ${name.padEnd(width)} ${provider.label}\n${' '.repeat(width + 4)}${kinds[provider.passwordKind]}; ${provider.url}`; + }); + rows.push( + ` ${'custom'.padEnd(width)} any CalDAV server: --url https://host/dav (Nextcloud: /remote.php/dav; Radicale, Baïkal, Stalwart …)`, + ); + const unsupported = Object.entries(UNSUPPORTED_PROVIDERS).map( + ([name, provider]) => ` ${name.padEnd(width)} ${provider.label}: ${provider.reason}`, + ); + return ['Providers (`cal login <name> <address>`):', ...rows, '', 'Not reachable with a password:', ...unsupported].join( + '\n', + ); +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +export interface AccountConfig { + email: string; + provider: ProviderName; + /** Login, when it is not the address itself. */ + user?: string; + password?: string; + /** Discovery URL, for `custom` or to override a preset (a regional Zoho host). */ + url?: string; +} + +export interface CalConfig { + default?: string; + accounts: Record<string, AccountConfig>; +} + +export type PasswordSource = 'env' | 'file' | 'unset'; + +export interface Account { + name: string; + email: string; + user: string; + password: string | null; + passwordSource: PasswordSource; + provider: ProviderName; + url: string; +} + +function xdgConfigHome(env: NodeJS.ProcessEnv): string { + return env.XDG_CONFIG_HOME || join(homedir(), '.config'); +} + +export function calConfigPath(env: NodeJS.ProcessEnv = process.env): string { + return env.CLI_TOOLS_CAL_CONFIG || join(xdgConfigHome(env), 'cli-tools', 'cal.json'); +} + +export function passwordVariable(name: string): string { + return `CAL_${name.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_PASSWORD`; +} + +export function loadConfig(env: NodeJS.ProcessEnv = process.env): CalConfig { + const path = calConfigPath(env); + let text: string; + try { + text = readFileSync(path, 'utf8'); + } catch { + return { accounts: {} }; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new CalError(`${path}: not valid JSON — ${(error as Error).message}`); + } + return normalizeConfig(parsed); +} + +export function normalizeConfig(parsed: unknown): CalConfig { + const config: CalConfig = { accounts: {} }; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return config; + const record = parsed as Record<string, unknown>; + if (typeof record.default === 'string' && record.default.trim()) config.default = record.default.trim(); + const accounts = record.accounts; + if (!accounts || typeof accounts !== 'object' || Array.isArray(accounts)) return config; + for (const [name, raw] of Object.entries(accounts as Record<string, unknown>)) { + if (!raw || typeof raw !== 'object') continue; + const entry = raw as Record<string, unknown>; + if (typeof entry.email !== 'string' || !entry.email.includes('@')) continue; + const account: AccountConfig = { + email: entry.email.trim().toLowerCase(), + provider: isProviderName(entry.provider) ? entry.provider : (guessProvider(entry.email) ?? 'custom'), + }; + for (const key of ['user', 'password', 'url'] as const) { + const value = entry[key]; + if (typeof value === 'string' && value.trim()) account[key] = value.trim(); + } + config.accounts[name] = account; + } + return config; +} + +export function saveConfig(config: CalConfig, env: NodeJS.ProcessEnv = process.env): string { + const path = calConfigPath(env); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); + chmodSync(path, 0o600); + return path; +} + +export function resolveAccount(name: string, config: AccountConfig, env: NodeJS.ProcessEnv = process.env): Account { + const preset = providerFor(config.provider); + const url = config.url ?? preset?.url; + if (!url) { + throw new CalError( + `account "${name}" is provider "custom" and needs a URL — \`cal login custom ${config.email} --url https://…\``, + ); + } + const fromEnv = env[passwordVariable(name)]; + const password = fromEnv || config.password || null; + return { + name, + email: config.email, + user: config.user ?? config.email, + password, + passwordSource: fromEnv ? 'env' : config.password ? 'file' : 'unset', + provider: config.provider, + url, + }; +} + +/** Which account a selector means — a name, an address, or the default. */ +export function selectAccount(config: CalConfig, selector: string | undefined, env: NodeJS.ProcessEnv = process.env): Account { + const names = Object.keys(config.accounts); + if (names.length === 0) { + throw new CalError('no accounts — `cal login <provider> <address>` adds one, `cal accounts pull` imports them'); + } + let name: string | undefined; + if (selector) { + const wanted = selector.toLowerCase(); + name = names.find((candidate) => candidate === wanted || config.accounts[candidate]!.email === wanted); + if (!name) throw new CalError(`no account "${selector}". Configured: ${names.join(', ')}`); + } else { + name = config.default ?? env.CAL_ACCOUNT ?? (names.length === 1 ? names[0] : undefined); + if (!name || !config.accounts[name]) { + throw new CalError(`which account? -a one of ${names.join(', ')}, or \`cal accounts default <name>\``); + } + } + return resolveAccount(name, config.accounts[name]!, env); +} + +export const CAL_VAULT_PROJECT = 'cli-tools-cal'; + +/** CAL_<NAME>_EMAIL / _PROVIDER / _PASSWORD / _USER / _URL and CAL_DEFAULT. */ +export function accountsFromVault(vault: Record<string, string>): CalConfig { + const config: CalConfig = { accounts: {} }; + const pattern = /^CAL_([A-Z0-9_]+?)_(EMAIL|PROVIDER|PASSWORD|USER|URL)$/; + const partial: Record<string, Record<string, string>> = {}; + for (const [key, value] of Object.entries(vault)) { + if (key === 'CAL_DEFAULT') { + config.default = value.trim().toLowerCase(); + continue; + } + const match = pattern.exec(key); + if (!match) continue; + (partial[match[1]!.toLowerCase()] ??= {})[match[2]!] = value.trim(); + } + for (const [name, fields] of Object.entries(partial)) { + const email = fields.EMAIL; + if (!email || !email.includes('@')) continue; + const provider = fields.PROVIDER?.toLowerCase(); + const account: AccountConfig = { + email: email.toLowerCase(), + provider: isProviderName(provider) ? provider : (guessProvider(email) ?? (fields.URL ? 'custom' : 'forwardemail')), + }; + if (fields.PASSWORD) account.password = fields.PASSWORD; + if (fields.USER) account.user = fields.USER; + if (fields.URL) account.url = fields.URL; + config.accounts[name] = account; + } + if (config.default && !config.accounts[config.default]) delete config.default; + return config; +} + +/** The vault wins for every field it names; local-only accounts are left alone. */ +export function mergeVaultAccounts(local: CalConfig, vault: CalConfig): { merged: CalConfig; changed: string[]; unchanged: string[] } { + const merged: CalConfig = { accounts: { ...local.accounts } }; + if (local.default) merged.default = local.default; + const changed: string[] = []; + const unchanged: string[] = []; + for (const [name, account] of Object.entries(vault.accounts)) { + const before = JSON.stringify(local.accounts[name] ?? null); + merged.accounts[name] = { ...(local.accounts[name] ?? {}), ...account }; + if (JSON.stringify(merged.accounts[name]) === before) unchanged.push(name); + else changed.push(name); + } + if (vault.default) merged.default = vault.default; + return { merged, changed, unchanged }; +} + +// --------------------------------------------------------------------------- +// XML — the little of it CalDAV returns +// --------------------------------------------------------------------------- + +function decodeXml(text: string): string { + return text + .replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1') + .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, dec: string) => String.fromCodePoint(Number(dec))) + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&'); +} + +function escapeXml(text: string): string { + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); +} + +/** The text of the first `<name>` element, prefix ignored, or null. */ +export function xmlText(block: string, name: string): string | null { + const match = new RegExp(`<(?:[\\w-]+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${name}>`, 'i').exec(block); + return match ? decodeXml(match[1]!).trim() : null; +} + +/** Every `<name>` element's inner XML, prefix ignored. */ +export function xmlBlocks(text: string, name: string): string[] { + const pattern = new RegExp(`<(?:[\\w-]+:)?${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/(?:[\\w-]+:)?${name}>`, 'gi'); + const blocks: string[] = []; + for (const match of text.matchAll(pattern)) blocks.push(match[1]!); + return blocks; +} + +/** Is an empty or non-empty `<name/>` element present, prefix ignored? */ +export function xmlHas(block: string, name: string): boolean { + return new RegExp(`<(?:[\\w-]+:)?${name}(?:\\s[^>]*)?\\/?>`, 'i').test(block); +} + +/** A `<D:href>` found inside `<name>`, resolved against the request URL. */ +function hrefIn(block: string, name: string, base: string): string | null { + const inner = xmlText(block, name); + if (!inner) return null; + // xmlText decoded the entities, so the href element may now be raw text. + const href = /<(?:[\w-]+:)?href[^>]*>([\s\S]*?)<\/(?:[\w-]+:)?href>/i.exec(inner)?.[1] ?? inner; + return new URL(href.trim(), base).toString(); +} + +// --------------------------------------------------------------------------- +// CalDAV +// --------------------------------------------------------------------------- + +export interface DavResponse { + status: number; + text: string; + etag: string | null; +} + +export type Fetcher = (url: string, init: RequestInit) => Promise<Response>; + +export interface Calendar { + name: string; + url: string; + color: string | null; + /** Component kinds the server allows here; empty means it did not say. */ + components: string[]; +} + +export interface CalEvent { + uid: string; + summary: string; + location: string; + description: string; + url: string; + /** ISO instant, or a `YYYY-MM-DD` when `allDay`. */ + start: string; + /** Exclusive end, same shape as `start`. */ + end: string; + allDay: boolean; + status: string; + recurring: boolean; + /** Where it lives on the server; null for an event not fetched from one. */ + href: string | null; + etag: string | null; + calendar: string | null; +} + +export interface CalDav { + calendars(): Promise<Calendar[]>; + events(calendar: Calendar, from: Date, to: Date): Promise<CalEvent[]>; + find(calendar: Calendar, uid: string): Promise<CalEvent | null>; + put(calendar: Calendar, ics: string, uid: string): Promise<string>; + remove(href: string, etag: string | null): Promise<void>; +} + +/** What a 401 means for this provider, so the fix is named. */ +export function loginFailure(account: Account, status: number, detail: string): string { + const preset = providerFor(account.provider); + let advice = ''; + if (preset?.passwordKind === 'app') { + advice = `\n${preset.label} refuses the account password over CalDAV; use an app password` + + (preset.passwordUrl ? ` (${preset.passwordUrl})` : '') + '.'; + } else if (preset?.passwordKind === 'alias') { + advice = `\n${preset.label}: ${preset.passwordHint}.`; + } + return `CalDAV login to ${account.url} as ${account.user} failed (${status}${detail ? `: ${detail}` : ''})${advice}`; +} + +/** A CalDAV client for one account. Requests carry Basic auth over HTTPS only. */ +export function openCalDav(account: Account, fetchImpl: Fetcher = fetch): CalDav { + if (!account.password) { + const preset = providerFor(account.provider); + throw new CalError( + `account "${account.name}" has no password${preset ? ` — ${preset.passwordHint}` : ''}.\n` + + `Store it with \`cal accounts password ${account.name}\`, export ${passwordVariable(account.name)}, ` + + 'or put it in the vault and run `cal accounts pull`.', + ); + } + if (!account.url.startsWith('https://') && !/^https?:\/\/(localhost|127\.0\.0\.1)/.test(account.url)) { + throw new CalError(`account "${account.name}" would send its password in clear over ${account.url}; use https://`); + } + const auth = `Basic ${Buffer.from(`${account.user}:${account.password}`).toString('base64')}`; + + async function request( + method: string, + url: string, + options: { depth?: string; body?: string; contentType?: string; headers?: Record<string, string> } = {}, + ): Promise<DavResponse> { + const headers: Record<string, string> = { + Authorization: auth, + 'User-Agent': 'cli-tools cal', + ...(options.depth !== undefined ? { Depth: options.depth } : {}), + ...(options.contentType ? { 'Content-Type': options.contentType } : {}), + ...(options.headers ?? {}), + }; + let response: Response; + try { + response = await fetchImpl(url, { + method, + headers, + ...(options.body !== undefined ? { body: options.body } : {}), + redirect: 'manual', + signal: AbortSignal.timeout(30_000), + }); + } catch (error) { + throw new CalError(`${method} ${url}: ${(error as Error).message}`); + } + if (response.status === 401 || response.status === 403) { + throw new CalError(loginFailure(account, response.status, response.statusText)); + } + // A few servers answer the discovery root with a redirect to the real DAV path. + if ([301, 302, 307, 308].includes(response.status)) { + const location = response.headers.get('location'); + if (location) return request(method, new URL(location, url).toString(), options); + } + const text = await response.text(); + return { status: response.status, text, etag: response.headers.get('etag') }; + } + + async function propfind(url: string, props: string, depth: string): Promise<DavResponse> { + const body = + '<?xml version="1.0" encoding="utf-8"?>' + + '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:A="http://apple.com/ns/ical/">' + + `<D:prop>${props}</D:prop></D:propfind>`; + const response = await request('PROPFIND', url, { depth, body, contentType: 'application/xml; charset=utf-8' }); + if (response.status < 200 || response.status >= 300) { + throw new CalError(`PROPFIND ${url} answered ${response.status}${response.text ? `: ${response.text.slice(0, 200)}` : ''}`); + } + return response; + } + + let homeUrl: string | null = null; + + async function calendarHome(): Promise<string> { + if (homeUrl) return homeUrl; + const root = await propfind(account.url, '<D:current-user-principal/><C:calendar-home-set/>', '0'); + let home = hrefIn(root.text, 'calendar-home-set', account.url); + if (!home) { + const principal = hrefIn(root.text, 'current-user-principal', account.url); + if (!principal) { + throw new CalError(`${account.url} did not name a principal; is this the CalDAV URL? (\`cal providers\` lists the usual ones)`); + } + const found = await propfind(principal, '<C:calendar-home-set/>', '0'); + home = hrefIn(found.text, 'calendar-home-set', principal); + if (!home) throw new CalError(`${principal} has no calendar-home-set; the account may have no calendar service`); + } + homeUrl = home; + return home; + } + + return { + async calendars() { + const home = await calendarHome(); + const response = await propfind( + home, + '<D:displayname/><D:resourcetype/><C:supported-calendar-component-set/><A:calendar-color/>', + '1', + ); + const calendars: Calendar[] = []; + for (const block of xmlBlocks(response.text, 'response')) { + const href = xmlText(block, 'href'); + if (!href) continue; + const type = xmlText(block, 'resourcetype') ?? ''; + if (!xmlHas(type, 'calendar')) continue; + const comps = xmlText(block, 'supported-calendar-component-set') ?? ''; + const components = [...comps.matchAll(/name="([A-Z]+)"/g)].map((match) => match[1]!); + if (components.length > 0 && !components.includes('VEVENT')) continue; + const url = new URL(href, home).toString(); + calendars.push({ + name: xmlText(block, 'displayname') || decodeURIComponent(url.replace(/\/$/, '').split('/').pop() ?? url), + url, + color: xmlText(block, 'calendar-color'), + components, + }); + } + return calendars; + }, + + async events(calendar, from, to) { + const range = `start="${icsUtc(from)}" end="${icsUtc(to)}"`; + const body = + '<?xml version="1.0" encoding="utf-8"?>' + + '<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' + + `<D:prop><D:getetag/><C:calendar-data><C:expand ${range}/></C:calendar-data></D:prop>` + + `<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"><C:time-range ${range}/></C:comp-filter></C:comp-filter></C:filter>` + + '</C:calendar-query>'; + const response = await request('REPORT', calendar.url, { depth: '1', body, contentType: 'application/xml; charset=utf-8' }); + if (response.status < 200 || response.status >= 300) { + throw new CalError(`REPORT ${calendar.url} answered ${response.status}${response.text ? `: ${response.text.slice(0, 200)}` : ''}`); + } + return eventsFromReport(response.text, calendar); + }, + + async find(calendar, uid) { + const body = + '<?xml version="1.0" encoding="utf-8"?>' + + '<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' + + '<D:prop><D:getetag/><C:calendar-data/></D:prop>' + + '<C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">' + + `<C:prop-filter name="UID"><C:text-match collation="i;octet">${escapeXml(uid)}</C:text-match></C:prop-filter>` + + '</C:comp-filter></C:comp-filter></C:filter></C:calendar-query>'; + const response = await request('REPORT', calendar.url, { depth: '1', body, contentType: 'application/xml; charset=utf-8' }); + if (response.status < 200 || response.status >= 300) return null; + return eventsFromReport(response.text, calendar).find((event) => event.uid === uid) ?? null; + }, + + async put(calendar, ics, uid) { + const href = new URL(`${encodeURIComponent(uid)}.ics`, calendar.url).toString(); + const response = await request('PUT', href, { + body: ics, + contentType: 'text/calendar; charset=utf-8', + headers: { 'If-None-Match': '*' }, + }); + if (response.status < 200 || response.status >= 300) { + throw new CalError(`PUT ${href} answered ${response.status}${response.text ? `: ${response.text.slice(0, 200)}` : ''}`); + } + return href; + }, + + async remove(href, etag) { + const response = await request('DELETE', href, { ...(etag ? { headers: { 'If-Match': etag } } : {}) }); + // Gone already is as good as removed. + if (response.status < 200 || (response.status >= 300 && response.status !== 404)) { + throw new CalError(`DELETE ${href} answered ${response.status}`); + } + }, + }; +} + +/** The events in a multistatus REPORT body, one per VEVENT (expanded occurrences included). */ +export function eventsFromReport(text: string, calendar: Calendar): CalEvent[] { + const events: CalEvent[] = []; + for (const block of xmlBlocks(text, 'response')) { + const href = xmlText(block, 'href'); + const data = xmlText(block, 'calendar-data'); + if (!href || !data) continue; + const etag = xmlText(block, 'getetag'); + for (const event of parseIcs(data)) { + events.push({ ...event, href: new URL(href, calendar.url).toString(), etag, calendar: calendar.name }); + } + } + return events.sort((a, b) => a.start.localeCompare(b.start)); +} + +// --------------------------------------------------------------------------- +// iCalendar +// --------------------------------------------------------------------------- + +interface IcsProperty { + name: string; + params: Record<string, string>; + value: string; +} + +/** Unfold and split an iCalendar text into properties. */ +export function icsProperties(text: string): IcsProperty[] { + const unfolded = text.replace(/\r?\n[ \t]/g, ''); + const properties: IcsProperty[] = []; + for (const line of unfolded.split(/\r?\n/)) { + if (!line.trim()) continue; + // NAME;PARAM=value;PARAM="quoted:value":VALUE — the colon that ends the + // name is the first one outside quotes. + let inQuotes = false; + let colon = -1; + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + if (char === '"') inQuotes = !inQuotes; + else if (char === ':' && !inQuotes) { + colon = index; + break; + } + } + if (colon === -1) continue; + const head = line.slice(0, colon); + const value = line.slice(colon + 1); + const [rawName, ...rawParams] = head.split(';'); + const params: Record<string, string> = {}; + for (const param of rawParams) { + const eq = param.indexOf('='); + if (eq === -1) continue; + params[param.slice(0, eq).toUpperCase()] = param.slice(eq + 1).replace(/^"|"$/g, ''); + } + properties.push({ name: rawName!.toUpperCase(), params, value }); + } + return properties; +} + +function unescapeIcs(value: string): string { + return value.replace(/\\n/gi, '\n').replace(/\\([,;\\])/g, '$1'); +} + +export function escapeIcs(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r?\n/g, '\\n'); +} + +/** The UTC offset of a zone at an instant, in minutes. */ +function zoneOffsetMinutes(instant: Date, zone: string): number { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: zone, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }).formatToParts(instant); + const get = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? '0'); + const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second')); + return Math.round((asUtc - instant.getTime()) / 60_000); +} + +/** The instant a wall-clock time in a zone names, DST folds resolved to the earlier offset. */ +export function zonedToUtc(y: number, mo: number, d: number, h: number, mi: number, s: number, zone: string): Date { + const guess = Date.UTC(y, mo - 1, d, h, mi, s); + const first = guess - zoneOffsetMinutes(new Date(guess), zone) * 60_000; + const second = guess - zoneOffsetMinutes(new Date(first), zone) * 60_000; + return new Date(second); +} + +/** An iCalendar DATE or DATE-TIME as an ISO instant or a `YYYY-MM-DD`. */ +export function parseIcsDate(value: string, params: Record<string, string>): { value: string; allDay: boolean } | null { + const date = /^(\d{4})(\d{2})(\d{2})$/.exec(value); + if (params.VALUE === 'DATE' || (date && !value.includes('T'))) { + if (!date) return null; + return { value: `${date[1]}-${date[2]}-${date[3]}`, allDay: true }; + } + const match = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})?(Z)?$/.exec(value); + if (!match) return null; + const [y, mo, d, h, mi, s] = [1, 2, 3, 4, 5, 6].map((index) => Number(match[index] ?? '0')); + let instant: Date; + if (match[7] === 'Z') instant = new Date(Date.UTC(y!, mo! - 1, d!, h!, mi!, s!)); + else if (params.TZID) { + try { + instant = zonedToUtc(y!, mo!, d!, h!, mi!, s!, params.TZID); + } catch { + instant = new Date(y!, mo! - 1, d!, h!, mi!, s!); + } + } else instant = new Date(y!, mo! - 1, d!, h!, mi!, s!); + return { value: instant.toISOString(), allDay: false }; +} + +function addDays(day: string, count: number): string { + const [y, m, d] = day.split('-').map(Number); + const next = new Date(Date.UTC(y!, m! - 1, d! + count)); + return next.toISOString().slice(0, 10); +} + +/** Every VEVENT in an iCalendar text, with dates normalised. */ +export function parseIcs(text: string): Omit<CalEvent, 'href' | 'etag' | 'calendar'>[] { + const events: Omit<CalEvent, 'href' | 'etag' | 'calendar'>[] = []; + let current: IcsProperty[] | null = null; + for (const property of icsProperties(text)) { + if (property.name === 'BEGIN' && property.value.toUpperCase() === 'VEVENT') { + current = []; + continue; + } + if (property.name === 'END' && property.value.toUpperCase() === 'VEVENT' && current) { + const event = eventFrom(current); + if (event) events.push(event); + current = null; + continue; + } + if (current) current.push(property); + } + return events; +} + +function eventFrom(properties: IcsProperty[]): Omit<CalEvent, 'href' | 'etag' | 'calendar'> | null { + const first = (name: string) => properties.find((property) => property.name === name); + const text = (name: string) => unescapeIcs(first(name)?.value ?? ''); + const dtstart = first('DTSTART'); + if (!dtstart) return null; + const start = parseIcsDate(dtstart.value, dtstart.params); + if (!start) return null; + const dtend = first('DTEND'); + let end = dtend ? parseIcsDate(dtend.value, dtend.params) : null; + if (!end) { + const duration = first('DURATION')?.value; + if (start.allDay) end = { value: addDays(start.value, 1), allDay: true }; + else { + const minutes = duration ? durationMinutes(duration) : 0; + end = { value: new Date(new Date(start.value).getTime() + minutes * 60_000).toISOString(), allDay: false }; + } + } + return { + uid: text('UID') || `${start.value}-${text('SUMMARY')}`, + summary: text('SUMMARY'), + location: text('LOCATION'), + description: text('DESCRIPTION'), + url: text('URL'), + start: start.value, + end: end.value, + allDay: start.allDay, + status: text('STATUS').toUpperCase(), + recurring: Boolean(first('RRULE') || first('RECURRENCE-ID')), + }; +} + +/** An RFC 5545 duration (`PT1H30M`, `P2D`) or a human one (`90m`, `1h30m`, `2d`) in minutes. */ +export function durationMinutes(text: string): number { + const iso = /^-?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/i.exec(text.trim()); + if (iso) { + const [w, d, h, m, s] = [1, 2, 3, 4, 5].map((index) => Number(iso[index] ?? '0')); + return w! * 7 * 24 * 60 + d! * 24 * 60 + h! * 60 + m! + Math.round(s! / 60); + } + const human = /^(?:(\d+)\s*d)?\s*(?:(\d+)\s*h)?\s*(?:(\d+)\s*m(?:in)?)?$/i.exec(text.trim()); + if (!human || !text.trim()) throw new CalError(`"${text}" is not a duration — 30m, 1h, 1h30m, 2d`); + const [d, h, m] = [1, 2, 3].map((index) => Number(human[index] ?? '0')); + return d! * 24 * 60 + h! * 60 + m!; +} + +/** `YYYYMMDDTHHMMSSZ` for a CalDAV time-range or a DTSTAMP. */ +export function icsUtc(date: Date): string { + return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); +} + +/** Fold at 75 octets, as RFC 5545 asks; a client that does not fold is a client servers reject. */ +export function foldIcs(line: string): string { + const bytes = Buffer.from(line, 'utf8'); + if (bytes.length <= 75) return line; + const out: string[] = []; + let index = 0; + let width = 75; + while (index < bytes.length) { + let cut = Math.min(index + width, bytes.length); + // Do not split a multi-byte character. + while (cut < bytes.length && (bytes[cut]! & 0xc0) === 0x80) cut -= 1; + out.push(bytes.subarray(index, cut).toString('utf8')); + index = cut; + width = 74; + } + return out.join('\r\n '); +} + +export interface NewEvent { + uid?: string; + summary: string; + /** ISO instant, or `YYYY-MM-DD` when `allDay`. */ + start: string; + end: string; + allDay: boolean; + location?: string; + description?: string; + url?: string; +} + +/** A VCALENDAR holding one VEVENT, ready to PUT. Times go out in UTC. */ +export function buildIcs(event: NewEvent, now: Date = new Date()): { uid: string; ics: string } { + const uid = event.uid ?? randomUUID(); + const lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//profullstack//cli-tools cal//EN', + 'BEGIN:VEVENT', + `UID:${uid}`, + `DTSTAMP:${icsUtc(now)}`, + ]; + if (event.allDay) { + lines.push(`DTSTART;VALUE=DATE:${event.start.replace(/-/g, '')}`); + lines.push(`DTEND;VALUE=DATE:${event.end.replace(/-/g, '')}`); + } else { + lines.push(`DTSTART:${icsUtc(new Date(event.start))}`); + lines.push(`DTEND:${icsUtc(new Date(event.end))}`); + } + lines.push(`SUMMARY:${escapeIcs(event.summary)}`); + if (event.location) lines.push(`LOCATION:${escapeIcs(event.location)}`); + if (event.description) lines.push(`DESCRIPTION:${escapeIcs(event.description)}`); + if (event.url) lines.push(`URL:${event.url}`); + lines.push('END:VEVENT', 'END:VCALENDAR'); + return { uid, ics: `${lines.map(foldIcs).join('\r\n')}\r\n` }; +} + +// --------------------------------------------------------------------------- +// Times as people type them +// --------------------------------------------------------------------------- + +export interface When { + /** ISO instant, or `YYYY-MM-DD` when `allDay`. */ + value: string; + allDay: boolean; +} + +function localDay(date: Date): string { + const pad = (n: number) => String(n).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; + +/** + * `2026-09-06`, `2026-09-06 14:00`, `2026-09-06T14:00`, `today`, `tomorrow`, + * `friday`, `tomorrow 9:30`, `fri 14:00`, `14:00` (today), `2pm`. + * A bare day is all-day; a time makes it an instant in the local zone. + */ +export function parseWhen(input: string, now: Date = new Date()): When { + const text = input.trim().toLowerCase().replace(/\s+/g, ' '); + if (!text) throw new CalError('empty time'); + let day: string | null = null; + let rest = text; + + const iso = /^(\d{4}-\d{2}-\d{2})(?:[t ](.*))?$/.exec(text); + if (iso) { + day = iso[1]!; + rest = iso[2] ?? ''; + } else { + const [word, ...more] = text.split(' '); + if (word === 'today') day = localDay(now); + else if (word === 'tomorrow') day = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)); + else { + const weekday = WEEKDAYS.findIndex((name) => name === word || name.slice(0, 3) === word); + if (weekday !== -1) { + const ahead = (weekday - now.getDay() + 7) % 7 || 7; + day = localDay(new Date(now.getFullYear(), now.getMonth(), now.getDate() + ahead)); + } + } + rest = day ? more.join(' ') : text; + if (!day) day = localDay(now); + } + + if (!rest) return { value: day, allDay: true }; + const time = /^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/.exec(rest); + if (!time) throw new CalError(`"${input}" is not a time — 2026-09-06 14:00, tomorrow 9:30, friday, 2pm`); + let hour = Number(time[1]); + const minute = Number(time[2] ?? '0'); + if (time[3] === 'pm' && hour < 12) hour += 12; + if (time[3] === 'am' && hour === 12) hour = 0; + if (hour > 23 || minute > 59) throw new CalError(`"${input}" is not a time of day`); + const [y, m, d] = day.split('-').map(Number); + return { value: new Date(y!, m! - 1, d!, hour, minute).toISOString(), allDay: false }; +} + +/** The window a listing covers, from the flags, defaulting to the next 7 days. */ +export function windowFrom( + options: { today?: boolean; tomorrow?: boolean; week?: boolean; days?: number; from?: string; to?: string }, + now: Date = new Date(), +): { from: Date; to: Date; label: string } { + const startOfDay = (date: Date) => new Date(date.getFullYear(), date.getMonth(), date.getDate()); + const dayAfter = (date: Date, count: number) => new Date(date.getFullYear(), date.getMonth(), date.getDate() + count); + if (options.today) return { from: startOfDay(now), to: dayAfter(now, 1), label: 'today' }; + if (options.tomorrow) return { from: dayAfter(now, 1), to: dayAfter(now, 2), label: 'tomorrow' }; + if (options.from || options.to) { + const from = options.from ? new Date(parseWhen(options.from, now).value) : startOfDay(now); + const to = options.to ? new Date(parseWhen(options.to, now).value) : dayAfter(from, 7); + if (to <= from) throw new CalError('--to must come after --from'); + return { from, to, label: `${localDay(from)} to ${localDay(to)}` }; + } + const days = options.week ? 7 : (options.days ?? 7); + return { from: startOfDay(now), to: dayAfter(now, days), label: `the next ${days} day${days === 1 ? '' : 's'}` }; +} + +// --------------------------------------------------------------------------- +// Output +// --------------------------------------------------------------------------- + +function localTime(iso: string): string { + const date = new Date(iso); + const pad = (n: number) => String(n).padStart(2, '0'); + return `${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +function dayLabel(day: string): string { + const [y, m, d] = day.split('-').map(Number); + const date = new Date(y!, m! - 1, d!); + return `${['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][date.getDay()]} ${day}`; +} + +/** The local day an event starts on, for grouping. */ +export function eventDay(event: Pick<CalEvent, 'start' | 'allDay'>): string { + return event.allDay ? event.start : localDay(new Date(event.start)); +} + +/** An agenda: one heading per day, one line per event, times local. */ +export function formatAgenda(events: CalEvent[], options: { showCalendar?: boolean; width?: number } = {}): string { + if (events.length === 0) return '(no events)'; + const width = options.width ?? 100; + const byDay = new Map<string, CalEvent[]>(); + for (const event of events) { + const day = eventDay(event); + (byDay.get(day) ?? byDay.set(day, []).get(day)!).push(event); + } + const lines: string[] = []; + for (const day of [...byDay.keys()].sort()) { + lines.push(dayLabel(day)); + const sorted = byDay.get(day)!.sort((a, b) => Number(b.allDay) - Number(a.allDay) || a.start.localeCompare(b.start)); + for (const event of sorted) { + const when = event.allDay ? 'all day ' : `${localTime(event.start)}–${localTime(event.end)}`; + const tail = [ + options.showCalendar && event.calendar ? `[${event.calendar}]` : '', + event.location ? `@ ${event.location}` : '', + event.status === 'CANCELLED' ? '(cancelled)' : '', + ] + .filter(Boolean) + .join(' '); + const line = ` ${when} ${event.summary || '(untitled)'}${tail ? ` ${tail}` : ''}`; + lines.push(line.length > width ? `${line.slice(0, width - 1)}…` : line); + } + } + return lines.join('\n'); +} + +export function formatEvent(event: CalEvent): string { + const when = event.allDay + ? `${dayLabel(event.start)}${addDays(event.start, 1) === event.end ? '' : ` to ${dayLabel(addDays(event.end, -1))}`} (all day)` + : `${dayLabel(eventDay(event))} ${localTime(event.start)}–${localTime(event.end)}`; + const lines = [`Title: ${event.summary || '(untitled)'}`, `When: ${when}`]; + if (event.location) lines.push(`Where: ${event.location}`); + if (event.calendar) lines.push(`Calendar: ${event.calendar}`); + if (event.status) lines.push(`Status: ${event.status.toLowerCase()}`); + if (event.recurring) lines.push('Repeats: yes (this is one occurrence)'); + if (event.url) lines.push(`Link: ${event.url}`); + lines.push(`Uid: ${event.uid}`); + if (event.description) lines.push('', event.description); + return lines.join('\n'); +} + +export function formatCalendars(calendars: Calendar[]): string { + if (calendars.length === 0) return '(no calendars)'; + const width = Math.max(...calendars.map((calendar) => calendar.name.length)); + return calendars.map((calendar) => `${calendar.name.padEnd(width)} ${calendar.url}`).join('\n'); +} + +export function formatAccounts(accounts: Account[], defaultName: string | undefined): string { + if (accounts.length === 0) return '(no accounts — `cal login <provider> <address>`)'; + const width = Math.max(...accounts.map((account) => account.name.length)); + return accounts + .map((account) => { + const marker = account.name === defaultName ? '*' : ' '; + const password = + account.passwordSource === 'unset' + ? 'no password' + : `password from ${account.passwordSource === 'env' ? passwordVariable(account.name) : 'cal.json'}`; + return `${marker} ${account.name.padEnd(width)} ${account.email} ${account.provider} ${password}`; + }) + .join('\n'); +} diff --git a/src/registry.ts b/src/registry.ts index 72eb3b5..263622c 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -30,6 +30,7 @@ const SUMMARIES: Record<string, string> = { affiliate: 'Work through a list of programs you mean to sign up for', 'ask-web': 'Answer a question from the live web, with its sources', 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', + 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', dl: 'Download a video, or just its audio, through yt-dlp', diff --git a/test/cal.test.ts b/test/cal.test.ts new file mode 100644 index 0000000..c3c5f39 --- /dev/null +++ b/test/cal.test.ts @@ -0,0 +1,555 @@ +import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + type Account, + type CalEvent, + type Calendar, + PROVIDERS, + PROVIDER_NAMES, + accountsFromVault, + buildIcs, + calConfigPath, + durationMinutes, + escapeIcs, + eventDay, + eventsFromReport, + foldIcs, + formatAccounts, + formatAgenda, + formatCalendars, + formatEvent, + formatProviders, + guessProvider, + icsProperties, + icsUtc, + isProviderName, + loadConfig, + loginFailure, + loginHint, + mergeVaultAccounts, + normalizeConfig, + openCalDav, + parseIcs, + parseIcsDate, + parseWhen, + passwordVariable, + providerFor, + resolveAccount, + saveConfig, + selectAccount, + unsupportedProvider, + windowFrom, + xmlBlocks, + xmlHas, + xmlText, + zonedToUtc, +} from '../src/cal.ts'; + +const env = (extra: Record<string, string> = {}): NodeJS.ProcessEnv => ({ ...extra }); + +function account(partial: Partial<Account> = {}): Account { + return { + name: 'work', + email: 'me@example.com', + user: 'me@example.com', + password: 'secret', + passwordSource: 'file', + provider: 'forwardemail', + url: 'https://caldav.example.com', + ...partial, + }; +} + +const calendar: Calendar = { name: 'Work', url: 'https://caldav.example.com/cal/work/', color: null, components: ['VEVENT'] }; + +function event(partial: Partial<CalEvent> = {}): CalEvent { + return { + uid: 'abc', + summary: 'Standup', + location: '', + description: '', + url: '', + start: '2026-09-07T09:00:00.000Z', + end: '2026-09-07T09:30:00.000Z', + allDay: false, + status: '', + recurring: false, + href: 'https://caldav.example.com/cal/work/abc.ics', + etag: '"1"', + calendar: 'Work', + ...partial, + }; +} + +const NOW = new Date(2026, 8, 5, 12, 0, 0); // Saturday 2026-09-05, local + +describe('providers', () => { + it('names every built-in provider with an https URL and a password rule', () => { + expect(PROVIDER_NAMES).toHaveLength(9); + for (const name of PROVIDER_NAMES) { + expect(PROVIDERS[name].url, name).toMatch(/^https:\/\//); + expect(PROVIDERS[name].passwordHint.length, name).toBeGreaterThan(10); + } + }); + + it('infers the provider from a known domain', () => { + expect(guessProvider('a@icloud.com')).toBe('icloud'); + expect(guessProvider('a@ME.com')).toBe('icloud'); + expect(guessProvider('a@fastmail.fm')).toBe('fastmail'); + expect(guessProvider('a@zoho.eu')).toBe('zoho'); + expect(guessProvider('a@yahoo.co.uk')).toBe('yahoo'); + expect(guessProvider('a@posteo.net')).toBe('posteo'); + expect(guessProvider('a@gmail.com')).toBeNull(); + expect(guessProvider('a@example.com')).toBeNull(); + }); + + it('knows the calendars a password cannot reach', () => { + expect(unsupportedProvider('google')?.reason).toContain('OAuth2'); + expect(unsupportedProvider('a@gmail.com')?.name).toBe('google'); + expect(unsupportedProvider('a@hotmail.com')?.name).toBe('outlook'); + expect(unsupportedProvider('a@pm.me')?.reason).toContain('no CalDAV on any plan'); + expect(unsupportedProvider('a@icloud.com')).toBeNull(); + expect(isProviderName('google')).toBe(false); + expect(isProviderName('icloud')).toBe(true); + expect(isProviderName('custom')).toBe(true); + expect(providerFor('custom')).toBeNull(); + }); + + it('says which kind of password before asking, and lists everything', () => { + expect(loginHint(PROVIDERS.icloud)).toContain('app password, not the account password'); + expect(loginHint(PROVIDERS.forwardemail)).toContain('generated per address'); + expect(loginHint(PROVIDERS.posteo)).toContain('takes the account password'); + const text = formatProviders(); + for (const name of PROVIDER_NAMES) expect(text).toContain(` ${name}`); + expect(text).toContain('Nextcloud'); + expect(text).toContain('Google Calendar'); + expect(text).toContain('Proton Calendar'); + }); + + it('names the fix in a login failure', () => { + expect(loginFailure(account({ provider: 'icloud' }), 401, 'Unauthorized')).toContain('use an app password (https://account.apple.com'); + expect(loginFailure(account(), 401, '')).toContain('Forward Email: the alias password'); + expect(loginFailure(account({ provider: 'custom' }), 403, 'Forbidden')).toBe( + 'CalDAV login to https://caldav.example.com as me@example.com failed (403: Forbidden)', + ); + }); +}); + +describe('configuration', () => { + let dir: string; + afterEach(() => { + if (dir) rmSync(dir, { recursive: true, force: true }); + }); + + it('fills the URL from the provider and resolves the password, environment first', () => { + const resolved = resolveAccount('i', { email: 'a@icloud.com', provider: 'icloud', password: 'p' }, env()); + expect(resolved.url).toBe('https://caldav.icloud.com'); + expect(resolved.passwordSource).toBe('file'); + expect(resolveAccount('i', { email: 'a@icloud.com', provider: 'icloud', password: 'p' }, env({ CAL_I_PASSWORD: 'e' })).password).toBe('e'); + expect(resolveAccount('z', { email: 'a@zoho.eu', provider: 'zoho', url: 'https://calendar.zoho.eu/caldav/' }, env()).url).toBe( + 'https://calendar.zoho.eu/caldav/', + ); + expect(() => resolveAccount('c', { email: 'a@x.org', provider: 'custom' }, env())).toThrow(/needs a URL/); + expect(passwordVariable('my-home')).toBe('CAL_MY_HOME_PASSWORD'); + }); + + it('selects by name, by address, by default, or by being the only one', () => { + const config = { + accounts: { + work: { email: 'a@example.com', provider: 'forwardemail' as const }, + home: { email: 'b@icloud.com', provider: 'icloud' as const }, + }, + }; + expect(selectAccount(config, 'home', env()).name).toBe('home'); + expect(selectAccount(config, 'A@EXAMPLE.COM', env()).name).toBe('work'); + expect(selectAccount({ ...config, default: 'home' }, undefined, env()).name).toBe('home'); + expect(selectAccount(config, undefined, env({ CAL_ACCOUNT: 'work' })).name).toBe('work'); + expect(() => selectAccount(config, undefined, env())).toThrow(/which account/); + expect(() => selectAccount(config, 'other', env())).toThrow(/work, home/); + expect(selectAccount({ accounts: { work: config.accounts.work } }, undefined, env()).name).toBe('work'); + expect(() => selectAccount({ accounts: {} }, undefined, env())).toThrow(/cal login/); + }); + + it('round-trips through a 0600 file and drops what is not an account', () => { + dir = mkdtempSync(join(tmpdir(), 'cal-config-')); + const e = env({ XDG_CONFIG_HOME: dir }); + expect(calConfigPath(e)).toBe(join(dir, 'cli-tools', 'cal.json')); + const path = saveConfig({ default: 'work', accounts: { work: { email: 'a@example.com', provider: 'forwardemail', password: 'p' } } }, e); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(loadConfig(e)).toEqual({ default: 'work', accounts: { work: { email: 'a@example.com', provider: 'forwardemail', password: 'p' } } }); + writeFileSync(path, '{nope'); + expect(() => loadConfig(e)).toThrow(/not valid JSON/); + expect(normalizeConfig({ accounts: { x: { email: 'nope' }, i: { email: 'a@icloud.com' }, g: { email: 'a@x.org', provider: 'google' } } })).toEqual({ + accounts: { i: { email: 'a@icloud.com', provider: 'icloud' }, g: { email: 'a@x.org', provider: 'custom' } }, + }); + }); + + it('reads accounts from the vault and merges them over the file', () => { + const vault = accountsFromVault({ + CAL_WORK_EMAIL: 'a@example.com', + CAL_WORK_PROVIDER: 'forwardemail', + CAL_WORK_PASSWORD: 'p', + CAL_HOME_EMAIL: 'b@icloud.com', + CAL_NC_EMAIL: 'c@x.org', + CAL_NC_URL: 'https://x.org/remote.php/dav', + CAL_NC_USER: 'c', + CAL_ORPHAN_PASSWORD: 'nope', + CAL_DEFAULT: 'home', + }); + expect(vault.accounts.work).toEqual({ email: 'a@example.com', provider: 'forwardemail', password: 'p' }); + expect(vault.accounts.home?.provider).toBe('icloud'); + expect(vault.accounts.nc).toEqual({ email: 'c@x.org', provider: 'custom', url: 'https://x.org/remote.php/dav', user: 'c' }); + expect(vault.accounts.orphan).toBeUndefined(); + expect(vault.default).toBe('home'); + const { merged, changed, unchanged } = mergeVaultAccounts( + { accounts: { work: { email: 'a@example.com', provider: 'forwardemail', password: 'p' }, local: { email: 'l@x.org', provider: 'custom', url: 'https://l' } } }, + vault, + ); + expect(changed.sort()).toEqual(['home', 'nc']); + expect(unchanged).toEqual(['work']); + expect(merged.accounts.local).toBeDefined(); + expect(merged.default).toBe('home'); + }); + + it('never prints a password in the accounts listing', () => { + const text = formatAccounts([account(), account({ name: 'home', passwordSource: 'unset', password: null })], 'work'); + expect(text).toContain('* work'); + expect(text).toContain('password from cal.json'); + expect(text).toContain('no password'); + expect(text).not.toContain('secret'); + }); +}); + +describe('xml', () => { + const multistatus = + '<?xml version="1.0"?><d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">' + + '<d:response><d:href>/cal/work/</d:href><d:propstat><d:prop><d:displayname>Work & Play</d:displayname>' + + '<d:resourcetype><d:collection/><cal:calendar/></d:resourcetype>' + + '<cal:supported-calendar-component-set><cal:comp name="VEVENT"/><cal:comp name="VTODO"/></cal:supported-calendar-component-set>' + + '</d:prop></d:propstat></d:response>' + + '<d:response><d:href>/cal/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>' + + '</d:multistatus>'; + + it('reads elements regardless of prefix and decodes entities', () => { + const blocks = xmlBlocks(multistatus, 'response'); + expect(blocks).toHaveLength(2); + expect(xmlText(blocks[0]!, 'href')).toBe('/cal/work/'); + expect(xmlText(blocks[0]!, 'displayname')).toBe('Work & Play'); + expect(xmlHas(xmlText(blocks[0]!, 'resourcetype')!, 'calendar')).toBe(true); + expect(xmlHas(xmlText(blocks[1]!, 'resourcetype')!, 'calendar')).toBe(false); + expect(xmlText('<D:x><![CDATA[a < b]]></D:x>', 'x')).toBe('a < b'); + expect(xmlText('<x>AB</x>', 'x')).toBe('AB'); + }); +}); + +describe('icalendar', () => { + const sample = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'BEGIN:VEVENT', + 'UID:one@example.com', + 'DTSTAMP:20260901T000000Z', + 'DTSTART;TZID=America/New_York:20260907T090000', + 'DTEND;TZID=America/New_York:20260907T093000', + 'SUMMARY:Standup\\, daily', + 'LOCATION:Zoom', + 'DESCRIPTION:line one\\nline two\\; semi', + 'RRULE:FREQ=DAILY', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:two', + 'DTSTART;VALUE=DATE:20260910', + 'SUMMARY:Holiday that is long enough to need folding across the seventy-five oc', + ' tet boundary of the line', + 'END:VEVENT', + 'BEGIN:VEVENT', + 'UID:three', + 'DTSTART:20260911T120000Z', + 'DURATION:PT1H30M', + 'SUMMARY:Lunch', + 'END:VEVENT', + 'END:VCALENDAR', + ].join('\r\n'); + + it('unfolds lines and parses parameters, quoted values included', () => { + const properties = icsProperties('X;A=1;B="c:d":val:ue\r\nY:\r\n z'); + expect(properties[0]).toEqual({ name: 'X', params: { A: '1', B: 'c:d' }, value: 'val:ue' }); + expect(properties[1]).toEqual({ name: 'Y', params: {}, value: 'z' }); + }); + + it('parses events with zoned, all-day and duration-based times', () => { + const events = parseIcs(sample); + expect(events).toHaveLength(3); + const [standup, holiday, lunch] = events; + expect(standup!.summary).toBe('Standup, daily'); + expect(standup!.description).toBe('line one\nline two; semi'); + expect(standup!.location).toBe('Zoom'); + expect(standup!.recurring).toBe(true); + // 09:00 New York on 2026-09-07 (EDT, UTC-4) is 13:00Z. + expect(standup!.start).toBe('2026-09-07T13:00:00.000Z'); + expect(standup!.end).toBe('2026-09-07T13:30:00.000Z'); + expect(holiday!.allDay).toBe(true); + expect(holiday!.start).toBe('2026-09-10'); + expect(holiday!.end).toBe('2026-09-11'); + expect(holiday!.summary).toContain('seventy-five octet boundary'); + expect(lunch!.start).toBe('2026-09-11T12:00:00.000Z'); + expect(lunch!.end).toBe('2026-09-11T13:30:00.000Z'); + }); + + it('converts zoned wall-clock times, DST on both sides', () => { + expect(zonedToUtc(2026, 1, 15, 9, 0, 0, 'America/New_York').toISOString()).toBe('2026-01-15T14:00:00.000Z'); + expect(zonedToUtc(2026, 7, 15, 9, 0, 0, 'America/New_York').toISOString()).toBe('2026-07-15T13:00:00.000Z'); + expect(zonedToUtc(2026, 7, 15, 9, 0, 0, 'Europe/Berlin').toISOString()).toBe('2026-07-15T07:00:00.000Z'); + expect(parseIcsDate('20260907', {})).toEqual({ value: '2026-09-07', allDay: true }); + expect(parseIcsDate('20260907T120000Z', {})).toEqual({ value: '2026-09-07T12:00:00.000Z', allDay: false }); + expect(parseIcsDate('nope', {})).toBeNull(); + }); + + it('reads durations both ways', () => { + expect(durationMinutes('PT1H30M')).toBe(90); + expect(durationMinutes('P2D')).toBe(2880); + expect(durationMinutes('1h30m')).toBe(90); + expect(durationMinutes('45m')).toBe(45); + expect(durationMinutes('2d')).toBe(2880); + expect(() => durationMinutes('soon')).toThrow(/not a duration/); + }); + + it('builds a VEVENT that folds, escapes and stamps in UTC', () => { + const { uid, ics } = buildIcs( + { + uid: 'fixed', + summary: 'Plan; the, thing', + start: '2026-09-07T13:00:00.000Z', + end: '2026-09-07T14:00:00.000Z', + allDay: false, + location: 'Room 1', + description: 'a\nb', + url: 'https://example.com/x', + }, + new Date('2026-09-01T00:00:00Z'), + ); + expect(uid).toBe('fixed'); + expect(ics).toContain('DTSTART:20260907T130000Z\r\n'); + expect(ics).toContain('DTSTAMP:20260901T000000Z\r\n'); + expect(ics).toContain('SUMMARY:Plan\\; the\\, thing\r\n'); + expect(ics).toContain('DESCRIPTION:a\\nb\r\n'); + expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true); + for (const line of ics.split('\r\n')) expect(Buffer.byteLength(line)).toBeLessThanOrEqual(75); + const allDay = buildIcs({ summary: 'Off', start: '2026-09-10', end: '2026-09-11', allDay: true }); + expect(allDay.ics).toContain('DTSTART;VALUE=DATE:20260910\r\nDTEND;VALUE=DATE:20260911\r\n'); + expect(allDay.uid).toMatch(/^[0-9a-f-]{36}$/); + // What was built parses back to the same event. + const [parsed] = parseIcs(ics); + expect(parsed?.summary).toBe('Plan; the, thing'); + expect(parsed?.start).toBe('2026-09-07T13:00:00.000Z'); + }); + + it('folds at 75 octets without splitting a multibyte character', () => { + const line = `SUMMARY:${'é'.repeat(60)}`; + const folded = foldIcs(line); + for (const part of folded.split('\r\n')) expect(Buffer.byteLength(part)).toBeLessThanOrEqual(75); + expect(folded.replace(/\r\n /g, '')).toBe(line); + expect(escapeIcs('a,b;c\\d\ne')).toBe('a\\,b\\;c\\\\d\\ne'); + expect(icsUtc(new Date('2026-09-07T13:00:00.500Z'))).toBe('20260907T130000Z'); + }); +}); + +describe('times as typed', () => { + it('reads dates, days, weekdays and clock times in the local zone', () => { + expect(parseWhen('2026-09-06', NOW)).toEqual({ value: '2026-09-06', allDay: true }); + expect(parseWhen('today', NOW)).toEqual({ value: '2026-09-05', allDay: true }); + expect(parseWhen('tomorrow', NOW)).toEqual({ value: '2026-09-06', allDay: true }); + expect(parseWhen('friday', NOW)).toEqual({ value: '2026-09-11', allDay: true }); + expect(parseWhen('sat', NOW)).toEqual({ value: '2026-09-12', allDay: true }); // next Saturday, never today + const local = (y: number, m: number, d: number, h: number, mi: number) => new Date(y, m - 1, d, h, mi).toISOString(); + expect(parseWhen('2026-09-06 14:00', NOW)).toEqual({ value: local(2026, 9, 6, 14, 0), allDay: false }); + expect(parseWhen('2026-09-06T09:30', NOW)).toEqual({ value: local(2026, 9, 6, 9, 30), allDay: false }); + expect(parseWhen('tomorrow 9:30', NOW)).toEqual({ value: local(2026, 9, 6, 9, 30), allDay: false }); + expect(parseWhen('fri 2pm', NOW)).toEqual({ value: local(2026, 9, 11, 14, 0), allDay: false }); + expect(parseWhen('12am', NOW)).toEqual({ value: local(2026, 9, 5, 0, 0), allDay: false }); + expect(parseWhen('14:00', NOW)).toEqual({ value: local(2026, 9, 5, 14, 0), allDay: false }); + expect(() => parseWhen('whenever', NOW)).toThrow(/not a time/); + expect(() => parseWhen('2026-09-06 25:00', NOW)).toThrow(/not a time of day/); + }); + + it('turns the listing flags into a window', () => { + const today = windowFrom({ today: true }, NOW); + expect(today.from).toEqual(new Date(2026, 8, 5)); + expect(today.to).toEqual(new Date(2026, 8, 6)); + expect(windowFrom({ tomorrow: true }, NOW).label).toBe('tomorrow'); + expect(windowFrom({}, NOW).to).toEqual(new Date(2026, 8, 12)); + expect(windowFrom({ days: 1 }, NOW).label).toBe('the next 1 day'); + const range = windowFrom({ from: '2026-10-01', to: '2026-10-03' }, NOW); + expect(range.from).toEqual(new Date(2026, 9, 1)); + expect(range.label).toBe('2026-10-01 to 2026-10-03'); + expect(() => windowFrom({ from: '2026-10-03', to: '2026-10-01' }, NOW)).toThrow(/after --from/); + }); +}); + +describe('CalDAV client', () => { + type Call = { method: string; url: string; headers: Record<string, string>; body: string | undefined }; + function server(routes: (call: Call) => { status: number; body?: string; headers?: Record<string, string> }) { + const calls: Call[] = []; + const fetcher = async (url: string, init: RequestInit) => { + const call: Call = { + method: init.method ?? 'GET', + url, + headers: init.headers as Record<string, string>, + body: typeof init.body === 'string' ? init.body : undefined, + }; + calls.push(call); + const reply = routes(call); + // A 204 may not carry a body, not even an empty string. + return new Response(reply.body ?? null, { status: reply.status, headers: reply.headers ?? {} }); + }; + return { calls, fetcher }; + } + + const principalXml = + '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:response><d:href>/</d:href><d:propstat><d:prop>' + + '<d:current-user-principal><d:href>/principals/me/</d:href></d:current-user-principal></d:prop></d:propstat></d:response></d:multistatus>'; + const homeXml = + '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:response><d:href>/principals/me/</d:href><d:propstat><d:prop>' + + '<c:calendar-home-set><d:href>/calendars/me/</d:href></c:calendar-home-set></d:prop></d:propstat></d:response></d:multistatus>'; + const calendarsXml = + '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">' + + '<d:response><d:href>/calendars/me/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop></d:propstat></d:response>' + + '<d:response><d:href>/calendars/me/work/</d:href><d:propstat><d:prop><d:displayname>Work</d:displayname>' + + '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><c:supported-calendar-component-set><c:comp name="VEVENT"/></c:supported-calendar-component-set></d:prop></d:propstat></d:response>' + + '<d:response><d:href>/calendars/me/tasks/</d:href><d:propstat><d:prop><d:displayname>Tasks</d:displayname>' + + '<d:resourcetype><d:collection/><c:calendar/></d:resourcetype><c:supported-calendar-component-set><c:comp name="VTODO"/></c:supported-calendar-component-set></d:prop></d:propstat></d:response>' + + '</d:multistatus>'; + const reportXml = + '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:response><d:href>/calendars/me/work/one.ics</d:href>' + + '<d:propstat><d:prop><d:getetag>"e1"</d:getetag><c:calendar-data>BEGIN:VCALENDAR\nBEGIN:VEVENT\nUID:one\nDTSTART:20260907T130000Z\nDTEND:20260907T140000Z\nSUMMARY:Planning & review\nEND:VEVENT\nEND:VCALENDAR</c:calendar-data>' + + '</d:prop></d:propstat></d:response></d:multistatus>'; + + it('discovers principal, home and the VEVENT calendars, with Basic auth on every request', async () => { + const { calls, fetcher } = server((call) => { + if (call.method === 'PROPFIND' && call.url === 'https://caldav.example.com/') return { status: 207, body: principalXml }; + if (call.method === 'PROPFIND' && call.url.endsWith('/principals/me/')) return { status: 207, body: homeXml }; + if (call.method === 'PROPFIND' && call.url.endsWith('/calendars/me/')) return { status: 207, body: calendarsXml }; + return { status: 404 }; + }); + const dav = openCalDav(account({ url: 'https://caldav.example.com/' }), fetcher); + const calendars = await dav.calendars(); + expect(calendars.map((calendar) => calendar.name)).toEqual(['Work']); + expect(calendars[0]!.url).toBe('https://caldav.example.com/calendars/me/work/'); + expect(calls).toHaveLength(3); + for (const call of calls) expect(call.headers.Authorization).toBe(`Basic ${Buffer.from('me@example.com:secret').toString('base64')}`); + expect(calls[0]!.headers.Depth).toBe('0'); + expect(calls[2]!.headers.Depth).toBe('1'); + // The home is remembered; a second listing does not rediscover it. + await dav.calendars(); + expect(calls).toHaveLength(4); + }); + + it('follows a redirect from the discovery root and takes a home-set answered directly', async () => { + const direct = + '<d:multistatus xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav"><d:response><d:href>/dav/</d:href><d:propstat><d:prop>' + + '<c:calendar-home-set><d:href>https://p01.example.com/home/</d:href></c:calendar-home-set></d:prop></d:propstat></d:response></d:multistatus>'; + const { calls, fetcher } = server((call) => { + if (call.url === 'https://caldav.example.com/') return { status: 301, headers: { location: '/dav/' } }; + if (call.url === 'https://caldav.example.com/dav/') return { status: 207, body: direct }; + if (call.url === 'https://p01.example.com/home/') return { status: 207, body: calendarsXml }; + return { status: 404 }; + }); + const calendars = await openCalDav(account({ url: 'https://caldav.example.com/' }), fetcher).calendars(); + expect(calendars[0]!.url).toBe('https://p01.example.com/calendars/me/work/'); + expect(calls.map((call) => call.url)).toEqual(['https://caldav.example.com/', 'https://caldav.example.com/dav/', 'https://p01.example.com/home/']); + }); + + it('asks the server to expand a window and reads the events back', async () => { + const { calls, fetcher } = server((call) => (call.method === 'REPORT' ? { status: 207, body: reportXml } : { status: 404 })); + const dav = openCalDav(account(), fetcher); + const events = await dav.events(calendar, new Date('2026-09-07T00:00:00Z'), new Date('2026-09-14T00:00:00Z')); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + uid: 'one', + summary: 'Planning & review', + etag: '"e1"', + calendar: 'Work', + href: 'https://caldav.example.com/calendars/me/work/one.ics', + }); + expect(calls[0]!.body).toContain('<C:expand start="20260907T000000Z" end="20260914T000000Z"/>'); + expect(calls[0]!.body).toContain('<C:time-range start="20260907T000000Z" end="20260914T000000Z"/>'); + expect(calls[0]!.headers.Depth).toBe('1'); + }); + + it('finds one event by uid, puts a new one without overwriting, and deletes with the etag', async () => { + const { calls, fetcher } = server((call) => { + if (call.method === 'REPORT') return { status: 207, body: reportXml }; + if (call.method === 'PUT') return { status: 201 }; + if (call.method === 'DELETE') return { status: 204 }; + return { status: 404 }; + }); + const dav = openCalDav(account(), fetcher); + expect((await dav.find(calendar, 'one'))?.summary).toBe('Planning & review'); + expect(await dav.find(calendar, 'other')).toBeNull(); + expect(calls[0]!.body).toContain('<C:prop-filter name="UID"><C:text-match collation="i;octet">one</C:text-match>'); + const href = await dav.put(calendar, 'BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n', 'new-uid'); + expect(href).toBe('https://caldav.example.com/cal/work/new-uid.ics'); + const put = calls.find((call) => call.method === 'PUT')!; + expect(put.headers['If-None-Match']).toBe('*'); + expect(put.headers['Content-Type']).toContain('text/calendar'); + await dav.remove(href, '"e1"'); + expect(calls.at(-1)!.headers['If-Match']).toBe('"e1"'); + }); + + it('turns a 401 into the provider-specific fix and refuses a clear-text URL', async () => { + const { fetcher } = server(() => ({ status: 401, body: 'Unauthorized' })); + await expect(openCalDav(account({ provider: 'icloud', url: 'https://caldav.icloud.com' }), fetcher).calendars()).rejects.toThrow(/use an app password/); + expect(() => openCalDav(account({ url: 'http://caldav.example.com' }), fetcher)).toThrow(/in clear/); + expect(() => openCalDav(account({ password: null, passwordSource: 'unset' }), fetcher)).toThrow(/has no password/); + const bad = server(() => ({ status: 207, body: '<d:multistatus xmlns:d="DAV:"></d:multistatus>' })); + await expect(openCalDav(account(), bad.fetcher).calendars()).rejects.toThrow(/did not name a principal/); + }); + + it('parses a report body on its own', () => { + expect(eventsFromReport(reportXml, calendar)[0]?.start).toBe('2026-09-07T13:00:00.000Z'); + expect(eventsFromReport('<d:multistatus xmlns:d="DAV:"/>', calendar)).toEqual([]); + }); +}); + +describe('output', () => { + it('groups the agenda by local day, all-day first, and labels calendars when asked', () => { + const local = (h: number) => new Date(2026, 8, 7, h, 0).toISOString(); + const text = formatAgenda( + [ + event({ uid: 'b', summary: 'Lunch', start: local(12), end: local(13), location: 'Cafe' }), + event({ uid: 'a', summary: 'Standup', start: local(9), end: new Date(2026, 8, 7, 9, 30).toISOString(), calendar: 'Work' }), + event({ uid: 'c', summary: 'Off', start: '2026-09-07', end: '2026-09-08', allDay: true, calendar: 'Home' }), + event({ uid: 'd', summary: 'Later', start: new Date(2026, 8, 8, 10, 0).toISOString(), end: new Date(2026, 8, 8, 11, 0).toISOString(), status: 'CANCELLED' }), + ], + { showCalendar: true }, + ); + const lines = text.split('\n'); + expect(lines[0]).toBe('Mon 2026-09-07'); + expect(lines[1]).toContain('all day'); + expect(lines[1]).toContain('Off [Home]'); + expect(lines[2]).toContain('09:00–09:30 Standup [Work]'); + expect(lines[3]).toContain('12:00–13:00 Lunch'); + expect(lines[3]).toContain('@ Cafe'); + expect(lines[4]).toBe('Tue 2026-09-08'); + expect(lines[5]).toContain('(cancelled)'); + expect(formatAgenda([])).toBe('(no events)'); + expect(eventDay(event({ start: '2026-09-07', allDay: true }))).toBe('2026-09-07'); + }); + + it('prints one event with what it has, and the calendars with their URLs', () => { + const text = formatEvent(event({ location: 'Zoom', description: 'Bring notes', recurring: true, url: 'https://x' })); + expect(text).toContain('Title: Standup'); + expect(text).toContain('Where: Zoom'); + expect(text).toContain('Repeats: yes'); + expect(text).toContain('Link: https://x'); + expect(text).toContain('Uid: abc'); + expect(text.endsWith('Bring notes')).toBe(true); + expect(formatEvent(event({ start: '2026-09-10', end: '2026-09-12', allDay: true }))).toContain('Thu 2026-09-10 to Fri 2026-09-11 (all day)'); + expect(formatCalendars([calendar])).toBe('Work https://caldav.example.com/cal/work/'); + expect(formatCalendars([])).toBe('(no calendars)'); + }); +});