Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Compiled binaries (repo-root only, not nested paths)
/work-cal-sync
/meeting-alerter
/ekprobe

# macOS .app bundles produced by the Makefile
/work-cal-sync.app/
/meeting-alerter.app/

# Go build artifacts
*.exe
*.test
*.out

# Mypy cache
.mypy_cache/

# Editor / OS noise
.DS_Store
135 changes: 135 additions & 0 deletions .kiro/specs/improve-setup/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# Design: Rewrite in Go with Improved Setup

## Overview
A single Go binary with subcommands that replaces the Python+bash setup. The Python implementation stays in the repo as an alternative.

## Project layout

```
work-cal-sync/
├── cmd/work-cal-sync/
│ └── main.go # Entry point: subcommand dispatch
├── internal/
│ ├── eventkit/ # cgo bindings to EventKit.framework
│ │ ├── eventkit.go
│ │ └── eventkit.m
│ └── sync/ # Everything else: config, caldav, wizard, plist, sync logic
│ ├── config.go
│ ├── caldav.go
│ ├── wizard.go
│ ├── plist.go
│ └── sync.go
├── go.mod
├── go.sum
├── Makefile
├── README.md
├── work-cal-sync.py # Preserved Python implementation
├── setup.sh # Preserved Python installer
└── tel.dead.work-cal-sync.plist # Preserved Python plist template
```

Two internal packages: `eventkit` (isolated because of cgo) and `sync` (everything else). If `sync` grows large enough to warrant splitting later, that's easy.

## Key design decisions

### Single binary, subcommands via standard library `flag` or a small library
- For a handful of subcommands, `flag.NewFlagSet` per subcommand is enough
- Avoid pulling in `cobra`/`urfave/cli` unless the surface grows
- Root `main.go` dispatches on `os.Args[1]` to the appropriate handler

### cgo only for EventKit
- EventKit has no Go binding and no REST surface, so cgo is required
- Thin ObjC bridge in `eventkit.m` with extern "C" functions
- Go side defines structs and calls bridge functions
- Keychain uses `github.com/zalando/go-keyring` instead of custom cgo

### CalDAV via go-webdav + go-ical
- `github.com/emersion/go-webdav` for CalDAV protocol
- `github.com/emersion/go-ical` for VCALENDAR/VEVENT generation and parsing
- Custom properties (`X-WORK-CAL-ID`, `X-WORK-CAL-MODIFIED`) attached to VEVENT for tracking

### Config
- Path: `~/.config/work-cal-sync/config.json`
- Permissions: directory `0700`, file `0600`
- Format:
```go
type Config struct {
WorkCalendarID string `json:"work_calendar_id"`
WorkCalendarName string `json:"work_calendar_name"`
CalDAVServer string `json:"caldav_server"`
CalDAVUsername string `json:"caldav_username"`
CalDAVCalendarURL string `json:"caldav_calendar_url"`
DaysBack int `json:"days_back"`
DaysForward int `json:"days_forward"`
}
```
Stores both the calendar ID (stable) and the name (for display).

### HTTPS validation
- A small helper checks the parsed URL: scheme must be `https`, OR host must be `localhost`/`127.0.0.1`/`::1`
- No `--allow-http` flag; loopback exception covers the legitimate case

