@wirecat/cli-core 0.487.0 → 0.489.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 (60) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/dist/calendar/cli/index.js +48 -13
  3. package/dist/calendar/index.d.ts +1 -1
  4. package/dist/calendar/mcp/index.d.ts +390 -0
  5. package/dist/calendar/mcp/index.js +50 -12
  6. package/dist/calendar/reads.d.ts +9 -2
  7. package/dist/calendar/reads.js +60 -5
  8. package/dist/calendar/recurrence.d.ts +20 -0
  9. package/dist/calendar/recurrence.js +74 -0
  10. package/dist/calendar/sync.d.ts +6 -0
  11. package/dist/calendar/sync.js +123 -41
  12. package/dist/calendar/testing/memory.js +90 -7
  13. package/dist/calendar/testing/sync-contract.js +90 -0
  14. package/dist/calendar/testing/writer-contract.d.ts +2 -6
  15. package/dist/calendar/testing/writer-contract.js +157 -29
  16. package/dist/calendar/testing/writer.d.ts +3 -1
  17. package/dist/calendar/testing/writer.js +11 -0
  18. package/dist/calendar/writer.d.ts +20 -3
  19. package/dist/calendar/writer.js +8 -4
  20. package/dist/calendar/writes.d.ts +21 -12
  21. package/dist/calendar/writes.js +259 -20
  22. package/dist/errors/reasons.d.ts +1 -1
  23. package/dist/errors/reasons.js +5 -0
  24. package/dist/mail/attachments.d.ts +15 -0
  25. package/dist/mail/attachments.js +38 -0
  26. package/dist/mail/locations.d.ts +28 -0
  27. package/dist/mail/locations.js +54 -0
  28. package/dist/mail/quick.d.ts +3 -1
  29. package/dist/mail/quick.js +9 -6
  30. package/dist/mail/resume.d.ts +6 -0
  31. package/dist/mail/retry.d.ts +23 -0
  32. package/dist/mail/retry.js +110 -0
  33. package/dist/mail/sync.d.ts +2 -3
  34. package/dist/mail/sync.js +85 -152
  35. package/dist/mail/testing/fake.d.ts +2 -0
  36. package/dist/mail/testing/fake.js +1 -1
  37. package/dist/meetings/event-store.d.ts +80 -2
  38. package/dist/meetings/event-store.js +65 -1
  39. package/dist/meetings/index.d.ts +2 -1
  40. package/dist/meetings/index.js +1 -0
  41. package/dist/meetings/testing/event-store-contract.d.ts +1 -1
  42. package/dist/meetings/testing/event-store-contract.js +114 -0
  43. package/dist/store/drivers/node-sqlite.d.ts +9 -1
  44. package/dist/store/drivers/node-sqlite.js +30 -8
  45. package/dist/store/index.d.ts +1 -1
  46. package/dist/store/sqlite/accounts.js +1 -0
  47. package/dist/store/sqlite/calendar-events.d.ts +4 -0
  48. package/dist/store/sqlite/calendar-events.js +89 -0
  49. package/dist/store/sqlite/calendar.js +126 -94
  50. package/dist/store/sqlite/email-locations.d.ts +24 -0
  51. package/dist/store/sqlite/email-locations.js +56 -0
  52. package/dist/store/sqlite/email-state.d.ts +2 -0
  53. package/dist/store/sqlite/email-state.js +28 -0
  54. package/dist/store/sqlite/emails.d.ts +13 -0
  55. package/dist/store/sqlite/emails.js +9 -29
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +7 -0
  58. package/dist/store/sqlite/schema.d.ts +72 -0
  59. package/dist/store/sqlite/schema.js +13 -0
  60. package/package.json +1 -1
