@cayde-6/icalendar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/assets/hero.png +0 -0
- package/dist/chunk-MSLA47CB.js +500 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +6 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +6 -0
- package/package.json +71 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cayde-6
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
# icalendar
|
|
2
|
+
|
|
3
|
+
[](https://github.com/cayde-6/icalendar/actions/workflows/ci.yml)
|
|
4
|
+
[](https://github.com/cayde-6/icalendar/actions/workflows/release.yml)
|
|
5
|
+
[](https://codecov.io/gh/cayde-6/icalendar)
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
Production-ready TypeScript CLI for **CalDAV calendars**, **iCalendar events**, and **agent-friendly automation**.
|
|
10
|
+
|
|
11
|
+
`icalendar` gives agents and scripts a thin, reliable interface for:
|
|
12
|
+
- listing calendars
|
|
13
|
+
- listing events
|
|
14
|
+
- creating events
|
|
15
|
+
- updating events
|
|
16
|
+
- deleting events
|
|
17
|
+
- sending attendee invites through CalDAV/iCalendar
|
|
18
|
+
- setting a friendly organizer display name (for example `Bender`)
|
|
19
|
+
|
|
20
|
+
It was validated live against **real iCloud CalDAV** with create → update → invite → delete flows.
|
|
21
|
+
|
|
22
|
+
## Why this exists
|
|
23
|
+
|
|
24
|
+
Most CalDAV tooling is either too low-level for agents or too UI-centric for automation. This repo wraps the ugly parts behind a small CLI and a layered codebase that is easy to embed, extend, and reason about.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- ESM TypeScript CLI with clean layering
|
|
29
|
+
- CalDAV access via `tsdav`
|
|
30
|
+
- invite-ready ICS generation
|
|
31
|
+
- attendee support for create/update flows
|
|
32
|
+
- configurable organizer common name via env
|
|
33
|
+
- text and JSON output modes
|
|
34
|
+
- runtime-safe handling of `--help` / `--version`
|
|
35
|
+
- live-tested against iCloud CalDAV
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
### From npm
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
npm install -g @cayde-6/icalendar
|
|
43
|
+
icalendar --help
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### From source
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
git clone https://github.com/cayde-6/icalendar.git
|
|
50
|
+
cd icalendar
|
|
51
|
+
npm install
|
|
52
|
+
npm run build
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### Use as a local CLI
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
npm link
|
|
59
|
+
icalendar --help
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Use without linking
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
node --import tsx src/cli.ts --help
|
|
66
|
+
node dist/cli.js calendars list
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Configuration
|
|
70
|
+
|
|
71
|
+
Copy the example file:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
cp .env.example .env
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Required configuration:
|
|
78
|
+
|
|
79
|
+
```env
|
|
80
|
+
CALDAV_SERVER_URL=https://caldav.icloud.com/
|
|
81
|
+
CALDAV_USERNAME=primary.attendee@example.test
|
|
82
|
+
CALDAV_PASSWORD=app-specific-password
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Optional configuration:
|
|
86
|
+
|
|
87
|
+
```env
|
|
88
|
+
CALDAV_CALENDAR_NAME=OpenClaw Test
|
|
89
|
+
CALDAV_ORGANIZER_NAME=Bender
|
|
90
|
+
CALDAV_RANGE_START=2026-05-07T00:00:00+02:00
|
|
91
|
+
CALDAV_RANGE_END=2026-05-08T00:00:00+02:00
|
|
92
|
+
CALDAV_EXPAND_RECURRING=true
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### iCloud setup: generate an app-specific password
|
|
96
|
+
|
|
97
|
+
For iCloud CalDAV you need an **app-specific password**. Your normal Apple ID password will not work.
|
|
98
|
+
|
|
99
|
+
How to generate it:
|
|
100
|
+
|
|
101
|
+
1. Go to your Apple account settings: <https://account.apple.com/>
|
|
102
|
+
2. Sign in with the Apple account that owns the iCloud calendar
|
|
103
|
+
3. Open the **Sign-In and Security** section
|
|
104
|
+
4. Find **App-Specific Passwords**
|
|
105
|
+
5. Create a new password, for example named `icalendar`
|
|
106
|
+
6. Copy the generated password and put it into:
|
|
107
|
+
|
|
108
|
+
```env
|
|
109
|
+
CALDAV_PASSWORD=your-app-specific-password
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Recommended iCloud values:
|
|
113
|
+
|
|
114
|
+
```env
|
|
115
|
+
CALDAV_SERVER_URL=https://caldav.icloud.com/
|
|
116
|
+
CALDAV_USERNAME=your-apple-id-email@example.com
|
|
117
|
+
CALDAV_PASSWORD=your-app-specific-password
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
If authentication fails, the most common cause is using the normal Apple ID password instead of the generated app-specific one.
|
|
121
|
+
|
|
122
|
+
## Quick examples
|
|
123
|
+
|
|
124
|
+
### List calendars
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
icalendar calendars list
|
|
128
|
+
icalendar calendars list --json
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### List events
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
icalendar events list
|
|
135
|
+
icalendar events list --json
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Create an event
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
icalendar events create \
|
|
142
|
+
--summary "Family sync" \
|
|
143
|
+
--start "2026-05-07T18:00:00+02:00" \
|
|
144
|
+
--end "2026-05-07T18:30:00+02:00" \
|
|
145
|
+
--description "Agenda review" \
|
|
146
|
+
--location "Belgrade" \
|
|
147
|
+
--attendees "primary.attendee@example.test,secondary.attendee@example.test"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Update an event
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
icalendar events update \
|
|
154
|
+
--url "https://caldav.example.com/calendars/personal/event.ics" \
|
|
155
|
+
--summary "Family sync" \
|
|
156
|
+
--start "2026-05-07T18:00:00+02:00" \
|
|
157
|
+
--end "2026-05-07T18:45:00+02:00" \
|
|
158
|
+
--attendees "primary.attendee@example.test,secondary.attendee@example.test"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Delete an event
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
icalendar events delete "https://caldav.example.com/calendars/personal/event.ics"
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Agent integration
|
|
168
|
+
|
|
169
|
+
If another agent/session wants to adopt this CLI, start here:
|
|
170
|
+
- [Agent integration guide](./docs/agent-integration.md)
|
|
171
|
+
- [Architecture](./docs/architecture.md)
|
|
172
|
+
|
|
173
|
+
Short version:
|
|
174
|
+
1. install dependencies
|
|
175
|
+
2. provide `.env`
|
|
176
|
+
3. run `npm run build`
|
|
177
|
+
4. use `icalendar ...` or `node dist/cli.js ...`
|
|
178
|
+
5. prefer `--json` for machine consumers
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npm run check
|
|
184
|
+
npm run build
|
|
185
|
+
npm test
|
|
186
|
+
npm run test:coverage
|
|
187
|
+
npm run verify
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Test strategy
|
|
191
|
+
|
|
192
|
+
Current coverage includes:
|
|
193
|
+
- calendar selection rules
|
|
194
|
+
- time range defaults
|
|
195
|
+
- command parsing for create/update/delete
|
|
196
|
+
- text/json runtime output
|
|
197
|
+
- env config parsing
|
|
198
|
+
- ICS generation and ICS parsing
|
|
199
|
+
- runtime error rendering
|
|
200
|
+
|
|
201
|
+
## Architecture
|
|
202
|
+
|
|
203
|
+
`icalendar` follows the same layered structure as `threads-cli`:
|
|
204
|
+
|
|
205
|
+
```text
|
|
206
|
+
cli -> app(commands/use-cases) -> domain -> infra -> presentation -> shared
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The CalDAV SDK stays in `infra/`, use-cases orchestrate, domain stays provider-agnostic, and renderers only format output.
|
|
210
|
+
|
|
211
|
+
## Production-readiness notes
|
|
212
|
+
|
|
213
|
+
- invite flows were smoke-tested live against iCloud CalDAV
|
|
214
|
+
- `--help` and `--version` do not require env credentials
|
|
215
|
+
- organizer display name is configurable with `CALDAV_ORGANIZER_NAME`
|
|
216
|
+
- attendee invites work in both create and update flows
|
|
217
|
+
- CLI defaults to the first calendar when `CALDAV_CALENDAR_NAME` is omitted
|
|
218
|
+
|
|
219
|
+
## Repo docs
|
|
220
|
+
|
|
221
|
+
- [Agent integration guide](./docs/agent-integration.md)
|
|
222
|
+
- [Architecture](./docs/architecture.md)
|
|
223
|
+
- [Release checklist](./docs/release-checklist.md)
|
|
224
|
+
- [Versioning policy](./docs/versioning.md)
|
|
225
|
+
- [Changelog](./CHANGELOG.md)
|
|
226
|
+
- GitHub Actions: `.github/workflows/ci.yml`, `.github/workflows/release.yml`, `.github/workflows/smoke-icloud.yml`
|
|
227
|
+
|
|
228
|
+
## License
|
|
229
|
+
|
|
230
|
+
MIT
|
|
231
|
+
|
|
232
|
+
## Live smoke workflow
|
|
233
|
+
|
|
234
|
+
A dedicated GitHub Actions workflow is included for optional live CalDAV validation through repository secrets.
|
|
235
|
+
|
|
236
|
+
Required secrets:
|
|
237
|
+
- `CALDAV_SERVER_URL`
|
|
238
|
+
- `CALDAV_USERNAME`
|
|
239
|
+
- `CALDAV_PASSWORD`
|
|
240
|
+
|
|
241
|
+
Optional secrets:
|
|
242
|
+
- `CALDAV_CALENDAR_NAME`
|
|
243
|
+
- `CALDAV_ORGANIZER_NAME`
|
|
244
|
+
|
|
245
|
+
The workflow creates a temporary event, updates it, and deletes it in the end.
|
package/assets/hero.png
ADDED
|
Binary file
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
// src/cli.ts
|
|
2
|
+
import { cac } from "cac";
|
|
3
|
+
|
|
4
|
+
// src/infra/config/env-config.reader.ts
|
|
5
|
+
import "dotenv/config";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
var envSchema = z.object({
|
|
8
|
+
CALDAV_SERVER_URL: z.string().url(),
|
|
9
|
+
CALDAV_USERNAME: z.string().min(1),
|
|
10
|
+
CALDAV_PASSWORD: z.string().min(1),
|
|
11
|
+
CALDAV_CALENDAR_NAME: z.string().min(1).optional(),
|
|
12
|
+
CALDAV_RANGE_START: z.string().datetime({ offset: true }).optional(),
|
|
13
|
+
CALDAV_RANGE_END: z.string().datetime({ offset: true }).optional(),
|
|
14
|
+
CALDAV_EXPAND_RECURRING: z.enum(["true", "false"]).optional().transform((value) => value === "true"),
|
|
15
|
+
CALDAV_ORGANIZER_NAME: z.string().min(1).optional()
|
|
16
|
+
});
|
|
17
|
+
var EnvConfigReader = class {
|
|
18
|
+
readRuntimeConfig() {
|
|
19
|
+
const env = envSchema.parse(process.env);
|
|
20
|
+
return {
|
|
21
|
+
serverUrl: env.CALDAV_SERVER_URL,
|
|
22
|
+
username: env.CALDAV_USERNAME,
|
|
23
|
+
password: env.CALDAV_PASSWORD,
|
|
24
|
+
calendarName: env.CALDAV_CALENDAR_NAME,
|
|
25
|
+
rangeStart: env.CALDAV_RANGE_START,
|
|
26
|
+
rangeEnd: env.CALDAV_RANGE_END,
|
|
27
|
+
expandRecurring: env.CALDAV_EXPAND_RECURRING,
|
|
28
|
+
organizerName: env.CALDAV_ORGANIZER_NAME
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// src/infra/parsing/ics-parser.ts
|
|
34
|
+
var unfoldIcsLines = (ics) => {
|
|
35
|
+
return ics.replace(/\r\n[ \t]/g, "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
36
|
+
};
|
|
37
|
+
var readIcsField = (ics, fieldName) => {
|
|
38
|
+
const lines = unfoldIcsLines(ics);
|
|
39
|
+
const line = lines.find((entry) => entry.startsWith(`${fieldName}:`) || entry.startsWith(`${fieldName};`));
|
|
40
|
+
if (!line) return void 0;
|
|
41
|
+
const separatorIndex = line.indexOf(":");
|
|
42
|
+
return separatorIndex === -1 ? void 0 : line.slice(separatorIndex + 1).trim();
|
|
43
|
+
};
|
|
44
|
+
var parseCalendarEvent = (raw, url) => {
|
|
45
|
+
const ics = typeof raw === "string" ? raw : "";
|
|
46
|
+
return {
|
|
47
|
+
summary: readIcsField(ics, "SUMMARY") ?? "Untitled event",
|
|
48
|
+
start: readIcsField(ics, "DTSTART"),
|
|
49
|
+
end: readIcsField(ics, "DTEND"),
|
|
50
|
+
description: readIcsField(ics, "DESCRIPTION"),
|
|
51
|
+
location: readIcsField(ics, "LOCATION"),
|
|
52
|
+
url
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// src/infra/caldav/calendar-mapper.ts
|
|
57
|
+
var toCalendar = (calendar) => ({
|
|
58
|
+
id: calendar.url,
|
|
59
|
+
displayName: String(calendar.displayName ?? "Untitled"),
|
|
60
|
+
url: calendar.url
|
|
61
|
+
});
|
|
62
|
+
var calendarDisplayName = (calendar) => calendar.displayName || "Untitled";
|
|
63
|
+
|
|
64
|
+
// src/infra/caldav/client.ts
|
|
65
|
+
import tsdav from "tsdav";
|
|
66
|
+
var { createDAVClient } = tsdav;
|
|
67
|
+
var createCaldavClient = async (config) => {
|
|
68
|
+
const client = await createDAVClient({
|
|
69
|
+
serverUrl: config.serverUrl,
|
|
70
|
+
credentials: {
|
|
71
|
+
username: config.username,
|
|
72
|
+
password: config.password
|
|
73
|
+
},
|
|
74
|
+
authMethod: "Basic",
|
|
75
|
+
defaultAccountType: "caldav"
|
|
76
|
+
});
|
|
77
|
+
return client;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// src/infra/caldav/event-object.service.ts
|
|
81
|
+
import crypto2 from "crypto";
|
|
82
|
+
|
|
83
|
+
// src/domain/events/ics.ts
|
|
84
|
+
import crypto from "crypto";
|
|
85
|
+
|
|
86
|
+
// src/shared/errors/cli-error.ts
|
|
87
|
+
var CliError = class extends Error {
|
|
88
|
+
code;
|
|
89
|
+
constructor(code, message) {
|
|
90
|
+
super(message);
|
|
91
|
+
this.name = "CliError";
|
|
92
|
+
this.code = code;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/domain/events/ics.ts
|
|
97
|
+
var escapeText = (value) => value.replace(/\\/g, "\\\\").replace(/\n/g, "\\n").replace(/,/g, "\\,").replace(/;/g, "\\;");
|
|
98
|
+
var toUtcStamp = (value) => {
|
|
99
|
+
const date = new Date(value);
|
|
100
|
+
if (Number.isNaN(date.getTime())) {
|
|
101
|
+
throw new CliError("invalid_datetime", `Invalid datetime: ${value}`);
|
|
102
|
+
}
|
|
103
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
104
|
+
};
|
|
105
|
+
var renderAttendee = (attendee) => {
|
|
106
|
+
const params = ["CUTYPE=INDIVIDUAL", "ROLE=REQ-PARTICIPANT", "PARTSTAT=NEEDS-ACTION", "RSVP=TRUE"];
|
|
107
|
+
if (attendee.commonName) params.unshift(`CN=${escapeText(attendee.commonName)}`);
|
|
108
|
+
return `ATTENDEE;${params.join(";")}:mailto:${attendee.email}`;
|
|
109
|
+
};
|
|
110
|
+
var renderOrganizer = (organizerEmail, organizerCommonName) => {
|
|
111
|
+
if (organizerCommonName) {
|
|
112
|
+
return `ORGANIZER;CN=${escapeText(organizerCommonName)}:mailto:${organizerEmail}`;
|
|
113
|
+
}
|
|
114
|
+
return `ORGANIZER:mailto:${organizerEmail}`;
|
|
115
|
+
};
|
|
116
|
+
var buildEventIcs = (draft, uid = crypto.randomUUID(), organizerEmail, organizerCommonName) => {
|
|
117
|
+
const now = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
118
|
+
const lines = [
|
|
119
|
+
"BEGIN:VCALENDAR",
|
|
120
|
+
"VERSION:2.0",
|
|
121
|
+
"PRODID:-//cayde-6//icalendar//EN",
|
|
122
|
+
draft.attendees?.length ? "METHOD:REQUEST" : "METHOD:PUBLISH",
|
|
123
|
+
"BEGIN:VEVENT",
|
|
124
|
+
`UID:${uid}`,
|
|
125
|
+
`DTSTAMP:${now}`,
|
|
126
|
+
"SEQUENCE:0",
|
|
127
|
+
"STATUS:CONFIRMED",
|
|
128
|
+
`DTSTART:${toUtcStamp(draft.start)}`,
|
|
129
|
+
`DTEND:${toUtcStamp(draft.end)}`,
|
|
130
|
+
`SUMMARY:${escapeText(draft.summary)}`
|
|
131
|
+
];
|
|
132
|
+
if (organizerEmail) lines.push(renderOrganizer(organizerEmail, organizerCommonName));
|
|
133
|
+
if (draft.description) lines.push(`DESCRIPTION:${escapeText(draft.description)}`);
|
|
134
|
+
if (draft.location) lines.push(`LOCATION:${escapeText(draft.location)}`);
|
|
135
|
+
for (const attendee of draft.attendees || []) lines.push(renderAttendee(attendee));
|
|
136
|
+
lines.push("END:VEVENT", "END:VCALENDAR");
|
|
137
|
+
return lines.join("\r\n") + "\r\n";
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
// src/infra/caldav/event-object.service.ts
|
|
141
|
+
var EventObjectService = class {
|
|
142
|
+
constructor(client, options) {
|
|
143
|
+
this.client = client;
|
|
144
|
+
this.options = options;
|
|
145
|
+
}
|
|
146
|
+
client;
|
|
147
|
+
options;
|
|
148
|
+
async create(calendar, draft) {
|
|
149
|
+
const filename = `${crypto2.randomUUID()}.ics`;
|
|
150
|
+
const objectUrl = new URL(filename, calendar.url.endsWith("/") ? calendar.url : `${calendar.url}/`).toString();
|
|
151
|
+
const iCalString = buildEventIcs(draft, void 0, this.options.organizerEmail, this.options.organizerCommonName);
|
|
152
|
+
await this.client.createCalendarObject({
|
|
153
|
+
calendar: { url: calendar.url },
|
|
154
|
+
filename,
|
|
155
|
+
iCalString
|
|
156
|
+
});
|
|
157
|
+
return objectUrl;
|
|
158
|
+
}
|
|
159
|
+
async update(update) {
|
|
160
|
+
const iCalString = buildEventIcs(update, void 0, this.options.organizerEmail, this.options.organizerCommonName);
|
|
161
|
+
await this.client.updateCalendarObject({
|
|
162
|
+
calendarObject: { url: update.url, data: iCalString }
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
async delete(url) {
|
|
166
|
+
await this.client.deleteCalendarObject({ calendarObject: { url } });
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
// src/infra/caldav/tsdav-calendar.gateway.ts
|
|
171
|
+
var TsdavCalendarGateway = class {
|
|
172
|
+
constructor(config) {
|
|
173
|
+
this.config = config;
|
|
174
|
+
this.clientPromise = createCaldavClient(config);
|
|
175
|
+
}
|
|
176
|
+
config;
|
|
177
|
+
clientPromise;
|
|
178
|
+
async listCalendars() {
|
|
179
|
+
const client = await this.clientPromise;
|
|
180
|
+
const calendars = await client.fetchCalendars();
|
|
181
|
+
return calendars.map(toCalendar);
|
|
182
|
+
}
|
|
183
|
+
async listEvents(input) {
|
|
184
|
+
const client = await this.clientPromise;
|
|
185
|
+
const calendars = await client.fetchCalendars();
|
|
186
|
+
const calendar = calendars.find((entry) => entry.url === input.calendar.url);
|
|
187
|
+
if (!calendar) return [];
|
|
188
|
+
const objects = await client.fetchCalendarObjects({
|
|
189
|
+
calendar,
|
|
190
|
+
timeRange: input.timeRange,
|
|
191
|
+
expand: input.expandRecurring
|
|
192
|
+
});
|
|
193
|
+
return objects.map((object) => parseCalendarEvent(object.data, object.url));
|
|
194
|
+
}
|
|
195
|
+
async createEvent(input) {
|
|
196
|
+
const client = await this.clientPromise;
|
|
197
|
+
const service = new EventObjectService(client, {
|
|
198
|
+
organizerEmail: this.config.username,
|
|
199
|
+
organizerCommonName: this.config.organizerName
|
|
200
|
+
});
|
|
201
|
+
const url = await service.create(input.calendar, input.draft);
|
|
202
|
+
return { ok: true, calendarName: calendarDisplayName(input.calendar), url };
|
|
203
|
+
}
|
|
204
|
+
async updateEvent(input) {
|
|
205
|
+
const client = await this.clientPromise;
|
|
206
|
+
const service = new EventObjectService(client, {
|
|
207
|
+
organizerEmail: this.config.username,
|
|
208
|
+
organizerCommonName: this.config.organizerName
|
|
209
|
+
});
|
|
210
|
+
await service.update(input.update);
|
|
211
|
+
return { ok: true, calendarName: calendarDisplayName(input.calendar), url: input.update.url };
|
|
212
|
+
}
|
|
213
|
+
async deleteEvent(input) {
|
|
214
|
+
const client = await this.clientPromise;
|
|
215
|
+
const service = new EventObjectService(client, {
|
|
216
|
+
organizerEmail: this.config.username,
|
|
217
|
+
organizerCommonName: this.config.organizerName
|
|
218
|
+
});
|
|
219
|
+
await service.delete(input.url);
|
|
220
|
+
return { ok: true, calendarName: calendarDisplayName(input.calendar), url: input.url };
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// src/app/use-cases/calendars/list-calendars.ts
|
|
225
|
+
var listCalendars = async (gateway) => {
|
|
226
|
+
const calendars = await gateway.listCalendars();
|
|
227
|
+
return { ok: true, calendars, count: calendars.length };
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
// src/app/commands/calendars/list.command.ts
|
|
231
|
+
var runCalendarsListCommand = async (gateway) => {
|
|
232
|
+
return listCalendars(gateway);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
// src/domain/shared/time-range.ts
|
|
236
|
+
var toCalDavTime = (date) => {
|
|
237
|
+
return date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
|
|
238
|
+
};
|
|
239
|
+
var addDays = (date, days) => {
|
|
240
|
+
const next = new Date(date);
|
|
241
|
+
next.setUTCDate(next.getUTCDate() + days);
|
|
242
|
+
return next;
|
|
243
|
+
};
|
|
244
|
+
var buildTimeRange = (start, end) => {
|
|
245
|
+
if (!start && !end) return void 0;
|
|
246
|
+
const startDate = start ? new Date(start) : /* @__PURE__ */ new Date();
|
|
247
|
+
const endDate = end ? new Date(end) : addDays(startDate, 30);
|
|
248
|
+
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime())) {
|
|
249
|
+
throw new CliError("invalid_time_range", "Invalid CALDAV_RANGE_START/CALDAV_RANGE_END value");
|
|
250
|
+
}
|
|
251
|
+
if (startDate >= endDate) {
|
|
252
|
+
throw new CliError("invalid_time_range", "CALDAV_RANGE_START must be earlier than CALDAV_RANGE_END");
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
start: toCalDavTime(startDate),
|
|
256
|
+
end: toCalDavTime(endDate)
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// src/domain/calendars/calendar.ts
|
|
261
|
+
var pickCalendar = (calendars, calendarName) => {
|
|
262
|
+
if (calendars.length === 0) return void 0;
|
|
263
|
+
if (!calendarName) return calendars[0];
|
|
264
|
+
const normalizedTarget = calendarName.trim().toLowerCase();
|
|
265
|
+
return calendars.find((calendar) => calendar.displayName.trim().toLowerCase() == normalizedTarget);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// src/app/use-cases/events/helpers.ts
|
|
269
|
+
var resolveCalendar = async (calendars, calendarName) => {
|
|
270
|
+
const selectedCalendar = pickCalendar(calendars, calendarName);
|
|
271
|
+
if (!selectedCalendar) {
|
|
272
|
+
if (calendars.length === 0) {
|
|
273
|
+
throw new CliError("calendar_missing", "No calendars found");
|
|
274
|
+
}
|
|
275
|
+
throw new CliError("calendar_not_found", `Calendar not found: ${calendarName}`);
|
|
276
|
+
}
|
|
277
|
+
return selectedCalendar;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// src/app/use-cases/events/list-events.ts
|
|
281
|
+
var listEvents = async (gateway, config) => {
|
|
282
|
+
const calendars = await gateway.listCalendars();
|
|
283
|
+
const selectedCalendar = await resolveCalendar(calendars, config.calendarName);
|
|
284
|
+
const timeRange = buildTimeRange(config.rangeStart, config.rangeEnd);
|
|
285
|
+
const events = await gateway.listEvents({
|
|
286
|
+
calendar: selectedCalendar,
|
|
287
|
+
timeRange,
|
|
288
|
+
expandRecurring: config.expandRecurring
|
|
289
|
+
});
|
|
290
|
+
return { ok: true, calendars, selectedCalendar, events, count: events.length, timeRange };
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// src/app/commands/events/list.command.ts
|
|
294
|
+
var runEventsListCommand = async (gateway, config) => {
|
|
295
|
+
return listEvents(gateway, config);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// src/app/use-cases/events/create-event.ts
|
|
299
|
+
var createEvent = async (gateway, config, draft) => {
|
|
300
|
+
const calendars = await gateway.listCalendars();
|
|
301
|
+
const calendar = await resolveCalendar(calendars, config.calendarName);
|
|
302
|
+
return gateway.createEvent({ calendar, draft });
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// src/app/commands/events/create.command.ts
|
|
306
|
+
var getFlagValue = (args, name) => {
|
|
307
|
+
const index = args.indexOf(name);
|
|
308
|
+
if (index === -1) return void 0;
|
|
309
|
+
return args[index + 1]?.startsWith("--") ? void 0 : args[index + 1];
|
|
310
|
+
};
|
|
311
|
+
var parseAttendees = (value) => {
|
|
312
|
+
if (!value) return void 0;
|
|
313
|
+
const attendees = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
314
|
+
return attendees.length ? attendees.map((email) => ({ email })) : void 0;
|
|
315
|
+
};
|
|
316
|
+
var runEventsCreateCommand = async (gateway, config, args) => {
|
|
317
|
+
const summary = getFlagValue(args, "--summary");
|
|
318
|
+
const start = getFlagValue(args, "--start");
|
|
319
|
+
const end = getFlagValue(args, "--end");
|
|
320
|
+
const description = getFlagValue(args, "--description");
|
|
321
|
+
const location = getFlagValue(args, "--location");
|
|
322
|
+
const attendees = parseAttendees(getFlagValue(args, "--attendees"));
|
|
323
|
+
if (!summary || !start || !end) {
|
|
324
|
+
throw new CliError("missing_flags", "events create requires --summary, --start, and --end");
|
|
325
|
+
}
|
|
326
|
+
return createEvent(gateway, config, { summary, start, end, description, location, attendees });
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// src/app/use-cases/events/update-event.ts
|
|
330
|
+
var updateEvent = async (gateway, config, update) => {
|
|
331
|
+
const calendars = await gateway.listCalendars();
|
|
332
|
+
const calendar = await resolveCalendar(calendars, config.calendarName);
|
|
333
|
+
return gateway.updateEvent({ calendar, update });
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/app/commands/events/update.command.ts
|
|
337
|
+
var getFlagValue2 = (args, name) => {
|
|
338
|
+
const index = args.indexOf(name);
|
|
339
|
+
if (index === -1) return void 0;
|
|
340
|
+
return args[index + 1]?.startsWith("--") ? void 0 : args[index + 1];
|
|
341
|
+
};
|
|
342
|
+
var parseAttendees2 = (value) => {
|
|
343
|
+
if (!value) return void 0;
|
|
344
|
+
const attendees = value.split(",").map((entry) => entry.trim()).filter(Boolean);
|
|
345
|
+
return attendees.length ? attendees.map((email) => ({ email })) : void 0;
|
|
346
|
+
};
|
|
347
|
+
var runEventsUpdateCommand = async (gateway, config, args) => {
|
|
348
|
+
const url = getFlagValue2(args, "--url");
|
|
349
|
+
const summary = getFlagValue2(args, "--summary");
|
|
350
|
+
const start = getFlagValue2(args, "--start");
|
|
351
|
+
const end = getFlagValue2(args, "--end");
|
|
352
|
+
const description = getFlagValue2(args, "--description");
|
|
353
|
+
const location = getFlagValue2(args, "--location");
|
|
354
|
+
const attendees = parseAttendees2(getFlagValue2(args, "--attendees"));
|
|
355
|
+
if (!url || !summary || !start || !end) {
|
|
356
|
+
throw new CliError("missing_flags", "events update requires --url, --summary, --start, and --end");
|
|
357
|
+
}
|
|
358
|
+
return updateEvent(gateway, config, { url, summary, start, end, description, location, attendees });
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
// src/app/use-cases/events/delete-event.ts
|
|
362
|
+
var deleteEvent = async (gateway, config, url) => {
|
|
363
|
+
const calendars = await gateway.listCalendars();
|
|
364
|
+
const calendar = await resolveCalendar(calendars, config.calendarName);
|
|
365
|
+
return gateway.deleteEvent({ calendar, url });
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// src/app/commands/events/delete.command.ts
|
|
369
|
+
var getFlagValue3 = (args, name) => {
|
|
370
|
+
const index = args.indexOf(name);
|
|
371
|
+
if (index === -1) return void 0;
|
|
372
|
+
return args[index + 1]?.startsWith("--") ? void 0 : args[index + 1];
|
|
373
|
+
};
|
|
374
|
+
var runEventsDeleteCommand = async (gateway, config, args) => {
|
|
375
|
+
const url = getFlagValue3(args, "--url") || args[2];
|
|
376
|
+
if (!url) {
|
|
377
|
+
throw new CliError("missing_flags", "events delete requires --url <event-url> or events delete <event-url>");
|
|
378
|
+
}
|
|
379
|
+
return deleteEvent(gateway, config, url);
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// src/presentation/text/renderers.ts
|
|
383
|
+
var renderCalendarsText = (result) => {
|
|
384
|
+
if (result.calendars.length === 0) return "calendars list: 0 item(s)";
|
|
385
|
+
return [
|
|
386
|
+
`calendars list: ${result.count} item(s)`,
|
|
387
|
+
...result.calendars.map((calendar) => `- ${calendar.displayName} (${calendar.url})`)
|
|
388
|
+
].join("\n");
|
|
389
|
+
};
|
|
390
|
+
var renderEventsText = (result) => {
|
|
391
|
+
const lines = [];
|
|
392
|
+
lines.push(`events list: ${result.count} item(s)`);
|
|
393
|
+
if (result.selectedCalendar) {
|
|
394
|
+
const rangeLabel = result.timeRange ? ` | range ${result.timeRange.start} \u2192 ${result.timeRange.end}` : "";
|
|
395
|
+
lines.push(`calendar: ${result.selectedCalendar.displayName}${rangeLabel}`);
|
|
396
|
+
}
|
|
397
|
+
if (result.events.length === 0) {
|
|
398
|
+
lines.push("- No events found.");
|
|
399
|
+
return lines.join("\n");
|
|
400
|
+
}
|
|
401
|
+
for (const event of result.events) {
|
|
402
|
+
lines.push(`- summary: ${event.summary}`);
|
|
403
|
+
lines.push(` start: ${event.start ?? "-"}`);
|
|
404
|
+
lines.push(` end: ${event.end ?? "-"}`);
|
|
405
|
+
lines.push(` location: ${event.location ?? "-"}`);
|
|
406
|
+
lines.push(` url: ${event.url}`);
|
|
407
|
+
}
|
|
408
|
+
return lines.join("\n");
|
|
409
|
+
};
|
|
410
|
+
var renderEventMutationText = (action, result) => {
|
|
411
|
+
return [
|
|
412
|
+
`events ${action}: done`,
|
|
413
|
+
`calendar: ${result.calendarName}`,
|
|
414
|
+
`url: ${result.url}`
|
|
415
|
+
].join("\n");
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// src/presentation/json/renderers.ts
|
|
419
|
+
var renderJson = (value) => JSON.stringify(value, null, 2);
|
|
420
|
+
|
|
421
|
+
// src/app/commands/runtime.ts
|
|
422
|
+
var hasFlag = (args, name) => args.includes(name);
|
|
423
|
+
var print = (args, value, renderText) => {
|
|
424
|
+
if (hasFlag(args, "--json")) {
|
|
425
|
+
console.log(renderJson(value));
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
console.log(renderText(value));
|
|
429
|
+
};
|
|
430
|
+
var printError = (args, error) => {
|
|
431
|
+
if (hasFlag(args, "--json")) {
|
|
432
|
+
const normalized = error instanceof CliError ? { ok: false, error: { code: error.code, message: error.message } } : { ok: false, error: { message: error.message } };
|
|
433
|
+
console.error(renderJson(normalized));
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
console.error(error.message);
|
|
437
|
+
};
|
|
438
|
+
var runCommand = async ({ gateway, config, args }) => {
|
|
439
|
+
if (args.length === 0) {
|
|
440
|
+
const result = await runEventsListCommand(gateway, config);
|
|
441
|
+
print(args, result, renderEventsText);
|
|
442
|
+
return true;
|
|
443
|
+
}
|
|
444
|
+
if (args[0] === "calendars" && args[1] === "list") {
|
|
445
|
+
const result = await runCalendarsListCommand(gateway);
|
|
446
|
+
print(args, result, renderCalendarsText);
|
|
447
|
+
return true;
|
|
448
|
+
}
|
|
449
|
+
if (args[0] === "events" && args[1] === "list") {
|
|
450
|
+
const result = await runEventsListCommand(gateway, config);
|
|
451
|
+
print(args, result, renderEventsText);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
if (args[0] === "events" && args[1] === "create") {
|
|
455
|
+
const result = await runEventsCreateCommand(gateway, config, args);
|
|
456
|
+
print(args, result, (value) => renderEventMutationText("create", value));
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
if (args[0] === "events" && args[1] === "update") {
|
|
460
|
+
const result = await runEventsUpdateCommand(gateway, config, args);
|
|
461
|
+
print(args, result, (value) => renderEventMutationText("update", value));
|
|
462
|
+
return true;
|
|
463
|
+
}
|
|
464
|
+
if (args[0] === "events" && args[1] === "delete") {
|
|
465
|
+
const result = await runEventsDeleteCommand(gateway, config, args);
|
|
466
|
+
print(args, result, (value) => renderEventMutationText("delete", value));
|
|
467
|
+
return true;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// src/cli.ts
|
|
473
|
+
var cli = cac("icalendar");
|
|
474
|
+
cli.command("calendars list", "list available calendars");
|
|
475
|
+
cli.command("events list", "list events for selected calendar/time range");
|
|
476
|
+
cli.command("events create", "create calendar event");
|
|
477
|
+
cli.command("events update", "update calendar event");
|
|
478
|
+
cli.command("events delete [url]", "delete calendar event");
|
|
479
|
+
cli.help();
|
|
480
|
+
cli.version("0.1.0");
|
|
481
|
+
var runCli = async (args = process.argv.slice(2)) => {
|
|
482
|
+
if (args.includes("--help") || args.includes("-h") || args.includes("--version") || args.includes("-v")) {
|
|
483
|
+
cli.parse();
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const configReader = new EnvConfigReader();
|
|
487
|
+
const config = configReader.readRuntimeConfig();
|
|
488
|
+
const gateway = new TsdavCalendarGateway(config);
|
|
489
|
+
if (!await runCommand({ gateway, config, args })) {
|
|
490
|
+
cli.parse();
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
runCli().catch((error) => {
|
|
494
|
+
printError(process.argv.slice(2), error);
|
|
495
|
+
process.exitCode = 1;
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
export {
|
|
499
|
+
runCli
|
|
500
|
+
};
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runCli } from './cli.js';
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cayde-6/icalendar",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Production-ready TypeScript CLI for CalDAV calendars, iCalendar events, and agent automation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"private": false,
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"bin": {
|
|
10
|
+
"icalendar": "./dist/cli.js"
|
|
11
|
+
},
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"import": "./dist/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"LICENSE",
|
|
22
|
+
"assets"
|
|
23
|
+
],
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "bash -lc 'node --import tsx --test $(find test -name \"*.test.ts\" -print)'",
|
|
29
|
+
"test:coverage": "bash -lc 'c8 --reporter=text --reporter=lcov node --import tsx --test $(find test -name \"*.test.ts\" -print)'",
|
|
30
|
+
"build": "tsup src/cli.ts src/index.ts --format esm --dts --clean",
|
|
31
|
+
"dev": "tsx src/cli.ts",
|
|
32
|
+
"check": "tsc --noEmit -p tsconfig.json",
|
|
33
|
+
"verify": "npm run check && npm run build && npm run test:coverage",
|
|
34
|
+
"start": "node dist/cli.js"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/cayde-6/icalendar.git"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"caldav",
|
|
42
|
+
"icalendar",
|
|
43
|
+
"ics",
|
|
44
|
+
"cli",
|
|
45
|
+
"automation",
|
|
46
|
+
"agents",
|
|
47
|
+
"icloud"
|
|
48
|
+
],
|
|
49
|
+
"author": "cayde-6",
|
|
50
|
+
"license": "MIT",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/cayde-6/icalendar/issues"
|
|
53
|
+
},
|
|
54
|
+
"homepage": "https://github.com/cayde-6/icalendar#readme",
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"cac": "^6.7.14",
|
|
57
|
+
"dotenv": "^17.4.2",
|
|
58
|
+
"tsdav": "^2.2.0",
|
|
59
|
+
"zod": "^4.4.1"
|
|
60
|
+
},
|
|
61
|
+
"devDependencies": {
|
|
62
|
+
"@types/node": "^25.6.0",
|
|
63
|
+
"c8": "^11.0.0",
|
|
64
|
+
"tsup": "^8.5.0",
|
|
65
|
+
"tsx": "^4.21.0",
|
|
66
|
+
"typescript": "^6.0.3"
|
|
67
|
+
},
|
|
68
|
+
"publishConfig": {
|
|
69
|
+
"access": "public"
|
|
70
|
+
}
|
|
71
|
+
}
|