### launchd plist
- Generated from a Go string template at install time
- Plist label: `tel.dead.work-cal-sync.go` (different from Python version's `tel.dead.work-cal-sync` so they can coexist during migration)
- Path: `~/Library/LaunchAgents/tel.dead.work-cal-sync.go.plist`
- `RunAtLoad=false`, `StartInterval=900`, `KeepAlive=false`
- Log to `~/Library/Logs/work-cal-sync.log`

### Status command
- Reads config (no password)
- Runs `launchctl list tel.dead.work-cal-sync.go` to check job status
- Tails last ~20 lines of the log file

## Subcommand dispatch (sketch)

```go
func main() {
if len(os.Args) < 2 {
runSync()
return
}
switch os.Args[1] {
case "setup": runSetup(os.Args[2:])
case "install": runInstall(os.Args[2:])
case "uninstall": runUninstall(os.Args[2:])
case "status": runStatus(os.Args[2:])
case "-h", "--help", "help":
printUsage()
default:
// Treat unknown args as flags to the sync command
runSync()
}
}
```

## Dependencies

| Dependency | Purpose |
|-----------|---------|
| `github.com/emersion/go-webdav` | CalDAV client |
| `github.com/emersion/go-ical` | iCalendar parsing/generation |
| `github.com/zalando/go-keyring` | macOS Keychain |
| Standard library + cgo | EventKit bridge |

## Build

- `go build ./cmd/work-cal-sync` produces the binary
- `go install github.com/<owner>/work-cal-sync/cmd/work-cal-sync@latest` for users
- Makefile targets: `build`, `install` (local copy to `~/bin/`), `clean`
- Min Go version: 1.22
- Requires Xcode Command Line Tools for cgo

## Coexistence with Python version

- Different launchd label means both can be installed without conflict
- README will warn against running both pointed at the same CalDAV calendar (they'd fight over the same events)
- A user migrating from Python would: stop the Python launchd job, run `work-cal-sync setup`, run `work-cal-sync install`

## Risks and mitigations

| Risk | Mitigation |
|------|-----------|
| EventKit cgo bridge complexity | Keep ObjC layer to data extraction only; all logic in Go |
| `go-webdav` edge cases vs Python `caldav` | Test against Radicale; document supported servers |
| Users accidentally run both implementations | Different plist labels + README warning |
197 changes: 197 additions & 0 deletions .kiro/specs/improve-setup/requirements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# Requirements Document

## Introduction

`work-cal-sync` copies work Exchange/O365 calendar events from macOS Calendar.app to a self-hosted CalDAV server. This document captures the as-built Go rewrite that runs alongside the original Python implementation. The original Python script (`work-cal-sync.py`) and bash installer (`setup.sh`) are preserved in the repository for users who prefer not to install Go, but the Go implementation is the recommended path.

A separate spec (`meeting-alerter`) covers an unrelated tool that shares the EventKit bridge but is otherwise independent. Meeting-alerter is **out of scope** for this document; only the calendar sync tool and its diagnostic helper are described here.

## Glossary

- **CalDAV**: RFC 4791 calendar protocol used to talk to self-hosted servers like Radicale, Nextcloud, and Baikal.
- **EventKit**: macOS framework for reading and writing local calendar data, including calendars synced from Exchange/O365 accounts configured in **System Settings → Internet Accounts**.
- **launchd**: macOS service manager. A LaunchAgent plist runs the sync binary on a 15-minute schedule.
- **TCC**: Apple's Transparency, Consent, and Control privacy subsystem. Calendar access permissions are recorded against a process's **bundle id**, not its binary path, which is why this tool ships as a `.app` bundle rather than a bare binary.
- **`.app` bundle**: macOS application directory layout (`Foo.app/Contents/Info.plist`, `Foo.app/Contents/MacOS/Foo`). Required so TCC honors the Calendar permission grant.
- **Loopback host**: `localhost`, `127.0.0.1`, or `::1`. The wizard allows plain HTTP for these only.
- **Managed event**: A CalDAV event created by this tool, identified by the custom iCalendar properties `X-WORK-CAL-ID` and `X-WORK-CAL-MODIFIED`.

## Architecture (as built)

Go module: `github.com/djdead/work-cal-sync`

```text
cmd/
work-cal-sync/ main binary: sync, setup, install, uninstall, status,
plus hidden bundle-routing subcommands
ekprobe/ diagnostic helper for verifying EventKit access
pkg/
eventkit/ cgo bridge to EventKit.framework + Info.plist used
by the .app bundle
internal/
sync/ config, caldav, ical, keyring, plist, prompt, url,
wizard, diff, managed, sync engine
work-cal-sync.app/ built .app bundle (gitignored, produced by `make app`)
work-cal-sync.py preserved Python implementation
setup.sh preserved Python installer
tel.dead.work-cal-sync.plist preserved Python launchd template
```

The Python implementation continues to work. The Go implementation uses a different launchd label (`tel.dead.work-cal-sync.go`) so both can be installed without conflict during migration, though they should not point at the same CalDAV calendar.

## Requirements

### REQ-1: Single binary with subcommands

- **What**: One Go binary at `cmd/work-cal-sync/` that handles `sync`, `setup`, `install`, `uninstall`, and `status` via subcommands.
- **Why**: Simpler to build, distribute, and document than multiple binaries for a tool of this size.
- **Acceptance criteria**:
- Default invocation (no args) runs sync; this is the path launchd takes.
- `setup`, `install`, `uninstall`, `status` are public subcommands.
- `help`, `-h`, `--help` print usage to stdout and exit 0.
- Unknown args are passed through to the sync command rather than rejected (forward-compat for sync flags).

### REQ-2: Bundle-routing subcommands

- **What**: Hidden subcommands (`request-access`, `list-calendars-internal`, `fetch-events-internal`) exist so the main binary can re-invoke itself inside the `.app` bundle to perform EventKit work, then read results back through a temp file.
- **Why**: macOS attaches Calendar (TCC) permissions to a bundle id, but only honors them when the process is launched via Launch Services (`open -a Foo.app`). A direct shell exec of the binary inside the bundle is attributed to the shell, not the bundle, and silently returns no calendars. Routing through `open -W -n -a … --args …` makes Launch Services attribute the work to the bundle.
- **Acceptance criteria**:
- `request-access` calls `eventkit.RequestAccess` and exits 0/1/2 (granted/error/denied).
- `list-calendars-internal <result-path>` writes the calendar list as JSON.
- `fetch-events-internal <result-path> <calendar-id> <start-unix> <end-unix>` writes events as JSON.
- These subcommands are not advertised in `help` output.
- The main binary's `setup` and default `sync` paths use bundle-routed helpers (`listCalendarsViaBundle`, `fetchEventsViaBundle`) when running outside the bundle.

### REQ-3: `.app` bundle distribution

- **What**: The Go binary is shipped as a code-signed macOS `.app` bundle at `~/Applications/work-cal-sync.app`.
- **Why**: TCC will not grant Calendar access to a bare binary, and a stable code-signing identity prevents TCC from treating each rebuild as a new app and re-prompting.
- **Acceptance criteria**:
- `make app` produces `work-cal-sync.app/` with `Contents/MacOS/work-cal-sync`, `Contents/Info.plist` (with `NSCalendarsFullAccessUsageDescription`), and `Contents/PkgInfo`.
- The bundle is signed with `codesign --identifier tel.dead.work-cal-sync` using a developer identity (defaults to the first `Apple Development:` identity in the keychain; falls back to ad-hoc `-` if none).
- `make install-app` copies the bundle to `~/Applications/work-cal-sync.app`.
- Bundle-routing helpers in the main binary look for the bundle in `~/Applications` first, then `/Applications`, and fail with a clear "run `make install-app`" message if neither exists.

### REQ-4: Sync logic

- **What**: Sync work events to a CalDAV server using rsync-delete semantics.
- **Why**: Keep the CalDAV calendar an exact mirror of the work calendar inside the configured time window.
- **Acceptance criteria**:
- Reads work events via the EventKit bridge (`pkg/eventkit`).
- Writes to CalDAV via `github.com/emersion/go-webdav` and `github.com/emersion/go-ical`.
- Each managed VEVENT carries `X-WORK-CAL-ID` (EventKit external id) and `X-WORK-CAL-MODIFIED` (Unix seconds).
- Diff produces three sets: create (in work, not in CalDAV), update (in both, work modified later), delete (in CalDAV, not in work).
- Sync window is `[now - DaysBack, now + DaysForward]` from config.
- Logs counts and per-event actions via `log/slog`.

### REQ-5: Setup subcommand

- **What**: `work-cal-sync setup` runs the interactive configuration wizard.
- **Why**: Co-locating with the sync binary lets the wizard reuse the same EventKit and CalDAV code paths.
- **Acceptance criteria**:
- Lists Exchange/O365 calendars via the bundle-routed EventKit helper.
- Prompts for CalDAV server URL, username, password (password via terminal echo-off).
- Rejects URLs that aren't `https://` unless the host is loopback (`localhost`, `127.0.0.1`, `::1`).
- Lists calendars from the CalDAV server for selection.
- Prompts for `DaysBack` (default 7) and `DaysForward` (default 90).
- Saves config to `~/.config/work-cal-sync/config.json` with directory `0700` and file `0600`.
- Saves password to macOS Keychain via `github.com/zalando/go-keyring` (service `work-cal-sync`, account = CalDAV username).
- When config already exists, current values are shown as defaults and Enter keeps them.

### REQ-6: Install subcommand

- **What**: `work-cal-sync install` installs the launchd plist and loads the job.
- **Why**: Users shouldn't write plists by hand or remember `launchctl` syntax.
- **Acceptance criteria**:
- Resolves the running binary path with `os.Executable` and `filepath.EvalSymlinks`.
- If no config exists, runs `setup` first.
- Generates the plist from an in-code Go template (no external files); fields: `Label`, `BinaryPath`, `LogPath`.
- Plist label: `tel.dead.work-cal-sync.go` (distinct from Python version).
- Plist location: `~/Library/LaunchAgents/tel.dead.work-cal-sync.go.plist`.
- Plist values: `StartInterval=900`, `RunAtLoad=false`, `KeepAlive=false`, stdout and stderr redirected to `~/Library/Logs/work-cal-sync.log`.
- Atomic write: temp file in same directory, rename over destination.
- Idempotent: unloads any existing job before loading, so re-running is safe.

### REQ-7: Uninstall subcommand

- **What**: `work-cal-sync uninstall` removes the launchd plist and config.
- **Why**: Users shouldn't have to remember multiple manual cleanup commands.
- **Acceptance criteria**:
- Lists what will be removed and asks for y/n confirmation (default `no`).
- Unloads the launchd job (idempotent: succeeds even if not loaded).
- Removes the plist file (idempotent on missing file).
- Removes the entire `~/.config/work-cal-sync/` directory.
- Does **not** remove the binary (user may have installed via `go install`, Homebrew, copy, etc.).
- Does **not** delete the Keychain entry; instead prints the `security delete-generic-password -s work-cal-sync -a <user>` command.
- Failures during removal are logged but don't abort the rest of the uninstall.

### REQ-8: Status subcommand

- **What**: `work-cal-sync status` shows current state.
- **Why**: Quick way to verify configuration and recent activity without opening multiple files.
- **Acceptance criteria**:
- Prints config path and pretty-printed JSON contents (no password — `Config` has no password field).
- Prints launchd state by running `launchctl list <label>`; "not loaded" is reported as state, not as an error.
- Prints the last 20 lines of `~/Library/Logs/work-cal-sync.log`; missing file reports "not found", empty file reports "empty".
- Does not exit non-zero on partial failures; the goal is to dump whatever state can be observed.

### REQ-9: Logging and verbosity

- **What**: All subcommands emit `log/slog` text to stderr with consistent format.
- **Why**: launchd captures stderr to the log file; consistent format makes the log readable.
- **Acceptance criteria**:
- Default level: `Debug` when stderr is a terminal (interactive runs), `Info` otherwise (launchd runs).
- `-v` / `--verbose` forces `Debug`. `-q` / `--quiet` forces `Info`.
- Verbosity flags are accepted by every subcommand and stripped before further parsing.
- TTY detection uses `golang.org/x/term`.

### REQ-10: Keychain integration

- **What**: Store and retrieve the CalDAV password via the macOS Keychain.
- **Why**: Avoid plaintext credentials on disk and avoid hand-written cgo against `Security.framework`.
- **Acceptance criteria**:
- Uses `github.com/zalando/go-keyring`.
- Service: `work-cal-sync`. Account: the CalDAV username from config.
- `KeyringStore.Get`, `Set`, `Delete` wrap the library so call sites are testable.
- Setup writes/updates the password; sync reads it; uninstall does not touch it (REQ-7).

### REQ-11: Build and distribution

- **What**: A Makefile drives the build, bundle, codesign, and install workflow.
- **Why**: A single command (`make install-app`) gets the user from clone to working install.
- **Acceptance criteria**:
- `make build` produces `./work-cal-sync` (a bare binary, useful for `go vet`/tests).
- `make install` copies the binary to `~/bin/work-cal-sync`.
- `make app` produces a code-signed `work-cal-sync.app/` bundle.
- `make install-app` installs the bundle to `~/Applications/`.
- `make clean` removes both binaries and both bundles.
- `CODESIGN_IDENTITY` env/var override is supported, with auto-detection and ad-hoc fallback.
- `go install github.com/djdead/work-cal-sync/cmd/work-cal-sync@latest` works for users who only want the bare binary; they will still need `make install-app` from a clone for full Calendar access.

### REQ-12: Diagnostic helper

- **What**: `cmd/ekprobe` is a small standalone binary that exercises the EventKit bridge.
- **Why**: TCC issues are common during install; a focused tool makes diagnosing access problems quicker than poking at the main binary.
- **Acceptance criteria**:
- Lives at `cmd/ekprobe/` with its own `main.go` and ObjC bridge (`probe.m`).
- Reports authorization status, requests access, and lists Exchange/O365 calendars.
- Documented in `docs/troubleshooting.md`.

### REQ-13: Preserve Python implementation

- **What**: Keep the Python script and bash installer in the repo for users who prefer them.
- **Why**: This is a fork; not every user wants Go. Both implementations should remain functional.
- **Acceptance criteria**:
- `work-cal-sync.py`, `setup.sh`, and `tel.dead.work-cal-sync.plist` remain in the repo root.
- README describes both options and recommends the Go version as the default.
- Different launchd labels (`tel.dead.work-cal-sync` Python, `tel.dead.work-cal-sync.go` Go) so both can be installed simultaneously, with a documented warning not to point both at the same CalDAV calendar.

## Non-requirements (out of scope)

- The `meeting-alerter` tool (`cmd/meeting-alerter/`). Covered by a separate spec.
- Cross-platform support. macOS-only due to EventKit.
- GUI or menu bar app.
- Two-way sync (CalDAV → work).
- Attendee/RSVP data.
- Recurring event exceptions beyond what `go-ical` handles natively.
- Backward-compatible config format with the Python version. The Go version starts fresh and adds `work_calendar_id` for stable identification across renames.
Loading