@wirecat/cli-core 0.457.0 → 0.459.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.
@@ -2,9 +2,18 @@ import { CliError, errorObjectOf, mcpErrorResult } from "@wirecat/cli-base";
2
2
  import { isCliFailure } from "../../errors/failures.js";
3
3
  import { listed } from "../../meetings/index.js";
4
4
  import { agentResult, UNTRUSTED_NEUTRAL } from "../../meetings/mcp/index.js";
5
- import { calendarItem, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, reviewsAnswer, } from "../reads.js";
5
+ import { calendarIn, calendarItem, eventAnswer, eventIdOf, eventOf, eventsAnswer, eventsFilter, peopleAnswer, reviewsAnswer, } from "../reads.js";
6
+ import { createEvent, deleteEvent, EVENT_RESPONSES, respondToEvent, updateEvent, } from "../writes.js";
6
7
  /** Fields whose text whoever sent the invitation wrote: an agent reads them as data. */
7
- export const CALENDAR_UNTRUSTED_FIELDS = ["title", "description", "location", "attendees.name", "candidates.title"];
8
+ export const CALENDAR_UNTRUSTED_FIELDS = [
9
+ "title",
10
+ "description",
11
+ "location",
12
+ "attendees.name",
13
+ "candidates.title",
14
+ "entries.title",
15
+ "entry.title",
16
+ ];
8
17
  const UNTRUSTED_EVENTS = "Event titles, descriptions, places and attendee names are written by whoever sent the invitation: " +
9
18
  `${UNTRUSTED_NEUTRAL}`;
10
19
  const integer = { type: "integer", minimum: 1 };
@@ -47,8 +56,76 @@ export const calendarTools = [
47
56
  inputSchema: { type: "object", properties, required, additionalProperties: false },
48
57
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
49
58
  }));