@@ -3,7 +3,8 @@ import { mkdtempSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { isCliError } from "@wirecat/cli-base";
6
- import { createEvent } from "../writes.js";
6
+ import { ruleEnd } from "../recurrence.js";
7
+ import { createEvent, deleteEvent, updateEvent } from "../writes.js";
7
8
  import { memoryEventStore } from "./memory.js";
8
9
  const reasonOf = (error) => (isCliError(error) ? error.details.reason : undefined);
9
10
  const rejectsWith = async (promise, check, message) => {
@@ -17,7 +18,10 @@ const rejectsWith = async (promise, check, message) => {
17
18
  assert.fail(`${message}, but it succeeded`);
18
19
  };
19
20
  const ID = "contract0sample0id0001";
21
+ const NEW_ID = "contract0sample0id0002";
22
+ const WEEK = 7 * 86_400_000;
20
23
  const WEEKLY = "RRULE:FREQ=WEEKLY;COUNT=3";
24
+ const WEEKLY_OPEN = "RRULE:FREQ=WEEKLY";
21
25
  const DAILY = "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5";
22
26
  /**
23
27
  * What every calendar writer keeps (gcal-cli plan §9, W1): a stale `etag` is a conflict, never an overwrite; a create
@@ -25,6 +29,53 @@ const DAILY = "RRULE:FREQ=DAILY;INTERVAL=2;COUNT=5";
25
29
  * switches to all-day and back; `notify: false` mails nobody, and a profile that keeps `events.notify` at its
26
30
  * default never reaches the provider with a write that would.
27
31
  */
32
+ const contextOver = async (f, permissions) => {
33
+ const store = memoryEventStore();
34
+ const [calendar] = await store.saveCalendars(1, [{ ...f.calendar, syncByDefault: true }], f.at);
35
+ const home = mkdtempSync(join(tmpdir(), "calendar-writer-contract-"));
36
+ return {
37
+ calendarId: calendar?.id,
38
+ store,
39
+ accountId: 1,
40
+ writes: {
41
+ writer: f.writer,
42
+ app: { command: "example", appName: "example-cli", envPrefix: "EXAMPLE", description: "", version: "0" },
43
+ settings: () => ({ profile: "work", permissions, permissionSources: {}, sendsPerHour: 30 }),
44
+ env: { HOME: home, XDG_DATA_HOME: home, EXAMPLE_STATE_DIR: home },
45
+ },
46
+ ask: async () => { },
47
+ now: f.at,
48
+ zone: "UTC",
49
+ names: {
50
+ notify: "notify",
51
+ id: "id",
52
+ calendar: "calendar",
53
+ series: "series",
54
+ following: "following",
55
+ repeat: "repeat",
56
+ },
57
+ };
58
+ };
59
+ /** A weekly series the writer made, stored with its second occurrence as a sync would store it. */
60
+ const storedSeries = async (f, context) => {
61
+ const series = await f.writer.create(f.calendar, { title: "Example planning", startsAt: f.at, endsAt: f.at + 3_600_000, timezone: "UTC", recurrence: [WEEKLY_OPEN] }, { id: ID, notify: false });
62
+ const [, second] = await context.store.saveEntries(context.calendarId, [
63
+ series,
64
+ {
65
+ externalId: `${ID}_second`,
66
+ kind: "occurrence",
67
+ seriesExternalId: ID,
68
+ originalStartsAt: f.at + WEEK,
69
+ status: "confirmed",
70
+ title: "Example planning",
71
+ startsAt: f.at + WEEK,
72
+ endsAt: f.at + WEEK + 3_600_000,
73
+ timezone: "UTC",
74
+ },
75
+ ], f.at);
76
+ return `event:${second?.eventId}`;
77
+ };
78
+ const SPLIT = { "events.create": "allow", "events.update": "allow", "events.delete.series": "allow" };
28
79
  export const calendarWriterContract = (fixture) => {
29
80
  const draft = (at, extra = {}) => ({
30
81
  title: "Example planning",
@@ -139,35 +190,9 @@ export const calendarWriterContract = (fixture) => {
139
190
  name: "with events.notify at its default, a create that would mail attendees is refused before the provider",
140
191
  run: async () => {
141
192
  const f = await fixture();
142
- const store = memoryEventStore();
143
- const [calendar] = await store.saveCalendars(1, [{ ...f.calendar, syncByDefault: true }], f.at);
144
- const home = mkdtempSync(join(tmpdir(), "calendar-writer-contract-"));
193
+ const context = await contextOver(f, { events: "allow" });
145
194
  const before = f.notified().length;
146
- await rejectsWith(createEvent({
147
- store,
148
- accountId: 1,
149
- writes: {
150
- writer: f.writer,
151
- app: {
152
- command: "example",
153
- appName: "example-cli",
154
- envPrefix: "EXAMPLE",
155
- description: "",
156
- version: "0",
157
- },
158
- settings: () => ({
159
- profile: "work",
160
- permissions: { events: "allow" },
161
- permissionSources: {},
162
- sendsPerHour: 30,
163
- }),
164
- env: { HOME: home, XDG_DATA_HOME: home, EXAMPLE_STATE_DIR: home },
165
- },
166
- ask: async () => { },
167
- now: f.at,
168
- zone: "UTC",
169
- names: { notify: "notify", id: "id", calendar: "calendar", series: "series", repeat: "repeat" },
170
- }, String(calendar?.id), {
195
+ await rejectsWith(createEvent(context, String(context.calendarId), {
171
196
  title: "Example planning",
172
197
  startTime: new Date(f.at).toISOString(),
173
198
  endTime: new Date(f.at + 3_600_000).toISOString(),
@@ -176,6 +201,109 @@ export const calendarWriterContract = (fixture) => {
176
201
  assert.equal(f.notified().length, before, "the provider was not called");
177
202
  },
178
203
  },
204
+ {
205
+ name: "following: the series ends before the occurrence, and a new one starts there with the change",
206
+ run: async () => {
207
+ const f = await fixture();
208
+ const context = await contextOver(f, SPLIT);
209
+ const occurrence = await storedSeries(f, context);
210
+ const answer = (await updateEvent(context, occurrence, { title: "Example planning (later)" }, {
211
+ following: true,
212
+ notify: false,
213
+ id: NEW_ID,
214
+ }));
215
+ const old = await context.store.entry(context.calendarId, ID);
216
+ const ends = ruleEnd(old?.recurrence?.[0] ?? "", "UTC");
217
+ assert.ok(ends !== undefined && ends >= f.at && ends < f.at + WEEK, "the first occurrence stays, the second goes");
218
+ const made = await context.store.entry(context.calendarId, NEW_ID);
219
+ assert.equal(answer.entry?.external_id, NEW_ID);
220
+ assert.equal(made?.kind, "series");
221
+ assert.equal(made?.title, "Example planning (later)");
222
+ assert.equal(made?.startsAt, f.at + WEEK);
223
+ assert.deepEqual(made?.recurrence, [WEEKLY_OPEN]);
224
+ },
225
+ },
226
+ {
227
+ name: "following: a retry with the id after a lost answer finds the new series instead of making a second",
228
+ run: async () => {
229
+ const f = await fixture();
230
+ const context = await contextOver(f, SPLIT);
231
+ const occurrence = await storedSeries(f, context);
232
+ const change = { title: "Example planning (later)" };
233
+ f.loseNextAnswer();
234
+ let named;
235
+ await rejectsWith(updateEvent(context, occurrence, change, { following: true, notify: false }), (error) => {
236
+ named = isCliError(error) ? error.details.operation_id : undefined;
237
+ return isCliError(error) && error.code === "outcome_unknown" && typeof named === "string";
238
+ }, "a lost answer is outcome_unknown naming the new series' id");
239
+ const again = (await updateEvent(context, occurrence, change, {
240
+ following: true,
241
+ notify: false,
242
+ id: named,
243
+ }));
244
+ assert.equal(again.entry?.external_id, named);
245
+ assert.ok(again.ended, "the old series is ended on the retry");
246
+ const ends = ruleEnd((await context.store.entry(context.calendarId, ID))?.recurrence?.[0] ?? "", "UTC");
247
+ assert.ok(ends !== undefined && ends < f.at + WEEK);
248
+ },
249
+ },
250
+ {
251
+ name: "following: a deletion ends the series before the occurrence and makes nothing",
252
+ run: async () => {
253
+ const f = await fixture();
254
+ const context = await contextOver(f, SPLIT);
255
+ const occurrence = await storedSeries(f, context);
256
+ await deleteEvent(context, occurrence, { following: true, notify: false });
257
+ const ends = ruleEnd((await context.store.entry(context.calendarId, ID))?.recurrence?.[0] ?? "", "UTC");
258
+ assert.ok(ends !== undefined && ends >= f.at && ends < f.at + WEEK);
259
+ },
260
+ },
261
+ ...moveCases(fixture),
179
262
  ];
180
263
  return cases;
181
264
  };
265
+ const moveCases = (fixture) => {
266
+ const ready = async () => {
267
+ const f = await fixture();
268
+ return f.writer.move && f.target ? { ...f, move: f.writer.move.bind(f.writer), target: f.target } : undefined;
269
+ };
270
+ return [
271
+ {
272
+ name: "move: the event keeps its id on the other calendar, with a new etag",
273
+ run: async () => {
274
+ const f = await ready();
275
+ if (!f)
276
+ return;
277
+ const moved = await f.move(f.calendar, f.existing.externalId, f.target, {
278
+ etag: f.existing.etag,
279
+ notify: false,
280
+ });
281
+ assert.equal(moved.externalId, f.existing.externalId);
282
+ assert.equal(typeof moved.etag, "string");
283
+ },
284
+ },
285
+ {
286
+ name: "move: an old etag is a conflict, and the event stays",
287
+ run: async () => {
288
+ const f = await ready();
289
+ if (!f)
290
+ return;
291
+ await f.changeBehind();
292
+ await rejectsWith(f.move(f.calendar, f.existing.externalId, f.target, { etag: f.existing.etag, notify: false }), (error) => reasonOf(error) === "event.conflict", "a stale etag is a calendarConflict()");
293
+ },
294
+ },
295
+ {
296
+ name: "move: a retry after a lost answer finds the event on the other calendar",
297
+ run: async () => {
298
+ const f = await ready();
299
+ if (!f)
300
+ return;
301
+ const options = { etag: f.existing.etag, notify: false };
302
+ f.loseNextAnswer();
303
+ await rejectsWith(f.move(f.calendar, f.existing.externalId, f.target, options), (error) => isCliError(error) && error.code === "outcome_unknown", "a lost answer is outcome_unknown");
304
+ const found = await f.move(f.calendar, f.existing.externalId, f.target, options);
305
+ assert.equal(found.externalId, f.existing.externalId);
306
+ },
307
+ },
308
+ ];
309
+ };
@@ -1,11 +1,13 @@
1
1
  import type { EntryInput } from "../../meetings/index.js";
2
2
  import { type CalendarWriter } from "../writer.js";
3
3
  export interface FakeCalendarWriterCall {
4
- write: "create" | "update" | "remove" | "respond";
4
+ write: "create" | "update" | "remove" | "respond" | "move";
5
5
  calendar: string;
6
6
  externalId: string;
7
7
  notify: boolean;
8
8
  scope?: "this" | "series";
9
+ /** A move's calendar. */
10
+ target?: string;
9
11
  }
10
12
  /**
11
13
  * A provider's calendars held in memory, as a writer sees them. Every entry carries an etag that each change moves
@@ -97,5 +97,16 @@ export const fakeCalendarWriter = (options = {}) => {
97
97
  of(calendar.externalId).set(externalId, entry);
98
98
  return answer(entry);
99
99
  },
100
+ async move(calendar, externalId, target, { etag, notify }) {
101
+ calls.push({ write: "move", calendar: calendar.externalId, externalId, notify, target: target.externalId });
102
+ const arrived = of(target.externalId).get(externalId);
103
+ // A retry after a lost answer: the event left this calendar on the first try.
104
+ if (arrived && !of(calendar.externalId).has(externalId))
105
+ return arrived;
106
+ const entry = stamp(current(calendar, externalId, etag));
107
+ of(calendar.externalId).delete(externalId);
108
+ of(target.externalId).set(externalId, entry);
109
+ return answer(entry);
110
+ },
100
111
  };
101
112
  };
@@ -26,8 +26,13 @@ export interface EventDraft {
26
26
  * `startDate` for a timed event, or `startsAt` for an all-day one, switches it: the adapter clears the other kind.
27
27
  */
28
28
  export type EventPatch = Partial<EventDraft>;
29
- /** `this`: the one entry named; `series`: the repeating event it belongs to, named by the series' own id. */
29
+ /**
30
+ * `this`: the one entry named; `series`: the repeating event it belongs to, named by the series' own id. A writer
31
+ * receives only these two: cli-core makes `following` of a series change and a create (`FollowingScope`).
32
+ */
30
33
  export type WriteScope = "this" | "series";
34
+ /** A write's scope as the owner chooses it: `following` is this occurrence and every later one. */
35
+ export type FollowingScope = WriteScope | "following";
31
36
  export type EventResponse = Extract<ParticipantResponse, "accepted" | "tentative" | "declined">;
32
37
  /**
33
38
  * Writes to one provider's calendars. Each change carries the entry's `etag`, sent as the provider's precondition
@@ -59,15 +64,27 @@ export interface CalendarWriter {
59
64
  etag: string;
60
65
  notify: boolean;
61
66
  }): Promise<EntryInput>;
67
+ /**
68
+ * Optional: moves the event, keeping its id, to `target`, another calendar of the same account. `etag` is checked
69
+ * before the move; a provider whose move takes no precondition reads the event first. A retry after
70
+ * `outcome_unknown` that finds the event already on `target` returns it.
71
+ */
72
+ move?(calendar: StoredCalendar, externalId: string, target: StoredCalendar, options: {
73
+ etag: string;
74
+ notify: boolean;
75
+ }): Promise<EntryInput>;
62
76
  }
63
77
  /** Which write a command makes, for `CalendarWrites.extraKeys`. */
64
- export type CalendarWrite = "create" | "update" | "delete" | "respond";
78
+ export type CalendarWrite = "create" | "update" | "delete" | "respond" | "move";
65
79
  /** Mailing the other attendees: an agent may write to the calendar and still not to people (WC-PERM-14's spirit). */
66
80
  export declare const EVENTS_NOTIFY_KEY: PermissionKey;
67
81
  export declare const EVENTS_DELETE_SERIES_KEY: PermissionKey;
68
82
  /** The keys the calendar writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
69
83
  export declare const CALENDAR_WRITE_KEYS: readonly PermissionKey[];
70
- /** Built in, and only ever tightening (WC-PERM-7): one change asks, a whole series is not deleted, nobody is mailed. */
84
+ /**
85
+ * Built in, and only ever tightening (WC-PERM-7). A change that is easy to undo goes ahead, as tg's and drive's writes
86
+ * do; a deletion asks; a whole series is not deleted, and nobody is mailed.
87
+ */
71
88
  export declare const CALENDAR_WRITE_DEFAULTS: Readonly<Record<PermissionKey, Level>>;
72
89
  /** What an adapter throws when the provider refused a write because the entry's `etag` is old (HTTP 412). */
73
90
  export declare const calendarConflict: (message?: string) => CliError;
@@ -4,11 +4,15 @@ export const EVENTS_NOTIFY_KEY = "events.notify";
4
4
  export const EVENTS_DELETE_SERIES_KEY = "events.delete.series";
5
5
  /** The keys the calendar writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
6
6
  export const CALENDAR_WRITE_KEYS = [EVENTS_NOTIFY_KEY, EVENTS_DELETE_SERIES_KEY];
7
- /** Built in, and only ever tightening (WC-PERM-7): one change asks, a whole series is not deleted, nobody is mailed. */
7
+ /**
8
+ * Built in, and only ever tightening (WC-PERM-7). A change that is easy to undo goes ahead, as tg's and drive's writes
9
+ * do; a deletion asks; a whole series is not deleted, and nobody is mailed.
10
+ */
8
11
  export const CALENDAR_WRITE_DEFAULTS = {
9
- "events.create": "ask",
10
- "events.update": "ask",
11
- "events.respond": "ask",
12
+ "events.create": "allow",
13
+ "events.update": "allow",
14
+ "events.respond": "allow",
15
+ "events.move": "allow",
12
16
  "events.delete": "ask",
13
17
  [EVENTS_DELETE_SERIES_KEY]: "deny",
14
18
  [EVENTS_NOTIFY_KEY]: "deny",
@@ -3,7 +3,7 @@ import type { AppIdentity } from "../ports/app.js";
3
3
  import type { Warn } from "../runs/events.js";
4
4
  import { type Asker, type GuardSettings } from "../sends/guard.js";
5
5
  import { type PermissionKey } from "../sends/permissions.js";
6
- import { type CalendarWrite, type CalendarWriter, type EventPatch, type EventResponse, type WriteScope } from "./writer.js";
6
+ import { type CalendarWrite, type CalendarWriter, type EventPatch, type EventResponse } from "./writer.js";
7
7
  /** What a CLI or an MCP host hands the calendar writes for one profile. */
8
8
  export interface CalendarWrites {
9
9
  writer: CalendarWriter;
@@ -46,6 +46,7 @@ export interface WriteContext {
46
46
  id: string;
47
47
  calendar: string;
48
48
  series: string;
49
+ following: string;
49
50
  repeat: string;
50
51
  };
51
52
  }
@@ -82,9 +83,19 @@ export declare const createEvent: (context: WriteContext, calendarReference: str
82
83
  } | null;
83
84
  hint?: string | undefined;
84
85
  }>;
85
- export declare const updateEvent: (context: WriteContext, reference: string, fields: EventFields, options?: {
86
+ interface ScopeChoice {
86
87
  calendar?: string;
87
88
  series?: boolean;
89
+ /** This occurrence and every later one of its series. */
90
+ following?: boolean;
91
+ notify?: boolean;
92
+ }
93
+ export declare const updateEvent: (context: WriteContext, reference: string, fields: EventFields, options?: ScopeChoice & {
94
+ id?: string;
95
+ }) => Promise<object>;
96
+ export declare const deleteEvent: (context: WriteContext, reference: string, options?: ScopeChoice) => Promise<object>;
97
+ export declare const respondToEvent: (context: WriteContext, reference: string, response: EventResponse, options?: {
98
+ calendar?: string;
88
99
  notify?: boolean;
89
100
  }) => Promise<{
90
101
  operation_id: string;
@@ -106,19 +117,14 @@ export declare const updateEvent: (context: WriteContext, reference: string, fie
106
117
  } | null;
107
118
  hint?: string | undefined;
108
119
  }>;
109
- export declare const deleteEvent: (context: WriteContext, reference: string, options?: {
120
+ /**
121
+ * The event, under the same id, to another calendar of the same account. A repeating event moves whole: an
122
+ * occurrence alone cannot change calendar at Google, and moving one copy of a series apart would split it.
123
+ */
124
+ export declare const moveEvent: (context: WriteContext, reference: string, toCalendar: string, options?: {
110
125
  calendar?: string;
111
126
  series?: boolean;
112
127
  notify?: boolean;
113
- }) => Promise<{
114
- operation_id: string;
115
- event: string | null;
116
- scope: WriteScope;
117
- removed: number;
118
- }>;
119
- export declare const respondToEvent: (context: WriteContext, reference: string, response: EventResponse, options?: {
120
- calendar?: string;
121
- notify?: boolean;
122
128
  }) => Promise<{
123
129
  operation_id: string;
124
130
  event: string | null;
@@ -138,4 +144,7 @@ export declare const respondToEvent: (context: WriteContext, reference: string,
138
144
  deleted_at: string | null;
139
145
  } | null;
140
146
  hint?: string | undefined;
147
+ from_calendar: string;
148
+ calendar: string;
141
149
  }>;
150
+ export {};