@pellux/goodvibes-tui 0.24.1 → 0.25.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/daemon/calendar/caldav-client.ts +657 -0
  5. package/src/daemon/calendar/ics.ts +556 -0
  6. package/src/daemon/calendar/index.ts +52 -0
  7. package/src/daemon/calendar/register.ts +527 -0
  8. package/src/daemon/channels/drafts/draft-store.ts +363 -0
  9. package/src/daemon/channels/drafts/index.ts +22 -0
  10. package/src/daemon/channels/drafts/register.ts +449 -0
  11. package/src/daemon/channels/inbox/cursor-store.ts +298 -0
  12. package/src/daemon/channels/inbox/index.ts +58 -0
  13. package/src/daemon/channels/inbox/mapping.ts +190 -0
  14. package/src/daemon/channels/inbox/poller.ts +155 -0
  15. package/src/daemon/channels/inbox/provider-adapter.ts +152 -0
  16. package/src/daemon/channels/inbox/providers/discord.ts +253 -0
  17. package/src/daemon/channels/inbox/providers/email.ts +151 -0
  18. package/src/daemon/channels/inbox/providers/imap-client.ts +300 -0
  19. package/src/daemon/channels/inbox/providers/route-util.ts +23 -0
  20. package/src/daemon/channels/inbox/providers/slack.ts +264 -0
  21. package/src/daemon/channels/inbox/register.ts +247 -0
  22. package/src/daemon/channels/routing/inbox-bridge.ts +58 -0
  23. package/src/daemon/channels/routing/index.ts +39 -0
  24. package/src/daemon/channels/routing/register.ts +296 -0
  25. package/src/daemon/channels/routing/route-store.ts +278 -0
  26. package/src/daemon/channels/routing/routing-resolver.ts +75 -0
  27. package/src/daemon/email/imap-connector.ts +441 -0
  28. package/src/daemon/email/imap-parsing.ts +499 -0
  29. package/src/daemon/email/index.ts +68 -0
  30. package/src/daemon/email/register.ts +715 -0
  31. package/src/daemon/email/smtp-connector.ts +557 -0
  32. package/src/daemon/operator/credential-store.ts +129 -0
  33. package/src/daemon/operator/index.ts +43 -0
  34. package/src/daemon/operator/register-helper.ts +150 -0
  35. package/src/daemon/operator/sqlite-store.ts +124 -0
  36. package/src/daemon/operator/surfaces.ts +137 -0
  37. package/src/daemon/operator/types.ts +207 -0
  38. package/src/daemon/remote/backends/cloud-terminal.ts +137 -0
  39. package/src/daemon/remote/backends/docker.ts +80 -0
  40. package/src/daemon/remote/backends/index.ts +34 -0
  41. package/src/daemon/remote/backends/local-process.ts +113 -0
  42. package/src/daemon/remote/backends/process-runner.ts +151 -0
  43. package/src/daemon/remote/backends/ssh.ts +120 -0
  44. package/src/daemon/remote/backends/types.ts +71 -0
  45. package/src/daemon/remote/dispatcher.ts +160 -0
  46. package/src/daemon/remote/index.ts +74 -0
  47. package/src/daemon/remote/peer-registry.ts +321 -0
  48. package/src/daemon/remote/register.ts +411 -0
  49. package/src/daemon/triage/index.ts +59 -0
  50. package/src/daemon/triage/integration.ts +179 -0
  51. package/src/daemon/triage/pipeline.ts +285 -0
  52. package/src/daemon/triage/register.ts +231 -0
  53. package/src/daemon/triage/scorer.ts +287 -0
  54. package/src/daemon/triage/tagger.ts +777 -0
  55. package/src/runtime/services.ts +35 -0
  56. package/src/version.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,12 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [0.25.0] — 2026-06-21
8
+
9
+ ### Changes
10
+ - a8a4cd4f fix(daemon/remote): make runProcess timeout resolve promptly under orphaned children
11
+ - c00461be feat(daemon): implement control-plane operator surfaces for channels, email, calendar, and remote execution
12
+
7
13
  ## [0.24.1] — 2026-06-17