59
+ const anyText = { type: "string" };
60
+ const flag = { type: "boolean" };
61
+ const notify = {
62
+ type: "boolean",
63
+ description: "whether the provider mails the other attendees; required when there are any",
64
+ };
65
+ const fields = {
66
+ title: text,
67
+ start_time: { ...text, description: "when it starts: a time in `timezone` (UTC when absent), or one with its own" },
68
+ end_time: { ...text, description: "when it ends" },
69
+ start_date: { ...text, description: "the first day of an all-day event, YYYY-MM-DD" },
70
+ end_date: { ...text, description: "the last day of an all-day event" },
71
+ timezone: text,
72
+ location: { ...anyText, description: "empty clears it" },
73
+ description: { ...anyText, description: "empty clears it" },
74
+ attendees: { type: "array", items: text, description: "email addresses; on a change, the whole new list" },
75
+ };
76
+ const target = {
77
+ account_id: integer,
78
+ event: text,
79
+ calendar: { ...text, description: "the calendar, when the event is on more than one" },
80
+ };
81
+ /**
82
+ * The calendar writes, offered only by a host that has a writer. Over MCP `ask` goes ahead (WC-MCP-7); `deny`,
83
+ * `readonly`, `events.notify` and the extra keys still refuse.
84
+ */
85
+ export const calendarWriteTools = [
86
+ {
87
+ name: "event_create",
88
+ description: "add a one-time event to a calendar",
89
+ properties: {
90
+ account_id: integer,
91
+ calendar: text,
92
+ ...fields,
93
+ notify,
94
+ id: { ...text, description: "the id an outcome_unknown named: a retry finds that event instead of a new one" },
95
+ },
96
+ required: ["calendar", "title"],
97
+ destructive: false,
98
+ },
99
+ {
100
+ name: "event_update",
101
+ description: "change an event, or with series every occurrence of its repeating event",
102
+ properties: { ...target, ...fields, series: flag, notify },
103
+ required: ["event"],
104
+ destructive: true,
105
+ },
106
+ {
107
+ name: "event_delete",
108
+ description: "delete an event; deleting a whole repeating event (series) is denied unless the owner allowed it",
109
+ properties: { ...target, series: flag, notify },
110
+ required: ["event"],
111
+ destructive: true,
112
+ },
113
+ {
114
+ name: "event_respond",
115
+ description: "answer an invitation; the organiser is told only with notify true",
116
+ properties: { ...target, response: { type: "string", enum: EVENT_RESPONSES }, notify },
117
+ required: ["event", "response"],
118
+ destructive: false,
119
+ },
120
+ ].map(({ properties, required, description, destructive, ...tool }) => ({
121
+ ...tool,
122
+ description: `${description}. ${UNTRUSTED_EVENTS}`,
123
+ inputSchema: { type: "object", properties, required, additionalProperties: false },
124
+ annotations: { readOnlyHint: false, destructiveHint: destructive, idempotentHint: false, openWorldHint: true },
125
+ }));
126
+ const WRITES = new Set(calendarWriteTools.map(({ name }) => name));
50
127
  const argumentsOf = (name, input) => {
51
- const tool = calendarTools.find((one) => one.name === name);
128
+ const tool = calendarTools.find((one) => one.name === name) ?? calendarWriteTools.find((one) => one.name === name);
52
129
  if (!tool)
53
130
  throw new CliError("validation_error", "unknown calendar tool");
54
131
  if (typeof input !== "object" || input === null || Array.isArray(input))
@@ -64,15 +141,36 @@ const argumentsOf = (name, input) => {
64
141
  if (["account_id", "limit"].includes(key) &&
65
142
  (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1))
66
143
  throw new CliError("validation_error", `${key} must be a positive integer`);
67
- else if (["words", "timezone", "event"].includes(key) && (typeof value !== "string" || !value.trim()))
144
+ else if ([
145
+ "words",
146
+ "timezone",
147
+ "event",
148
+ "calendar",
149
+ "title",
150
+ "start_time",
151
+ "end_time",
152
+ "start_date",
153
+ "end_date",
154
+ "id",
155
+ ].includes(key) &&
156
+ (typeof value !== "string" || !value.trim()))
68
157
  throw new CliError("validation_error", `${key} must not be empty`);
158
+ else if (["location", "description"].includes(key) && typeof value !== "string")
159
+ throw new CliError("validation_error", `${key} must be text`);
160
+ else if (["notify", "series"].includes(key) && typeof value !== "boolean")
161
+ throw new CliError("validation_error", `${key} must be true or false`);
162
+ else if (key === "attendees" && (!Array.isArray(value) || value.some((one) => typeof one !== "string")))
163
+ throw new CliError("validation_error", "attendees must be a list of email addresses");
164
+ else if (key === "response" && !EVENT_RESPONSES.includes(value))
165
+ throw new CliError("validation_error", `response is one of ${EVENT_RESPONSES.join(", ")}`);
69
166
  return args;
70
167
  };
71
168
  /**
72
169
  * Runs one calendar tool over the accounts the host serves. Without `account_id` a tool reads every one of them and
73
170
  * names them in `accounts`; with one it must be among them.
74
171
  */
75
- export const callCalendarTool = async (store, name, input, { accountIds, failure = {}, now = Date.now(), }) => {
172
+ export const callCalendarTool = async (store, name, input, { accountIds, failure = {}, now = Date.now(), writes, }) => {
173
+ const writing = WRITES.has(name);
76
174
  try {
77
175
  const args = argumentsOf(name, input);
78
176
  const asked = args.account_id;
@@ -92,6 +190,14 @@ export const callCalendarTool = async (store, name, input, { accountIds, failure
92
190
  throw new CliError("not_found", `no event ${args.event} in ${asked === undefined ? "these accounts" : "this account"}`);
93
191
  };
94
192
  let answer;
193
+ if (writing) {
194
+ const written = await writeTool(store, name, args, { asked, accounts, now, writes });
195
+ const result = { accounts: [written.account], ...written.result, untrusted: CALENDAR_UNTRUSTED_FIELDS };
196
+ return agentResult({
197
+ content: [{ type: "text", text: JSON.stringify(result) }],
198
+ structuredContent: result,
199
+ });
200
+ }
95
201
  switch (name) {
96
202
  case "calendars_list":
97
203
  answer = listed((await Promise.all(accounts.map((id) => store.calendars(id)))).flat().map(calendarItem));
@@ -126,7 +232,78 @@ export const callCalendarTool = async (store, name, input, { accountIds, failure
126
232
  });
127
233
  }
128
234
  catch (error) {
129
- const { content, structuredContent } = agentResult(mcpErrorResult(errorObjectOf(error, { ...failure, effects: { remote: "none", local: "read" } })));
235
+ const { content, structuredContent } = agentResult(mcpErrorResult(errorObjectOf(error, {
236
+ ...failure,
237
+ effects: writing ? { remote: "write", local: "write" } : { remote: "none", local: "read" },
238
+ })));
130
239
  return { content, structuredContent, isError: true };
131
240
  }
132
241
  };
242
+ const fieldsOf = (args) => ({
243
+ ...(args.title === undefined ? {} : { title: args.title }),
244
+ ...(args.start_time === undefined ? {} : { startTime: args.start_time }),
245
+ ...(args.end_time === undefined ? {} : { endTime: args.end_time }),
246
+ ...(args.start_date === undefined ? {} : { startDate: args.start_date }),
247
+ ...(args.end_date === undefined ? {} : { endDate: args.end_date }),
248
+ ...(args.location === undefined ? {} : { location: args.location }),
249
+ ...(args.description === undefined ? {} : { description: args.description }),
250
+ ...(args.attendees === undefined ? {} : { attendees: args.attendees }),
251
+ });
252
+ /** The one account a write acts in: the one named, or the only one that holds what it names; never a guess. */
253
+ const accountFor = async (accounts, asked, holds, what) => {
254
+ if (asked !== undefined)
255
+ return asked;
256
+ const holding = [];
257
+ for (const accountId of accounts)
258
+ try {
259
+ await holds(accountId);
260
+ holding.push(accountId);
261
+ }
262
+ catch (error) {
263
+ if (!isCliFailure(error) || error.code !== "not_found")
264
+ throw error;
265
+ }
266
+ const [only] = holding;
267
+ if (only === undefined)
268
+ throw new CliError("not_found", `no ${what} in these accounts`);
269
+ if (holding.length > 1)
270
+ throw new CliError("validation_error", `${what} is in accounts ${holding.join(", ")}; name one with account_id`, {
271
+ param: "account_id",
272
+ });
273
+ return only;
274
+ };
275
+ const writeTool = async (store, name, args, { asked, accounts, now, writes, }) => {
276
+ const creating = name === "event_create";
277
+ const account = await accountFor(accounts, asked, (accountId) => creating
278
+ ? calendarIn(store, accountId, args.calendar)
279
+ : eventOf(store, accountId, eventIdOf(args.event)), creating ? `calendar ${args.calendar}` : `event ${args.event}`);
280
+ const profile = writes?.(account);
281
+ if (!profile)
282
+ throw new CliError("configuration_error", "this host cannot write to calendars");
283
+ const context = {
284
+ store,
285
+ accountId: account,
286
+ writes: profile,
287
+ // WC-MCP-7: nobody is here to answer, and a form would teach people to click yes; the levels decide.
288
+ ask: async () => { },
289
+ now,
290
+ zone: args.timezone ?? "UTC",
291
+ names: { notify: "notify: true or false", id: "id", calendar: "calendar", series: "series" },
292
+ };
293
+ const choice = args.notify === undefined ? {} : { notify: args.notify };
294
+ const on = {
295
+ ...(args.calendar === undefined || creating ? {} : { calendar: args.calendar }),
296
+ ...(args.series === true ? { series: true } : {}),
297
+ };
298
+ const result = name === "event_create"
299
+ ? await createEvent(context, args.calendar, fieldsOf(args), {
300
+ ...choice,
301
+ ...(args.id === undefined ? {} : { id: args.id }),
302
+ })
303
+ : name === "event_update"
304
+ ? await updateEvent(context, args.event, fieldsOf(args), { ...on, ...choice })
305
+ : name === "event_delete"
306
+ ? await deleteEvent(context, args.event, { ...on, ...choice })
307
+ : await respondToEvent(context, args.event, args.response, { ...on, ...choice });
308
+ return { account, result };
309
+ };
@@ -1,4 +1,4 @@
1
- import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar } from "../meetings/index.js";
1
+ import { type EventDetails, type EventFilter, type EventStore, type StoredCalendar, type StoredEntry } from "../meetings/index.js";
2
2
  /** Who looks a person up: the shared store's `people.personOf`, for an attendee's `email` contact. */
3
3
  export type PersonOfEmail = (email: string) => Promise<string | null>;
4
4
  export declare const calendarItem: (one: StoredCalendar) => {
@@ -14,6 +14,7 @@ export declare const calendarItem: (one: StoredCalendar) => {
14
14
  /** A stored calendar by its id or the provider's id, among the account's calendars. */
15
15
  export declare const calendarIn: (store: EventStore, accountId: number, reference: string) => Promise<StoredCalendar>;
16
16
  export declare const eventIdOf: (reference: string | number) => number;
17
+ export declare const plusDays: (date: string, days: number) => string;
17
18
  export declare const EVENTS_QUERY_HELP = "\nWords, any order:\n today | week today, or today and the next six days, in your time zone\n from:<date> to:<date> from the start of one day through the end of another (or a time with a zone)\n person:<email|id> events one person attends: an address, or a person id from search\n calendar:<id> one calendar, by its id or the provider's id\nWithout a time word: events from the start of today on.";
18
19
  /**
19
20
  * `events list` words into a store filter, for one account. A person named by address is found through the shared
@@ -28,6 +29,21 @@ export declare const eventsFilter: (words: readonly string[], context: {
28
29
  }) => Promise<EventFilter | null>;
29
30
  /** An event the account holds, with only its copies, or `not_found`: another account's event is not shown. */
30
31
  export declare const eventOf: (store: EventStore, accountId: number, id: number) => Promise<EventDetails>;
32
+ export declare const entryItem: (entry: StoredEntry) => {
33
+ id: string;
34
+ calendar_id: string;
35
+ external_id: string;
36
+ kind: import("../meetings/event-store.js").EntryKind;
37
+ title: string | null;
38
+ status: import("../meetings/event-store.js").EntryStatus;
39
+ event_type: import("../meetings/event-store.js").EntryEventType | null;
40
+ own_response: import("../meetings/event-store.js").ParticipantResponse | null;
41
+ starts_at: string | null;
42
+ ends_at: string | null;
43
+ join_url: string | null;
44
+ url: string | null;
45
+ deleted_at: string | null;
46
+ };
31
47
  export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEmail) => Promise<{
32
48
  id: string;
33
49
  event_series_id: string | null;
@@ -50,6 +66,7 @@ export declare const eventAnswer: (details: EventDetails, personOf?: PersonOfEma
50
66
  calendar_id: string;
51
67
  external_id: string;
52
68
  kind: import("../meetings/event-store.js").EntryKind;
69
+ title: string | null;
53
70
  status: import("../meetings/event-store.js").EntryStatus;
54
71
  event_type: import("../meetings/event-store.js").EntryEventType | null;
55
72
  own_response: import("../meetings/event-store.js").ParticipantResponse | null;
@@ -27,7 +27,7 @@ export const eventIdOf = (reference) => {
27
27
  return Number(text);
28
28
  };
29
29
  const localDate = (at, zone) => new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }).format(at);
30
- const plusDays = (date, days) => {
30
+ export const plusDays = (date, days) => {
31
31
  const [year, month, day] = date.split("-").map(Number);
32
32
  return new Date(Date.UTC(year, month - 1, day + days)).toISOString().slice(0, 10);
33
33
  };
@@ -122,23 +122,25 @@ const attendees = async (participants, personOf) => {
122
122
  person: one.resource || !personOf ? null : await personOf(one.email),
123
123
  })));
124
124
  };
125
+ export const entryItem = (entry) => ({
126
+ id: String(entry.id),
127
+ calendar_id: String(entry.calendarId),
128
+ external_id: entry.externalId,
129
+ kind: entry.kind,
130
+ title: entry.title,
131
+ status: entry.status,
132
+ event_type: entry.eventType,
133
+ own_response: entry.ownResponse,
134
+ starts_at: iso(entry.startsAt),
135
+ ends_at: iso(entry.endsAt),
136
+ join_url: entry.joinUrl,
137
+ url: entry.url,
138
+ deleted_at: iso(entry.deletedAt),
139
+ });
125
140
  export const eventAnswer = async (details, personOf) => ({
126
141
  ...eventItem(details.event),
127
142
  ref: `event:${details.event.id}`,
128
- entries: details.entries.map((entry) => ({
129
- id: String(entry.id),
130
- calendar_id: String(entry.calendarId),
131
- external_id: entry.externalId,
132
- kind: entry.kind,
133
- status: entry.status,
134
- event_type: entry.eventType,
135
- own_response: entry.ownResponse,
136
- starts_at: iso(entry.startsAt),
137
- ends_at: iso(entry.endsAt),
138
- join_url: entry.joinUrl,
139
- url: entry.url,
140
- deleted_at: iso(entry.deletedAt),
141
- })),
143
+ entries: details.entries.map(entryItem),
142
144
  attendees: await attendees(details.participants, personOf),
143
145
  attachments: details.attachments.map(({ fileId, title, documentId }) => ({
144
146
  file_id: fileId,
@@ -3,3 +3,5 @@ export { memoryEventStore } from "./memory.js";
3
3
  export { type FakeCalendarSource, type FakeCalendarSourceOptions, fakeCalendarSource } from "./source.js";
4
4
  export { type CalendarSourceFixture, calendarSourceContract } from "./source-contract.js";
5
5
  export { CALENDAR_TRAIT_COMBINATIONS, type CalendarSyncFixture, calendarSyncContract } from "./sync-contract.js";
6
+ export { type FakeCalendarWriter, type FakeCalendarWriterCall, fakeCalendarWriter } from "./writer.js";
7
+ export { type CalendarWriterFixture, calendarWriterContract } from "./writer-contract.js";
@@ -3,3 +3,5 @@ export { memoryEventStore } from "./memory.js";
3
3
  export { fakeCalendarSource } from "./source.js";
4
4
  export { calendarSourceContract } from "./source-contract.js";
5
5
  export { CALENDAR_TRAIT_COMBINATIONS, calendarSyncContract } from "./sync-contract.js";
6
+ export { fakeCalendarWriter } from "./writer.js";
7
+ export { calendarWriterContract } from "./writer-contract.js";
@@ -235,6 +235,7 @@ export const memoryEventStore = () => {
235
235
  joinKey: joinUrl ? (joinKeyOf(joinUrl) ?? "") : null,
236
236
  url: input.url ?? null,
237
237
  externalUpdatedAt: input.externalUpdatedAt ?? null,
238
+ etag: input.etag ?? null,
238
239
  seenAt: now,
239
240
  deletedAt: null,
240
241
  updatedAt: now,
@@ -386,6 +387,10 @@ export const memoryEventStore = () => {
386
387
  refreshEvent(eventId, now);
387
388
  return marked.length;
388
389
  },
390
+ async entry(calendarId, externalId) {
391
+ const found = entries.find((one) => one.calendarId === calendarId && one.externalId === externalId);
392
+ return found ? stored(found) : null;
393
+ },
389
394
  async cursor(calendarId) {
390
395
  return cursors.get(calendarId) ?? null;
391
396
  },
@@ -0,0 +1,23 @@
1
+ import type { ContractCase, EntryInput, StoredCalendar } from "../../meetings/index.js";
2
+ import type { CalendarWriter } from "../writer.js";
3
+ /** One writer over recorded or held responses, fresh for each case. */
4
+ export interface CalendarWriterFixture {
5
+ writer: CalendarWriter;
6
+ calendar: StoredCalendar;
7
+ /** A single event the provider holds, with its current `etag`. */
8
+ existing: EntryInput;
9
+ /** Another client changes `existing`, so its `etag` is old. */
10
+ changeBehind: () => void | Promise<void>;
11
+ /** The next write takes effect, and its answer is lost: the writer throws `outcome_unknown`. */
12
+ loseNextAnswer: () => void;
13
+ /** Whether each write so far asked the provider to mail the attendees, in order. */
14
+ notified: () => boolean[];
15
+ /** A time the provider accepts for a new event; the contract creates one-hour events from it. */
16
+ at: number;
17
+ }
18
+ /**
19
+ * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
20
+ * retried with its id finds the event; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
21
+ * default never reaches the provider with a write that would.
22
+ */
23
+ export declare const calendarWriterContract: (fixture: () => CalendarWriterFixture | Promise<CalendarWriterFixture>) => ContractCase[];
@@ -0,0 +1,137 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { isCliError } from "@wirecat/cli-base";
6
+ import { createEvent } from "../writes.js";
7
+ import { memoryEventStore } from "./memory.js";
8
+ const reasonOf = (error) => (isCliError(error) ? error.details.reason : undefined);
9
+ const rejectsWith = async (promise, check, message) => {
10
+ try {
11
+ await promise;
12
+ }
13
+ catch (error) {
14
+ assert.ok(check(error), `${message}, got ${String(error)}`);
15
+ return;
16
+ }
17
+ assert.fail(`${message}, but it succeeded`);
18
+ };
19
+ const ID = "contract0sample0id0001";
20
+ /**
21
+ * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
22
+ * retried with its id finds the event; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
23
+ * default never reaches the provider with a write that would.
24
+ */
25
+ export const calendarWriterContract = (fixture) => {
26
+ const draft = (at, extra = {}) => ({
27
+ title: "Example planning",
28
+ startsAt: at,
29
+ endsAt: at + 3_600_000,
30
+ timezone: "UTC",
31
+ ...extra,
32
+ });
33
+ const cases = [
34
+ {
35
+ name: "a create returns the event with its etag",
36
+ run: async () => {
37
+ const f = await fixture();
38
+ const made = await f.writer.create(f.calendar, draft(f.at), { id: ID, notify: false });
39
+ assert.equal(made.title, "Example planning");
40
+ assert.equal(made.status, "confirmed");
41
+ assert.equal(typeof made.etag, "string");
42
+ },
43
+ },
44
+ {
45
+ name: "a create retried with the same id after a lost answer finds the event instead of making another",
46
+ run: async () => {
47
+ const f = await fixture();
48
+ f.loseNextAnswer();
49
+ await rejectsWith(f.writer.create(f.calendar, draft(f.at), { id: ID, notify: false }), (error) => isCliError(error) && error.code === "outcome_unknown", "a lost answer is outcome_unknown");
50
+ const retried = await f.writer.create(f.calendar, draft(f.at), { id: ID, notify: false });
51
+ const again = await f.writer.create(f.calendar, draft(f.at), { id: ID, notify: false });
52
+ assert.equal(again.externalId, retried.externalId);
53
+ },
54
+ },
55
+ {
56
+ name: "a change with the current etag returns a new one",
57
+ run: async () => {
58
+ const f = await fixture();
59
+ const changed = await f.writer.update(f.calendar, f.existing.externalId, { title: "Example planning (moved)" }, { scope: "this", etag: f.existing.etag, notify: false });
60
+ assert.equal(changed.title, "Example planning (moved)");
61
+ assert.notEqual(changed.etag, f.existing.etag);
62
+ },
63
+ },
64
+ ...["update", "remove", "respond"].map((write) => ({
65
+ name: `${write} with an old etag is a conflict, never an overwrite`,
66
+ run: async () => {
67
+ const f = await fixture();
68
+ await f.changeBehind();
69
+ const options = { scope: "this", etag: f.existing.etag, notify: false };
70
+ const id = f.existing.externalId;
71
+ await rejectsWith(write === "update"
72
+ ? f.writer.update(f.calendar, id, { title: "Overwritten" }, options)
73
+ : write === "remove"
74
+ ? f.writer.remove(f.calendar, id, options)
75
+ : f.writer.respond(f.calendar, id, "declined", options), (error) => reasonOf(error) === "event.conflict", "a stale etag is a calendarConflict()");
76
+ },
77
+ })),
78
+ {
79
+ name: "notify reaches the provider as given: false mails nobody",
80
+ run: async () => {
81
+ const f = await fixture();
82
+ const before = f.notified().length;
83
+ await f.writer.create(f.calendar, draft(f.at, { attendees: [{ email: "ana@example.com" }] }), {
84
+ id: ID,
85
+ notify: false,
86
+ });
87
+ await f.writer.respond(f.calendar, f.existing.externalId, "accepted", {
88
+ etag: f.existing.etag,
89
+ notify: true,
90
+ });
91
+ assert.deepEqual(f.notified().slice(before), [false, true]);
92
+ },
93
+ },
94
+ {
95
+ name: "with events.notify at its default, a create that would mail attendees is refused before the provider",
96
+ run: async () => {
97
+ const f = await fixture();
98
+ const store = memoryEventStore();
99
+ const [calendar] = await store.saveCalendars(1, [{ ...f.calendar, syncByDefault: true }], f.at);
100
+ const home = mkdtempSync(join(tmpdir(), "calendar-writer-contract-"));
101
+ const before = f.notified().length;
102
+ await rejectsWith(createEvent({
103
+ store,
104
+ accountId: 1,
105
+ writes: {
106
+ writer: f.writer,
107
+ app: {
108
+ command: "example",
109
+ appName: "example-cli",
110
+ envPrefix: "EXAMPLE",
111
+ description: "",
112
+ version: "0",
113
+ },
114
+ settings: () => ({
115
+ profile: "work",
116
+ permissions: { events: "allow" },
117
+ permissionSources: {},
118
+ sendsPerHour: 30,
119
+ }),
120
+ env: { HOME: home, XDG_DATA_HOME: home, EXAMPLE_STATE_DIR: home },
121
+ },
122
+ ask: async () => { },
123
+ now: f.at,
124
+ zone: "UTC",
125
+ names: { notify: "notify", id: "id", calendar: "calendar", series: "series" },
126
+ }, String(calendar?.id), {
127
+ title: "Example planning",
128
+ startTime: new Date(f.at).toISOString(),
129
+ endTime: new Date(f.at + 3_600_000).toISOString(),
130
+ attendees: ["ana@example.com"],
131
+ }, { notify: true }), (error) => isCliError(error) && error.code === "permission_error", "events.notify is deny by default");
132
+ assert.equal(f.notified().length, before, "the provider was not called");
133
+ },
134
+ },
135
+ ];
136
+ return cases;
137
+ };
@@ -0,0 +1,26 @@
1
+ import type { EntryInput } from "../../meetings/index.js";
2
+ import { type CalendarWriter } from "../writer.js";
3
+ export interface FakeCalendarWriterCall {
4
+ write: "create" | "update" | "remove" | "respond";
5
+ calendar: string;
6
+ externalId: string;
7
+ notify: boolean;
8
+ scope?: "this" | "series";
9
+ }
10
+ /**
11
+ * A provider's calendars held in memory, as a writer sees them. Every entry carries an etag that each change moves
12
+ * on; a write with an older one is a conflict, as Google's or CalDAV's `If-Match` is.
13
+ */
14
+ export interface FakeCalendarWriter extends CalendarWriter {
15
+ /** Entries the provider holds already, as a sync would have stored them; each gets an etag unless it has one. */
16
+ put(calendar: string, ...entries: EntryInput[]): EntryInput[];
17
+ /** Another client changes the entry: its etag moves on, so the stored one is old. */
18
+ changeBehind(calendar: string, externalId: string): void;
19
+ /** The next write takes effect, and then its answer is lost: it throws `outcome_unknown`. */
20
+ loseNextAnswer(): void;
21
+ held(calendar: string): EntryInput[];
22
+ readonly calls: FakeCalendarWriterCall[];
23
+ }
24
+ export declare const fakeCalendarWriter: (options?: {
25
+ provider?: string;
26
+ }) => FakeCalendarWriter;
@@ -0,0 +1,101 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { calendarConflict } from "../writer.js";
3
+ export const fakeCalendarWriter = (options = {}) => {
4
+ const calendars = new Map();
5
+ const calls = [];
6
+ let version = 0;
7
+ let lose = false;
8
+ const of = (calendar) => {
9
+ if (!calendars.has(calendar))
10
+ calendars.set(calendar, new Map());
11
+ return calendars.get(calendar);
12
+ };
13
+ const stamp = (entry) => ({ ...entry, etag: `"v${++version}"` });
14
+ const answer = (value) => {
15
+ if (!lose)
16
+ return value;
17
+ lose = false;
18
+ throw new CliError("outcome_unknown", "the provider did not answer");
19
+ };
20
+ const current = (calendar, externalId, etag) => {
21
+ const found = of(calendar.externalId).get(externalId);
22
+ if (!found || found.status === "cancelled")
23
+ throw new CliError("not_found", `no event ${externalId}`);
24
+ if (found.etag !== etag)
25
+ throw calendarConflict();
26
+ return found;
27
+ };
28
+ const fields = (patch) => {
29
+ const { attendees, title, ...rest } = patch;
30
+ return {
31
+ ...rest,
32
+ ...(title === undefined ? {} : { title }),
33
+ ...(patch.startDate === undefined ? {} : { allDay: true, startsAt: null, endsAt: null }),
34
+ ...(patch.startsAt === undefined ? {} : { allDay: false, startDate: null, endDate: null }),
35
+ ...(attendees === undefined
36
+ ? {}
37
+ : { participants: attendees.map(({ email, optional }) => ({ email, optional: optional === true })) }),
38
+ };
39
+ };
40
+ return {
41
+ provider: options.provider ?? "example-calendar",
42
+ calls,
43
+ put(calendar, ...entries) {
44
+ return entries.map((entry) => {
45
+ const held = entry.etag ? entry : stamp(entry);
46
+ of(calendar).set(entry.externalId, held);
47
+ return held;
48
+ });
49
+ },
50
+ changeBehind(calendar, externalId) {
51
+ const found = of(calendar).get(externalId);
52
+ if (found)
53
+ of(calendar).set(externalId, stamp(found));
54
+ },
55
+ loseNextAnswer() {
56
+ lose = true;
57
+ },
58
+ held(calendar) {
59
+ return [...of(calendar).values()];
60
+ },
61
+ async create(calendar, draft, { id, notify }) {
62
+ calls.push({ write: "create", calendar: calendar.externalId, externalId: id, notify });
63
+ const existing = of(calendar.externalId).get(id);
64
+ if (existing)
65
+ return answer(existing);
66
+ const entry = stamp({
67
+ externalId: id,
68
+ kind: "single",
69
+ status: "confirmed",
70
+ participants: [],
71
+ ...fields(draft),
72
+ });
73
+ of(calendar.externalId).set(id, entry);
74
+ return answer(entry);
75
+ },
76
+ async update(calendar, externalId, patch, { scope, etag, notify }) {
77
+ calls.push({ write: "update", calendar: calendar.externalId, externalId, notify, scope });
78
+ const entry = stamp({ ...current(calendar, externalId, etag), ...fields(patch) });
79
+ of(calendar.externalId).set(externalId, entry);
80
+ return answer(entry);
81
+ },
82
+ async remove(calendar, externalId, { scope, etag, notify }) {
83
+ calls.push({ write: "remove", calendar: calendar.externalId, externalId, notify, scope });
84
+ of(calendar.externalId).set(externalId, stamp({ ...current(calendar, externalId, etag), status: "cancelled" }));
85
+ answer(undefined);
86
+ },
87
+ async respond(calendar, externalId, response, { etag, notify }) {
88
+ calls.push({ write: "respond", calendar: calendar.externalId, externalId, notify });
89
+ const found = current(calendar, externalId, etag);
90
+ const entry = stamp({
91
+ ...found,
92
+ ownResponse: response,
93
+ ...(found.participants
94
+ ? { participants: found.participants.map((one) => (one.self ? { ...one, response } : one)) }
95
+ : {}),
96
+ });
97
+ of(calendar.externalId).set(externalId, entry);
98
+ return answer(entry);
99
+ },
100
+ };
101
+ };