8
14
 
9
15
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-0.24.1-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-0.25.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
6
6
 
7
7
  A terminal-native AI coding, operations, automation, knowledge, and integration console with a typed runtime, omnichannel surfaces, structured memory/knowledge, and a raw ANSI renderer.
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "0.24.1",
3
+ "version": "0.25.0",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -0,0 +1,657 @@
1
+ // CalDAV client for the calendar operator surface.
2
+ //
3
+ // Authenticates to the user's CalDAV endpoint using credentials resolved from
4
+ // the daemon credential store (config keys under `surfaces.calendar.*`). It
5
+ // implements the CalDAV/WebDAV verbs the operator methods need:
6
+ // - REPORT (calendar-query) -> list / get events
7
+ // - PUT -> create / import events
8
+ // - REPORT (multiget) / GET -> export a collection
9
+ // - PROPFIND -> discover calendar collections
10
+ //
11
+ // SECURITY POSTURE:
12
+ // * The authenticated CalDAV base URL and credentials NEVER leave this module.
13
+ // * `calendarId` is a LOGICAL identifier. It is mapped internally to a
14
+ // collection path; the authenticated absolute URL is never returned to or
15
+ // accepted from callers.
16
+ // * Event hrefs are returned to callers as opaque relative identifiers, never
17
+ // as fully-qualified authenticated URLs.
18
+
19
+ import type { OperatorContext } from '../operator/types.ts';
20
+ import { OperatorError } from '../operator/types.ts';
21
+ import { createDaemonCredentialStore } from '../operator/credential-store.ts';
22
+ import {
23
+ generateCalendar,
24
+ generateICS,
25
+ parseICS,
26
+ type GenerateICalInput,
27
+ type ParsedICalEvent,
28
+ } from './ics.ts';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Configuration
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const CFG_URL = 'surfaces.calendar.caldavUrl';
35
+ const CFG_USER = 'surfaces.calendar.caldavUser';
36
+ const CFG_PASSWORD = 'surfaces.calendar.caldavPassword';
37
+ const CFG_DEFAULT_CALENDAR = 'surfaces.calendar.defaultCalendarId';
38
+ const CFG_CALENDARS = 'surfaces.calendar.calendars';
39
+
40
+ export interface CalDavConfig {
41
+ baseUrl: string;
42
+ username: string;
43
+ password: string;
44
+ defaultCalendarId: string;
45
+ /** logical calendarId -> collection path (relative to baseUrl host). */
46
+ collectionMap: Record<string, string>;
47
+ }
48
+
49
+ export interface CalDavEvent extends ParsedICalEvent {
50
+ /** Opaque, host-relative href identifying the resource (never absolute/authenticated). */
51
+ href: string;
52
+ /** Logical calendar id this event belongs to. */
53
+ calendarId: string;
54
+ }
55
+
56
+ export interface ListEventsOptions {
57
+ calendarId?: string;
58
+ from?: string;
59
+ to?: string;
60
+ limit?: number;
61
+ }
62
+
63
+ export interface CreateEventInput {
64
+ title: string;
65
+ start: string;
66
+ end: string;
67
+ description?: string;
68
+ attendees?: string[];
69
+ location?: string;
70
+ calendarId?: string;
71
+ }
72
+
73
+ export interface CreatedEvent {
74
+ eventId: string; // opaque href or UID
75
+ uid: string;
76
+ createdAt: string;
77
+ }
78
+
79
+ export interface ImportResult {
80
+ imported: number;
81
+ eventIds: string[];
82
+ errors: string[];
83
+ }
84
+
85
+ export interface ExportResult {
86
+ icsContent: string;
87
+ eventCount: number;
88
+ }
89
+
90
+ /** Minimal fetch contract so the client is testable with an injected fetch. */
91
+ export type FetchLike = (
92
+ input: string,
93
+ init?: {
94
+ method?: string;
95
+ headers?: Record<string, string>;
96
+ body?: string;
97
+ },
98
+ ) => Promise<{
99
+ ok: boolean;
100
+ status: number;
101
+ statusText: string;
102
+ headers: { get(name: string): string | null };
103
+ text(): Promise<string>;
104
+ }>;
105
+
106
+ export interface CalDavClient {
107
+ listCalendars(): Promise<Array<{ calendarId: string; displayName: string }>>;
108
+ listEvents(opts: ListEventsOptions): Promise<CalDavEvent[]>;
109
+ getEvent(eventId: string, calendarId?: string): Promise<CalDavEvent | null>;
110
+ createEvent(input: CreateEventInput): Promise<CreatedEvent>;
111
+ importIcs(icsContent: string, calendarId?: string): Promise<ImportResult>;
112
+ exportIcs(opts: { calendarId?: string; from?: string; to?: string }): Promise<ExportResult>;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Config resolution
117
+ // ---------------------------------------------------------------------------
118
+
119
+ function readConfigString(
120
+ ctx: Pick<OperatorContext, 'configManager'>,
121
+ key: string,
122
+ ): string | undefined {
123
+ const value = ctx.configManager.get(key as never);
124
+ if (value === undefined || value === null) return undefined;
125
+ const text = String(value).trim();
126
+ return text.length > 0 ? text : undefined;
127
+ }
128
+
129
+ function parseCollectionMap(raw: string | undefined): Record<string, string> {
130
+ if (!raw) return {};
131
+ try {
132
+ const parsed = JSON.parse(raw) as unknown;
133
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
134
+ const map: Record<string, string> = {};
135
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
136
+ if (typeof v === 'string' && v.length > 0) map[k] = v;
137
+ }
138
+ return map;
139
+ }
140
+ } catch {
141
+ // Ignore malformed config; fall back to default-calendar-only behaviour.
142
+ }
143
+ return {};
144
+ }
145
+
146
+ /**
147
+ * Resolve the full CalDAV configuration from config + credential store.
148
+ * The password is resolved from the daemon credential store — either as a
149
+ * goodvibes://secrets/ reference stored in config, or the config-key-derived
150
+ * secret. Throws OperatorError when the surface is not configured.
151
+ */
152
+ export async function resolveCalDavConfig(
153
+ ctx: Pick<OperatorContext, 'configManager' | 'secrets'>,
154
+ ): Promise<CalDavConfig> {
155
+ const credentials = createDaemonCredentialStore(ctx.secrets);
156
+ const baseUrl = readConfigString(ctx, CFG_URL);
157
+ const username = readConfigString(ctx, CFG_USER);
158
+ if (!baseUrl || !username) {
159
+ throw new OperatorError(
160
+ 'CalDAV is not configured. Set surfaces.calendar.caldavUrl and surfaces.calendar.caldavUser.',
161
+ 'CALENDAR_NOT_CONFIGURED',
162
+ 412,
163
+ );
164
+ }
165
+
166
+ const passwordConfig = readConfigString(ctx, CFG_PASSWORD);
167
+ let password: string | null = null;
168
+ if (passwordConfig) {
169
+ // Config may hold a goodvibes://secrets/ ref or a raw secret key.
170
+ password = await credentials.resolveRef(passwordConfig);
171
+ }
172
+ if (!password) {
173
+ // Fall back to the config-key-derived secret.
174
+ password = await credentials.resolveConfigSecret(CFG_PASSWORD);
175
+ }
176
+ if (!password) {
177
+ throw new OperatorError(
178
+ 'CalDAV password is not available in the credential store.',
179
+ 'CALENDAR_CREDENTIALS_MISSING',
180
+ 412,
181
+ );
182
+ }
183
+
184
+ const defaultCalendarId = readConfigString(ctx, CFG_DEFAULT_CALENDAR) ?? 'default';
185
+ const collectionMap = parseCollectionMap(readConfigString(ctx, CFG_CALENDARS));
186
+
187
+ return { baseUrl, username, password, defaultCalendarId, collectionMap };
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // URL helpers (logical id <-> collection path; never expose absolute URLs)
192
+ // ---------------------------------------------------------------------------
193
+
194
+ function stripTrailingSlash(value: string): string {
195
+ return value.endsWith('/') ? value.slice(0, -1) : value;
196
+ }
197
+
198
+ function joinUrl(base: string, segment: string): string {
199
+ const cleanBase = stripTrailingSlash(base);
200
+ const cleanSegment = segment.startsWith('/') ? segment : `/${segment}`;
201
+ return `${cleanBase}${cleanSegment}`;
202
+ }
203
+
204
+ /** Extract the scheme+host origin from an absolute URL (no trailing slash). */
205
+ export function originOf(url: string): string {
206
+ const match = /^(https?:\/\/[^/]+)/i.exec(url.trim());
207
+ return match ? match[1] : stripTrailingSlash(url);
208
+ }
209
+
210
+ /**
211
+ * Resolve a configured collection path. A host-absolute path (starting with
212
+ * '/') is resolved against the origin of the base URL; any other value is
213
+ * treated as a child segment relative to the base URL.
214
+ */
215
+ function resolveCollectionUrl(baseUrl: string, path: string): string {
216
+ if (path.startsWith('/')) {
217
+ return `${originOf(baseUrl)}${stripTrailingSlash(path)}`;
218
+ }
219
+ return joinUrl(baseUrl, stripTrailingSlash(path));
220
+ }
221
+
222
+ /**
223
+ * Convert an absolute or host-relative href returned by the server into an
224
+ * opaque, host-relative identifier. Strips scheme + host so authenticated URLs
225
+ * never leak to callers.
226
+ */
227
+ export function toRelativeHref(href: string): string {
228
+ const trimmed = href.trim();
229
+ const schemeMatch = /^https?:\/\/[^/]+(\/.*)$/i.exec(trimmed);
230
+ if (schemeMatch) return schemeMatch[1];
231
+ return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
232
+ }
233
+
234
+ // ---------------------------------------------------------------------------
235
+ // REPORT / PROPFIND body builders
236
+ // ---------------------------------------------------------------------------
237
+
238
+ function calendarQueryBody(from?: string, to?: string): string {
239
+ const timeFilter = from || to
240
+ ? `<C:time-range${from ? ` start="${toCalDavStamp(from)}"` : ''}${to ? ` end="${toCalDavStamp(to)}"` : ''}/>`
241
+ : '';
242
+ return [
243
+ '<?xml version="1.0" encoding="utf-8" ?>',
244
+ '<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
245
+ ' <D:prop>',
246
+ ' <D:getetag/>',
247
+ ' <C:calendar-data/>',
248
+ ' </D:prop>',
249
+ ' <C:filter>',
250
+ ' <C:comp-filter name="VCALENDAR">',
251
+ ` <C:comp-filter name="VEVENT">${timeFilter}</C:comp-filter>`,
252
+ ' </C:comp-filter>',
253
+ ' </C:filter>',
254
+ '</C:calendar-query>',
255
+ ].join('\n');
256
+ }
257
+
258
+ /**
259
+ * Build a calendar-query REPORT body that matches a single VEVENT by UID using
260
+ * a server-side text-match prop-filter. This lets the server return only the
261
+ * one matching resource instead of the entire collection, so a UID lookup is
262
+ * O(1) on the wire and cannot silently miss an event truncated from a large
263
+ * unfiltered listing.
264
+ */
265
+ function calendarQueryByUidBody(uid: string): string {
266
+ // Escape XML metacharacters in the UID before embedding it in the filter.
267
+ const safeUid = uid
268
+ .replace(/&/g, '&amp;')
269
+ .replace(/</g, '&lt;')
270
+ .replace(/>/g, '&gt;')
271
+ .replace(/"/g, '&quot;');
272
+ return [
273
+ '<?xml version="1.0" encoding="utf-8" ?>',
274
+ '<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
275
+ ' <D:prop>',
276
+ ' <D:getetag/>',
277
+ ' <C:calendar-data/>',
278
+ ' </D:prop>',
279
+ ' <C:filter>',
280
+ ' <C:comp-filter name="VCALENDAR">',
281
+ ' <C:comp-filter name="VEVENT">',
282
+ ` <C:prop-filter name="UID"><C:text-match collation="i;octet">${safeUid}</C:text-match></C:prop-filter>`,
283
+ ' </C:comp-filter>',
284
+ ' </C:comp-filter>',
285
+ ' </C:filter>',
286
+ '</C:calendar-query>',
287
+ ].join('\n');
288
+ }
289
+
290
+ function propfindCalendarsBody(): string {
291
+ return [
292
+ '<?xml version="1.0" encoding="utf-8" ?>',
293
+ '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">',
294
+ ' <D:prop>',
295
+ ' <D:displayname/>',
296
+ ' <D:resourcetype/>',
297
+ ' </D:prop>',
298
+ '</D:propfind>',
299
+ ].join('\n');
300
+ }
301
+
302
+ function toCalDavStamp(iso: string): string {
303
+ const date = new Date(iso);
304
+ if (Number.isNaN(date.getTime())) {
305
+ throw new OperatorError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
306
+ }
307
+ const pad = (n: number): string => (n < 10 ? `0${n}` : String(n));
308
+ return (
309
+ `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}`
310
+ + `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
311
+ );
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Multistatus (207) response parsing
316
+ // ---------------------------------------------------------------------------
317
+
318
+ interface MultiStatusEntry {
319
+ href: string;
320
+ calendarData?: string;
321
+ displayName?: string;
322
+ isCalendar: boolean;
323
+ }
324
+
325
+ function decodeXmlEntities(value: string): string {
326
+ return value
327
+ .replace(/&lt;/g, '<')
328
+ .replace(/&gt;/g, '>')
329
+ .replace(/&quot;/g, '"')
330
+ .replace(/&#39;/g, "'")
331
+ .replace(/&apos;/g, "'")
332
+ .replace(/&amp;/g, '&');
333
+ }
334
+
335
+ function tagContent(block: string, localName: string): string | undefined {
336
+ // Match <ns:local ...>content</ns:local> or <local>content</local>.
337
+ const re = new RegExp(
338
+ `<(?:[a-zA-Z0-9]+:)?${localName}(?:\\s[^>]*)?>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${localName}>`,
339
+ 'i',
340
+ );
341
+ const match = re.exec(block);
342
+ return match ? match[1] : undefined;
343
+ }
344
+
345
+ function hasTag(block: string, localName: string): boolean {
346
+ const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${localName}(?:\\s[^>]*)?\\/?>`, 'i');
347
+ return re.test(block);
348
+ }
349
+
350
+ /** Parse a WebDAV 207 multistatus document into per-resource entries. */
351
+ export function parseMultiStatus(xml: string): MultiStatusEntry[] {
352
+ const entries: MultiStatusEntry[] = [];
353
+ const responseRe = /<(?:[a-zA-Z0-9]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/gi;
354
+ let match: RegExpExecArray | null;
355
+ while ((match = responseRe.exec(xml)) !== null) {
356
+ const block = match[1];
357
+ const hrefRaw = tagContent(block, 'href');
358
+ if (!hrefRaw) continue;
359
+ const href = decodeXmlEntities(hrefRaw.trim());
360
+ const calendarDataRaw = tagContent(block, 'calendar-data');
361
+ const displayNameRaw = tagContent(block, 'displayname');
362
+ const isCalendar = hasTag(block, 'calendar');
363
+ entries.push({
364
+ href,
365
+ calendarData: calendarDataRaw ? decodeXmlEntities(calendarDataRaw) : undefined,
366
+ displayName: displayNameRaw ? decodeXmlEntities(displayNameRaw.trim()) : undefined,
367
+ isCalendar,
368
+ });
369
+ }
370
+ return entries;
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Client implementation
375
+ // ---------------------------------------------------------------------------
376
+
377
+ export interface CreateCalDavClientOptions {
378
+ config: CalDavConfig;
379
+ fetchImpl?: FetchLike;
380
+ }
381
+
382
+ const defaultFetch: FetchLike = (input, init) =>
383
+ fetch(input, init as RequestInit) as unknown as ReturnType<FetchLike>;
384
+
385
+ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavClient {
386
+ const { config } = options;
387
+ const doFetch = options.fetchImpl ?? defaultFetch;
388
+ const authHeader = `Basic ${Buffer.from(`${config.username}:${config.password}`).toString('base64')}`;
389
+
390
+ const collectionPathFor = (calendarId: string | undefined): string => {
391
+ const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
392
+ const mapped = config.collectionMap[id];
393
+ if (mapped) return mapped;
394
+ if (id === config.defaultCalendarId) return ''; // base URL is the default collection
395
+ // Unknown logical id with no mapping: treat the id as a child collection name.
396
+ return `/${encodeURIComponent(id)}/`;
397
+ };
398
+
399
+ const collectionUrlFor = (calendarId: string | undefined): string => {
400
+ const path = collectionPathFor(calendarId);
401
+ return path.length > 0 ? resolveCollectionUrl(config.baseUrl, path) : stripTrailingSlash(config.baseUrl);
402
+ };
403
+
404
+ const request = async (
405
+ url: string,
406
+ method: string,
407
+ body?: string,
408
+ extraHeaders?: Record<string, string>,
409
+ ): Promise<{ status: number; text: string; headers: { get(n: string): string | null } }> => {
410
+ let response;
411
+ try {
412
+ response = await doFetch(url, {
413
+ method,
414
+ headers: {
415
+ Authorization: authHeader,
416
+ ...(body !== undefined
417
+ ? { 'Content-Type': method === 'PUT' ? 'text/calendar; charset=utf-8' : 'application/xml; charset=utf-8' }
418
+ : {}),
419
+ ...extraHeaders,
420
+ },
421
+ body,
422
+ });
423
+ } catch {
424
+ // Redact like the HTTP-status branch below: the raw fetch error
425
+ // (e.g. "getaddrinfo ENOTFOUND cal.example.com") leaks the CalDAV
426
+ // hostname/URL into caller-visible output (importIcs errors[]).
427
+ // Never include creds/URL/host or raw fetch detail.
428
+ throw new OperatorError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
429
+ }
430
+ const text = await response.text();
431
+ if (!response.ok) {
432
+ // Map auth/permission/not-found to operator errors. Never include creds/URL.
433
+ const status = response.status;
434
+ const code = status === 401 || status === 403
435
+ ? 'CALENDAR_AUTH_FAILED'
436
+ : status === 404
437
+ ? 'CALENDAR_NOT_FOUND'
438
+ : 'CALENDAR_REQUEST_FAILED';
439
+ throw new OperatorError(
440
+ `CalDAV server returned HTTP ${status}.`,
441
+ code,
442
+ status >= 400 && status < 500 ? status : 502,
443
+ );
444
+ }
445
+ return { status: response.status, text, headers: response.headers };
446
+ };
447
+
448
+ const buildEvents = (
449
+ entries: MultiStatusEntry[],
450
+ calendarId: string,
451
+ ): CalDavEvent[] => {
452
+ const events: CalDavEvent[] = [];
453
+ for (const entry of entries) {
454
+ if (!entry.calendarData) continue;
455
+ const parsed = parseICS(entry.calendarData);
456
+ for (const event of parsed) {
457
+ events.push({ ...event, href: toRelativeHref(entry.href), calendarId });
458
+ }
459
+ }
460
+ return events;
461
+ };
462
+
463
+ return {
464
+ async listCalendars() {
465
+ const url = stripTrailingSlash(config.baseUrl);
466
+ const { text } = await request(url, 'PROPFIND', propfindCalendarsBody(), { Depth: '1' });
467
+ const entries = parseMultiStatus(text);
468
+ const calendars: Array<{ calendarId: string; displayName: string }> = [];
469
+ const seen = new Set<string>();
470
+ for (const entry of entries) {
471
+ if (!entry.isCalendar) continue;
472
+ const rel = toRelativeHref(entry.href);
473
+ // Derive a stable logical id from the last path segment.
474
+ const segments = rel.split('/').filter((s) => s.length > 0);
475
+ const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]) : config.defaultCalendarId;
476
+ if (seen.has(logical)) continue;
477
+ seen.add(logical);
478
+ calendars.push({ calendarId: logical, displayName: entry.displayName ?? logical });
479
+ }
480
+ if (calendars.length === 0) {
481
+ calendars.push({ calendarId: config.defaultCalendarId, displayName: config.defaultCalendarId });
482
+ }
483
+ return calendars;
484
+ },
485
+
486
+ async listEvents(opts) {
487
+ const calendarId = opts.calendarId && opts.calendarId.length > 0 ? opts.calendarId : config.defaultCalendarId;
488
+ const url = collectionUrlFor(calendarId);
489
+ const { text } = await request(url, 'REPORT', calendarQueryBody(opts.from, opts.to), { Depth: '1' });
490
+ const entries = parseMultiStatus(text);
491
+ let events = buildEvents(entries, calendarId);
492
+ events.sort((a, b) => a.start.localeCompare(b.start));
493
+ const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
494
+ if (events.length > limit) events = events.slice(0, limit);
495
+ return events;
496
+ },
497
+
498
+ async getEvent(eventId, calendarId) {
499
+ const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
500
+ const collectionUrl = collectionUrlFor(id);
501
+
502
+ // Strategy 1 — href-like identifiers (a relative path, or a *.ics
503
+ // resource name): resolve to a single resource URL and GET it directly.
504
+ // This is a true single-event fetch, not a collection scan.
505
+ if (isHrefLike(eventId)) {
506
+ const resourceUrl = resolveResourceUrl(collectionUrl, config.baseUrl, eventId);
507
+ let single;
508
+ try {
509
+ single = await request(resourceUrl, 'GET', undefined, { Depth: '0' });
510
+ } catch (error) {
511
+ // A 404 maps to "not found"; any other failure propagates.
512
+ if (error instanceof OperatorError && error.code === 'CALENDAR_NOT_FOUND') {
513
+ return null;
514
+ }
515
+ throw error;
516
+ }
517
+ const parsed = parseICS(single.text);
518
+ if (parsed.length === 0) return null;
519
+ const relHref = toRelativeHref(eventId);
520
+ return { ...parsed[0], href: relHref, calendarId: id };
521
+ }
522
+
523
+ // Strategy 2 — bare UID: ask the server for ONLY the matching resource via
524
+ // a UID prop-filter calendar-query. The server does the matching, so this
525
+ // is O(1) on the wire and cannot miss an event hidden past a list cutoff.
526
+ const { text } = await request(
527
+ collectionUrl,
528
+ 'REPORT',
529
+ calendarQueryByUidBody(eventId),
530
+ { Depth: '1' },
531
+ );
532
+ const entries = parseMultiStatus(text);
533
+ const events = buildEvents(entries, id);
534
+ // Some servers ignore unsupported prop-filters and return the collection;
535
+ // confirm the exact UID match client-side so we never return a wrong event.
536
+ const found = events.find((event) => event.uid === eventId);
537
+ return found ?? null;
538
+ },
539
+
540
+ async createEvent(input) {
541
+ const calendarId = input.calendarId && input.calendarId.length > 0 ? input.calendarId : config.defaultCalendarId;
542
+ const uid = `${crypto.randomUUID()}@goodvibes`;
543
+ const createdAt = new Date().toISOString();
544
+ const ics = generateICS({
545
+ uid,
546
+ summary: input.title,
547
+ start: input.start,
548
+ end: input.end,
549
+ description: input.description,
550
+ location: input.location,
551
+ attendees: input.attendees,
552
+ status: 'confirmed',
553
+ dtStamp: createdAt,
554
+ });
555
+ const resourcePath = `${uid}.ics`;
556
+ const collectionUrl = collectionUrlFor(calendarId);
557
+ const resourceUrl = joinUrl(collectionUrl, resourcePath);
558
+ const { headers } = await request(resourceUrl, 'PUT', ics, { 'If-None-Match': '*' });
559
+ // Prefer the server-assigned location; otherwise the relative resource path.
560
+ const location = headers.get('Location');
561
+ const href = location ? toRelativeHref(location) : toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath));
562
+ return { eventId: href, uid, createdAt };
563
+ },
564
+
565
+ async importIcs(icsContent, calendarId) {
566
+ const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
567
+ const parsed = parseICS(icsContent);
568
+ if (parsed.length === 0) {
569
+ throw new OperatorError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
570
+ }
571
+ const collectionUrl = collectionUrlFor(id);
572
+ const eventIds: string[] = [];
573
+ const errors: string[] = [];
574
+ let imported = 0;
575
+ for (const event of parsed) {
576
+ const uid = event.uid && event.uid.length > 0 ? event.uid : `${crypto.randomUUID()}@goodvibes`;
577
+ try {
578
+ const ics = generateICS(parsedToGenerateInput(event, uid));
579
+ const resourcePath = `${uid}.ics`;
580
+ const resourceUrl = joinUrl(collectionUrl, resourcePath);
581
+ await request(resourceUrl, 'PUT', ics);
582
+ eventIds.push(toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath)));
583
+ imported += 1;
584
+ } catch (error) {
585
+ const message = error instanceof OperatorError ? error.message : error instanceof Error ? error.message : String(error);
586
+ errors.push(`${uid}: ${message}`);
587
+ }
588
+ }
589
+ return { imported, eventIds, errors };
590
+ },
591
+
592
+ async exportIcs(opts) {
593
+ const id = opts.calendarId && opts.calendarId.length > 0 ? opts.calendarId : config.defaultCalendarId;
594
+ const url = collectionUrlFor(id);
595
+ const { text } = await request(url, 'REPORT', calendarQueryBody(opts.from, opts.to), { Depth: '1' });
596
+ const entries = parseMultiStatus(text);
597
+ const inputs: GenerateICalInput[] = [];
598
+ for (const entry of entries) {
599
+ if (!entry.calendarData) continue;
600
+ for (const event of parseICS(entry.calendarData)) {
601
+ inputs.push(parsedToGenerateInput(event, event.uid || `${crypto.randomUUID()}@goodvibes`));
602
+ }
603
+ }
604
+ const icsContent = generateCalendar(inputs);
605
+ return { icsContent, eventCount: inputs.length };
606
+ },
607
+ };
608
+ }
609
+
610
+ /**
611
+ * Heuristic: does an event identifier look like a resource href rather than a
612
+ * bare iCalendar UID? Hrefs contain a path separator or end in `.ics`; UIDs
613
+ * are opaque tokens (often `uuid@host`) without a path component.
614
+ */
615
+ function isHrefLike(eventId: string): boolean {
616
+ return eventId.includes('/') || /\.ics$/i.test(eventId);
617
+ }
618
+
619
+ /**
620
+ * Resolve an href-like identifier to an absolute, authenticated resource URL
621
+ * for a direct GET. A host-absolute href (starting with '/') is resolved
622
+ * against the base URL origin; any other value is treated as a resource name
623
+ * within the target collection.
624
+ */
625
+ function resolveResourceUrl(collectionUrl: string, baseUrl: string, eventId: string): string {
626
+ const trimmed = eventId.trim();
627
+ if (trimmed.startsWith('/')) {
628
+ return `${originOf(baseUrl)}${trimmed}`;
629
+ }
630
+ return joinUrl(collectionUrl, trimmed);
631
+ }
632
+
633
+ /** Compute the host-relative collection path from an absolute collection URL. */
634
+ function collectionPathOrRoot(collectionUrl: string, baseUrl: string): string {
635
+ const rel = toRelativeHref(collectionUrl);
636
+ if (rel.length > 0) return rel;
637
+ return toRelativeHref(baseUrl);
638
+ }
639
+
640
+ function parsedToGenerateInput(event: ParsedICalEvent, uid: string): GenerateICalInput {
641
+ return {
642
+ uid,
643
+ summary: event.summary,
644
+ start: event.start,
645
+ end: event.end,
646
+ description: event.description,
647
+ location: event.location,
648
+ // Re-emit attendees by their raw value so import preserves the original
649
+ // addressing; display-name-only redaction applies to operator responses,
650
+ // not to server-side payloads.
651
+ attendees: event.attendees.map((a) => a.rawValue),
652
+ organizer: event.organizerRaw,
653
+ status: event.status,
654
+ recurrence: event.recurrence,
655
+ allDay: event.allDay,
656
+ };
657
+ }