m365-agent-cli 1.2.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 (92) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +916 -0
  3. package/package.json +50 -0
  4. package/src/cli.ts +100 -0
  5. package/src/commands/auto-reply.ts +182 -0
  6. package/src/commands/calendar.ts +576 -0
  7. package/src/commands/counter.ts +87 -0
  8. package/src/commands/create-event.ts +544 -0
  9. package/src/commands/delegates.ts +286 -0
  10. package/src/commands/delete-event.ts +321 -0
  11. package/src/commands/drafts.ts +502 -0
  12. package/src/commands/files.ts +532 -0
  13. package/src/commands/find.ts +195 -0
  14. package/src/commands/findtime.ts +270 -0
  15. package/src/commands/folders.ts +177 -0
  16. package/src/commands/forward-event.ts +49 -0
  17. package/src/commands/graph-calendar.ts +217 -0
  18. package/src/commands/login.ts +195 -0
  19. package/src/commands/mail.ts +950 -0
  20. package/src/commands/oof.ts +263 -0
  21. package/src/commands/outlook-categories.ts +173 -0
  22. package/src/commands/outlook-graph.ts +880 -0
  23. package/src/commands/planner.ts +1678 -0
  24. package/src/commands/respond.ts +291 -0
  25. package/src/commands/rooms.ts +210 -0
  26. package/src/commands/rules.ts +511 -0
  27. package/src/commands/schedule.ts +109 -0
  28. package/src/commands/send.ts +204 -0
  29. package/src/commands/serve.ts +14 -0
  30. package/src/commands/sharepoint.ts +179 -0
  31. package/src/commands/site-pages.ts +163 -0
  32. package/src/commands/subscribe.ts +103 -0
  33. package/src/commands/subscriptions.ts +29 -0
  34. package/src/commands/suggest.ts +155 -0
  35. package/src/commands/todo.ts +2092 -0
  36. package/src/commands/update-event.ts +608 -0
  37. package/src/commands/update.ts +88 -0
  38. package/src/commands/verify-token.ts +62 -0
  39. package/src/commands/whoami.ts +74 -0
  40. package/src/index.ts +190 -0
  41. package/src/lib/atomic-write.ts +20 -0
  42. package/src/lib/attach-link-spec.test.ts +24 -0
  43. package/src/lib/attach-link-spec.ts +70 -0
  44. package/src/lib/attachments.ts +79 -0
  45. package/src/lib/auth.ts +192 -0
  46. package/src/lib/calendar-range.test.ts +41 -0
  47. package/src/lib/calendar-range.ts +103 -0
  48. package/src/lib/dates.test.ts +74 -0
  49. package/src/lib/dates.ts +137 -0
  50. package/src/lib/delegate-client.test.ts +74 -0
  51. package/src/lib/delegate-client.ts +322 -0
  52. package/src/lib/ews-client.ts +3418 -0
  53. package/src/lib/git-commit.ts +4 -0
  54. package/src/lib/glitchtip-eligibility.ts +220 -0
  55. package/src/lib/glitchtip.ts +253 -0
  56. package/src/lib/global-env.ts +3 -0
  57. package/src/lib/graph-auth.ts +223 -0
  58. package/src/lib/graph-calendar-client.test.ts +118 -0
  59. package/src/lib/graph-calendar-client.ts +112 -0
  60. package/src/lib/graph-client.test.ts +107 -0
  61. package/src/lib/graph-client.ts +1058 -0
  62. package/src/lib/graph-constants.ts +12 -0
  63. package/src/lib/graph-directory.ts +116 -0
  64. package/src/lib/graph-event.ts +134 -0
  65. package/src/lib/graph-schedule.ts +173 -0
  66. package/src/lib/graph-subscriptions.ts +94 -0
  67. package/src/lib/graph-user-path.ts +13 -0
  68. package/src/lib/jwt-utils.ts +34 -0
  69. package/src/lib/markdown.test.ts +21 -0
  70. package/src/lib/markdown.ts +174 -0
  71. package/src/lib/mime-type.ts +106 -0
  72. package/src/lib/oof-client.test.ts +59 -0
  73. package/src/lib/oof-client.ts +122 -0
  74. package/src/lib/outlook-graph-client.test.ts +146 -0
  75. package/src/lib/outlook-graph-client.ts +649 -0
  76. package/src/lib/outlook-master-categories.ts +145 -0
  77. package/src/lib/package-info.ts +59 -0
  78. package/src/lib/places-client.ts +144 -0
  79. package/src/lib/planner-client.ts +1226 -0
  80. package/src/lib/rules-client.ts +178 -0
  81. package/src/lib/sharepoint-client.ts +101 -0
  82. package/src/lib/site-pages-client.ts +73 -0
  83. package/src/lib/todo-client.test.ts +298 -0
  84. package/src/lib/todo-client.ts +1309 -0
  85. package/src/lib/url-validation.ts +40 -0
  86. package/src/lib/utils.ts +45 -0
  87. package/src/lib/webhook-server.ts +51 -0
  88. package/src/test/auth.test.ts +104 -0
  89. package/src/test/cli.integration.test.ts +1083 -0
  90. package/src/test/ews-client.test.ts +268 -0
  91. package/src/test/mocks/index.ts +375 -0
  92. package/src/test/mocks/responses.ts +861 -0
@@ -0,0 +1,576 @@
1
+ import { access, mkdir, writeFile } from 'node:fs/promises';
2
+ import { extname, join } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { resolveAuth } from '../lib/auth.js';
5
+ import {
6
+ businessDaysBackward,
7
+ businessDaysForward,
8
+ calendarDaysBackward,
9
+ calendarDaysForward,
10
+ isWeekRangeKeyword
11
+ } from '../lib/calendar-range.js';
12
+ import { parseDay, parseLocalDate } from '../lib/dates.js';
13
+ import {
14
+ type CalendarAttendee,
15
+ type CalendarEvent,
16
+ getAttachment,
17
+ getAttachments,
18
+ getCalendarEvent,
19
+ getCalendarEvents
20
+ } from '../lib/ews-client.js';
21
+
22
+ function sanitizeFileComponent(name: string): string {
23
+ const s = name.replace(/[/\\?%*:|"<>]/g, '_').trim();
24
+ return s.length > 0 ? s : 'attachment';
25
+ }
26
+
27
+ async function pathExists(p: string): Promise<boolean> {
28
+ try {
29
+ await access(p);
30
+ return true;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ function formatTime(dateStr: string): string {
37
+ const date = parseLocalDate(dateStr);
38
+ return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
39
+ }
40
+
41
+ function formatDate(dateStr: string): string {
42
+ const date = parseLocalDate(dateStr);
43
+ return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
44
+ }
45
+
46
+ /**
47
+ * Convert a local-midnight Date to a UTC ISO string for EWS CalendarView.
48
+ *
49
+ * EWS CalendarView StartDate/EndDate are UTC-datetime strings.
50
+ * CalendarView is exclusive on EndDate, so we set end = next day's local midnight.
51
+ *
52
+ * Example for UTC-5 (EST) on March 15:
53
+ * start = 2024-03-15T00:00 local = 2024-03-15T05:00:00Z
54
+ * end = 2024-03-16T00:00 local = 2024-03-16T05:00:00Z
55
+ */
56
+ function toEWSRange(localMidnight: Date): { start: string; end: string } {
57
+ const start = new Date(localMidnight);
58
+ start.setHours(0, 0, 0, 0);
59
+
60
+ const end = new Date(start);
61
+ end.setDate(end.getDate() + 1);
62
+ end.setHours(0, 0, 0, 0);
63
+
64
+ return { start: start.toISOString(), end: end.toISOString() };
65
+ }
66
+
67
+ function getDateRange(startDay: string, endDay?: string): { start: string; end: string; label: string } {
68
+ const now = new Date();
69
+
70
+ // Handle special range keywords
71
+ switch (startDay.toLowerCase()) {
72
+ case 'week':
73
+ case 'thisweek': {
74
+ const start = new Date(now);
75
+ const dayOfWeek = start.getDay();
76
+ const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // Monday
77
+ start.setDate(start.getDate() + diff);
78
+ start.setHours(0, 0, 0, 0);
79
+ const end = new Date(start);
80
+ end.setDate(end.getDate() + 7); // exclusive end = next Monday midnight
81
+ return { start: start.toISOString(), end: end.toISOString(), label: 'This Week' };
82
+ }
83
+ case 'lastweek': {
84
+ const start = new Date(now);
85
+ const dayOfWeek = start.getDay();
86
+ const diff = dayOfWeek === 0 ? -13 : -6 - dayOfWeek; // Last Monday
87
+ start.setDate(start.getDate() + diff);
88
+ start.setHours(0, 0, 0, 0);
89
+ const end = new Date(start);
90
+ end.setDate(end.getDate() + 7); // exclusive end = next Monday midnight
91
+ return { start: start.toISOString(), end: end.toISOString(), label: 'Last Week' };
92
+ }
93
+ case 'nextweek': {
94
+ const start = new Date(now);
95
+ const dayOfWeek = start.getDay();
96
+ const diff = dayOfWeek === 0 ? 1 : 8 - dayOfWeek; // Next Monday
97
+ start.setDate(start.getDate() + diff);
98
+ start.setHours(0, 0, 0, 0);
99
+ const end = new Date(start);
100
+ end.setDate(end.getDate() + 7); // exclusive end = next Monday midnight
101
+ return { start: start.toISOString(), end: end.toISOString(), label: 'Next Week' };
102
+ }
103
+ }
104
+
105
+ // Single day or start of range
106
+ const startDate = parseDay(startDay, { weekdayDirection: 'previous' });
107
+ startDate.setHours(0, 0, 0, 0);
108
+
109
+ if (endDay) {
110
+ // Date range - use nearestForward for end date
111
+ const endDate = parseDay(endDay, { baseDate: startDate, weekdayDirection: 'nearestForward' });
112
+ endDate.setHours(0, 0, 0, 0);
113
+ // Exclusive end: next day's midnight
114
+ const endExclusive = new Date(endDate);
115
+ endExclusive.setDate(endExclusive.getDate() + 1);
116
+
117
+ const label = `${formatDate(startDate.toISOString())} - ${formatDate(endDate.toISOString())}`;
118
+ return { start: startDate.toISOString(), end: endExclusive.toISOString(), label };
119
+ }
120
+
121
+ // Single day — use toEWSRange for consistent UTC conversion
122
+ const { end: endISO } = toEWSRange(startDate);
123
+
124
+ return {
125
+ start: startDate.toISOString(),
126
+ end: endISO,
127
+ label: formatDate(startDate.toISOString())
128
+ };
129
+ }
130
+
131
+ function parseCalendarRangeInt(v: string | boolean | undefined, flag: string): number | undefined {
132
+ if (v === undefined || v === '') {
133
+ return undefined;
134
+ }
135
+ if (typeof v === 'boolean') {
136
+ return undefined;
137
+ }
138
+ const n = parseInt(String(v), 10);
139
+ if (!Number.isFinite(n) || n < 1 || n > 366) {
140
+ throw new Error(`${flag} must be an integer between 1 and 366`);
141
+ }
142
+ return n;
143
+ }
144
+
145
+ function parseAnchorDateForDynamicRange(startDay: string): Date {
146
+ const startDate = parseDay(startDay, { weekdayDirection: 'previous' });
147
+ startDate.setHours(0, 0, 0, 0);
148
+ return startDate;
149
+ }
150
+
151
+ /**
152
+ * Resolve EWS window when using --days / --business-days / etc., or delegate to getDateRange.
153
+ */
154
+ function resolveCalendarQueryRange(
155
+ startDay: string,
156
+ endDay: string | undefined,
157
+ rangeOpts: {
158
+ days?: number;
159
+ previousDays?: number;
160
+ businessDays?: number;
161
+ previousBusinessDays?: number;
162
+ }
163
+ ): { start: string; end: string; label: string } {
164
+ const { days, previousDays, businessDays, previousBusinessDays } = rangeOpts;
165
+ const modeCount = [days, previousDays, businessDays, previousBusinessDays].filter((x) => x !== undefined).length;
166
+
167
+ if (modeCount === 0) {
168
+ return getDateRange(startDay, endDay);
169
+ }
170
+
171
+ if (modeCount > 1) {
172
+ throw new Error(
173
+ 'Use only one of: --days, --previous-days, --business-days (--busness-days), --previous-business-days'
174
+ );
175
+ }
176
+
177
+ if (endDay !== undefined) {
178
+ throw new Error('Do not pass an end date argument when using --days / --business-days / --previous-days / ...');
179
+ }
180
+
181
+ if (isWeekRangeKeyword(startDay)) {
182
+ throw new Error(
183
+ 'Week keywords (week, thisweek, lastweek, nextweek) cannot be combined with --days / --business-days / ... — use a single day (e.g. today) as start'
184
+ );
185
+ }
186
+
187
+ const anchor = parseAnchorDateForDynamicRange(startDay);
188
+ let result: { start: Date; endExclusive: Date };
189
+ let title: string;
190
+
191
+ if (days !== undefined) {
192
+ result = calendarDaysForward(anchor, days);
193
+ title = `Next ${days} calendar day(s)`;
194
+ } else if (previousDays !== undefined) {
195
+ result = calendarDaysBackward(anchor, previousDays);
196
+ title = `Previous ${previousDays} calendar day(s)`;
197
+ } else if (businessDays !== undefined) {
198
+ result = businessDaysForward(anchor, businessDays);
199
+ title = `Next ${businessDays} business day(s)`;
200
+ } else {
201
+ result = businessDaysBackward(anchor, previousBusinessDays!);
202
+ title = `Previous ${previousBusinessDays} business day(s)`;
203
+ }
204
+
205
+ const lastInclusive = new Date(result.endExclusive);
206
+ lastInclusive.setDate(lastInclusive.getDate() - 1);
207
+
208
+ return {
209
+ start: result.start.toISOString(),
210
+ end: result.endExclusive.toISOString(),
211
+ label: `${title} (${formatDate(result.start.toISOString())} – ${formatDate(lastInclusive.toISOString())})`
212
+ };
213
+ }
214
+
215
+ function getResponseIcon(response: string): string {
216
+ switch (response) {
217
+ case 'Accepted':
218
+ return 'āœ“';
219
+ case 'Declined':
220
+ return 'āœ—';
221
+ case 'TentativelyAccepted':
222
+ return '?';
223
+ case 'NotResponded':
224
+ return 'Ā·';
225
+ case 'Organizer':
226
+ return 'ā˜…';
227
+ default:
228
+ return 'Ā·';
229
+ }
230
+ }
231
+
232
+ function displayEvent(event: CalendarEvent, verbose: boolean): void {
233
+ const startTime = formatTime(event.Start.DateTime);
234
+ const endTime = formatTime(event.End.DateTime);
235
+ const location = event.Location?.DisplayName || '';
236
+ const cancelled = event.IsCancelled ? ' [CANCELLED]' : '';
237
+
238
+ if (event.IsAllDay) {
239
+ console.log(` šŸ“… All day: ${event.Subject}${cancelled}`);
240
+ } else {
241
+ console.log(` ${startTime} - ${endTime}: ${event.Subject}${cancelled}`);
242
+ }
243
+
244
+ if (location) {
245
+ console.log(` šŸ“ ${location}`);
246
+ }
247
+
248
+ if (event.HasAttachments) {
249
+ console.log(` šŸ“Ž Has attachments`);
250
+ }
251
+
252
+ if (verbose) {
253
+ // Show organizer if not self
254
+ if (!event.IsOrganizer && event.Organizer?.EmailAddress?.Name) {
255
+ console.log(` šŸ‘¤ Organizer: ${event.Organizer.EmailAddress.Name}`);
256
+ }
257
+
258
+ // Show recurrence info
259
+ if (event.RecurrenceDescription) {
260
+ console.log(` šŸ” ${event.RecurrenceDescription}`);
261
+ } else if (event.FirstOccurrence || event.LastOccurrence) {
262
+ // Series bounds without full description
263
+ const first = event.FirstOccurrence ? ` from ${formatDate(event.FirstOccurrence.Start)}` : '';
264
+ const last = event.LastOccurrence ? ` until ${formatDate(event.LastOccurrence.Start)}` : '';
265
+ if (first || last) {
266
+ console.log(` šŸ” Recurring series${first}${last}`);
267
+ }
268
+ }
269
+
270
+ // Show attendees
271
+ if (event.Attendees && event.Attendees.length > 0) {
272
+ const attendeeList = event.Attendees.map(
273
+ (a: CalendarAttendee) => `${getResponseIcon(a.Status.Response)} ${a.EmailAddress.Name}`
274
+ ).join(', ');
275
+ console.log(` šŸ‘„ ${attendeeList}`);
276
+ }
277
+
278
+ // Show categories
279
+ if (event.Categories && event.Categories.length > 0) {
280
+ console.log(` šŸ·ļø ${event.Categories.join(', ')}`);
281
+ }
282
+
283
+ // Show body preview if available
284
+ if (event.BodyPreview) {
285
+ const preview = event.BodyPreview.substring(0, 80).replace(/\n/g, ' ');
286
+ console.log(` šŸ“ ${preview}${event.BodyPreview.length > 80 ? '...' : ''}`);
287
+ }
288
+
289
+ // Show recurrence series info if available
290
+ if (event.FirstOccurrence || event.LastOccurrence) {
291
+ const first = event.FirstOccurrence ? event.FirstOccurrence.Start.substring(0, 10) : 'unknown';
292
+ const last = event.LastOccurrence ? event.LastOccurrence.Start.substring(0, 10) : 'unknown';
293
+ console.log(` šŸ”„ Series: First: ${first}, Last: ${last}`);
294
+ }
295
+ if (event.ModifiedOccurrences && event.ModifiedOccurrences.length > 0) {
296
+ console.log(
297
+ ` āœļø Modified exceptions: ${event.ModifiedOccurrences.map((o) => o.OriginalStart.substring(0, 10)).join(', ')}`
298
+ );
299
+ }
300
+ if (event.DeletedOccurrences && event.DeletedOccurrences.length > 0) {
301
+ console.log(
302
+ ` šŸ—‘ļø Deleted exceptions: ${event.DeletedOccurrences.map((o) => o.Start.substring(0, 10)).join(', ')}`
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ export const calendarCommand = new Command('calendar')
309
+ .description('View calendar events')
310
+ .argument(
311
+ '[start]',
312
+ 'Start day: today, yesterday, tomorrow, monday-sunday, week, lastweek, nextweek, or YYYY-MM-DD',
313
+ 'today'
314
+ )
315
+ .argument('[end]', 'End day for range (optional)')
316
+ .option('-v, --verbose', 'Show attendees and more details')
317
+ .option('--list-attachments <eventId>', 'List file and link attachments on an event by id')
318
+ .option('--download-attachments <eventId>', 'Download file attachments; links saved as .url shortcuts')
319
+ .option('-o, --output <dir>', 'Output directory for --download-attachments', '.')
320
+ .option('--force', 'Overwrite existing files when downloading calendar attachments')
321
+ .option('--json', 'Output as JSON')
322
+ .option('--token <token>', 'Use a specific token')
323
+ .option('--identity <name>', 'Use a specific authentication identity (default: default)')
324
+ .option('--mailbox <email>', 'Delegated or shared mailbox calendar')
325
+ .option(
326
+ '--days <n>',
327
+ 'Show N consecutive calendar days forward from start (includes start day; not for use with week keywords)'
328
+ )
329
+ .option('--previous-days <n>', 'Show N consecutive calendar days ending on start day')
330
+ .option(
331
+ '--business-days <n>',
332
+ 'Show N weekdays (Mon–Fri) forward from start (skip weekend; use for ā€œnext N working daysā€)'
333
+ )
334
+ .option('--busness-days <n>', 'Same as --business-days (common typo)')
335
+ .option('--previous-business-days <n>', 'Show N weekdays backward ending on the last weekday on or before start')
336
+ .action(
337
+ async (
338
+ startDay: string,
339
+ endDay: string | undefined,
340
+ options: {
341
+ json?: boolean;
342
+ token?: string;
343
+ identity?: string;
344
+ verbose?: boolean;
345
+ mailbox?: string;
346
+ listAttachments?: string;
347
+ downloadAttachments?: string;
348
+ output: string;
349
+ force?: boolean;
350
+ days?: string;
351
+ previousDays?: string;
352
+ businessDays?: string;
353
+ busnessDays?: string;
354
+ previousBusinessDays?: string;
355
+ }
356
+ ) => {
357
+ const authResult = await resolveAuth({
358
+ token: options.token,
359
+ identity: options.identity
360
+ });
361
+
362
+ if (!authResult.success) {
363
+ if (options.json) {
364
+ console.log(JSON.stringify({ error: authResult.error }, null, 2));
365
+ } else {
366
+ console.error(`Error: ${authResult.error}`);
367
+ console.error('\nCheck your .env file for EWS_CLIENT_ID and EWS_REFRESH_TOKEN.');
368
+ }
369
+ process.exit(1);
370
+ }
371
+
372
+ const token = authResult.token!;
373
+ const mailbox = options.mailbox;
374
+
375
+ if (options.listAttachments) {
376
+ const eventId = options.listAttachments.trim();
377
+ const eventRes = await getCalendarEvent(token, eventId, mailbox);
378
+ if (!eventRes.ok || !eventRes.data) {
379
+ console.error(`Error: ${eventRes.error?.message || 'Event not found'}`);
380
+ process.exit(1);
381
+ }
382
+ const attsRes = await getAttachments(token, eventId, mailbox);
383
+ if (!attsRes.ok || !attsRes.data) {
384
+ console.error(`Error: ${attsRes.error?.message || 'Failed to list attachments'}`);
385
+ process.exit(1);
386
+ }
387
+ const atts = attsRes.data.value.filter((a) => !a.IsInline);
388
+ if (options.json) {
389
+ console.log(JSON.stringify({ eventId, subject: eventRes.data.Subject, attachments: atts }, null, 2));
390
+ return;
391
+ }
392
+ console.log(`\nAttachments — ${eventRes.data.Subject}`);
393
+ console.log('─'.repeat(40));
394
+ if (atts.length === 0) {
395
+ console.log(' (none)');
396
+ } else {
397
+ for (const a of atts) {
398
+ if (a.Kind === 'reference' && a.AttachLongPathName) {
399
+ console.log(` šŸ”— ${a.Name}`);
400
+ console.log(` ${a.AttachLongPathName}`);
401
+ } else {
402
+ const sizeKB = Math.round(a.Size / 1024);
403
+ console.log(` šŸ“„ ${a.Name} (${sizeKB} KB)`);
404
+ }
405
+ }
406
+ }
407
+ console.log();
408
+ return;
409
+ }
410
+
411
+ if (options.downloadAttachments) {
412
+ const eventId = options.downloadAttachments.trim();
413
+ const eventRes = await getCalendarEvent(token, eventId, mailbox);
414
+ if (!eventRes.ok || !eventRes.data) {
415
+ console.error(`Error: ${eventRes.error?.message || 'Event not found'}`);
416
+ process.exit(1);
417
+ }
418
+ if (!eventRes.data.HasAttachments) {
419
+ console.log('This event has no attachments.');
420
+ return;
421
+ }
422
+ const attsRes = await getAttachments(token, eventId, mailbox);
423
+ if (!attsRes.ok || !attsRes.data) {
424
+ console.error(`Error: ${attsRes.error?.message || 'Failed to fetch attachments'}`);
425
+ process.exit(1);
426
+ }
427
+ const attachments = attsRes.data.value.filter((a) => !a.IsInline);
428
+ if (attachments.length === 0) {
429
+ console.log('No downloadable attachments (inline-only).');
430
+ return;
431
+ }
432
+ await mkdir(options.output, { recursive: true });
433
+ const usedPaths = new Set<string>();
434
+ console.log(`\nDownloading ${attachments.length} attachment(s) to ${options.output}/\n`);
435
+ for (const att of attachments) {
436
+ if (att.Kind === 'reference' || att.AttachLongPathName) {
437
+ let url = att.AttachLongPathName;
438
+ if (!url) {
439
+ const full = await getAttachment(token, eventId, att.Id, mailbox);
440
+ if (full.ok && full.data?.AttachLongPathName) {
441
+ url = full.data.AttachLongPathName;
442
+ }
443
+ }
444
+ if (!url) {
445
+ console.error(` Failed to resolve link: ${att.Name}`);
446
+ continue;
447
+ }
448
+ const base = sanitizeFileComponent(att.Name || 'link');
449
+ let filePath = join(options.output, `${base}.url`);
450
+ let counter = 1;
451
+ while (usedPaths.has(filePath) || (!options.force && (await pathExists(filePath)))) {
452
+ filePath = join(options.output, `${base} (${counter}).url`);
453
+ counter++;
454
+ }
455
+ usedPaths.add(filePath);
456
+ const content = `[InternetShortcut]\r\nURL=${url}\r\n`;
457
+ await writeFile(filePath, content, 'utf8');
458
+ console.log(` āœ“ ${filePath.split(/[\\/]/).pop()} (link)`);
459
+ continue;
460
+ }
461
+
462
+ const fullAtt = await getAttachment(token, eventId, att.Id, mailbox);
463
+ if (!fullAtt.ok || !fullAtt.data?.ContentBytes) {
464
+ console.error(` Failed to download: ${att.Name}`);
465
+ continue;
466
+ }
467
+ const content = Buffer.from(fullAtt.data.ContentBytes, 'base64');
468
+ let filePath = join(options.output, att.Name);
469
+ let counter = 1;
470
+ while (true) {
471
+ if (usedPaths.has(filePath)) {
472
+ const ext = extname(att.Name);
473
+ const base = att.Name.slice(0, att.Name.length - ext.length);
474
+ filePath = join(options.output, `${base} (${counter})${ext}`);
475
+ counter++;
476
+ continue;
477
+ }
478
+ if (!options.force) {
479
+ try {
480
+ await access(filePath);
481
+ const ext = extname(att.Name);
482
+ const base = att.Name.slice(0, att.Name.length - ext.length);
483
+ filePath = join(options.output, `${base} (${counter})${ext}`);
484
+ counter++;
485
+ continue;
486
+ } catch {
487
+ // missing — ok
488
+ }
489
+ }
490
+ break;
491
+ }
492
+ usedPaths.add(filePath);
493
+ await writeFile(filePath, content);
494
+ const sizeKB = Math.round(content.length / 1024);
495
+ console.log(` āœ“ ${filePath.split(/[\\/]/).pop()} (${sizeKB} KB)`);
496
+ }
497
+ console.log('\nDone.\n');
498
+ return;
499
+ }
500
+
501
+ let start: string;
502
+ let end: string;
503
+ let label: string;
504
+ try {
505
+ const resolved = resolveCalendarQueryRange(startDay, endDay, {
506
+ days: parseCalendarRangeInt(options.days, '--days'),
507
+ previousDays: parseCalendarRangeInt(options.previousDays, '--previous-days'),
508
+ businessDays: parseCalendarRangeInt(options.businessDays ?? options.busnessDays, '--business-days'),
509
+ previousBusinessDays: parseCalendarRangeInt(options.previousBusinessDays, '--previous-business-days')
510
+ });
511
+ start = resolved.start;
512
+ end = resolved.end;
513
+ label = resolved.label;
514
+ } catch (err) {
515
+ const message = err instanceof Error ? err.message : String(err);
516
+ if (options.json) {
517
+ console.log(JSON.stringify({ error: message }, null, 2));
518
+ } else {
519
+ console.error(`Error: ${message}`);
520
+ }
521
+ process.exit(1);
522
+ }
523
+
524
+ const result = await getCalendarEvents(authResult.token!, start, end, options.mailbox);
525
+
526
+ if (!result.ok || !result.data) {
527
+ if (options.json) {
528
+ console.log(JSON.stringify({ error: result.error?.message || 'Failed to fetch events' }, null, 2));
529
+ } else {
530
+ console.error(`Error: ${result.error?.message || 'Failed to fetch events'}`);
531
+ }
532
+ process.exit(1);
533
+ }
534
+
535
+ const events = result.data.filter((e) => !e.IsCancelled);
536
+
537
+ if (options.json) {
538
+ console.log(JSON.stringify(events, null, 2));
539
+ return;
540
+ }
541
+
542
+ console.log(`\nšŸ“† Calendar for ${label}${options.mailbox ? ` — ${options.mailbox}` : ''}`);
543
+ console.log('─'.repeat(40));
544
+
545
+ if (events.length === 0) {
546
+ console.log(' No events scheduled.');
547
+ } else {
548
+ // Group by date for multi-day ranges
549
+ const eventsByDate = new Map<string, CalendarEvent[]>();
550
+ for (const event of events) {
551
+ const localDate = parseLocalDate(event.Start.DateTime);
552
+ const dateKey = `${localDate.getFullYear()}-${String(localDate.getMonth() + 1).padStart(2, '0')}-${String(localDate.getDate()).padStart(2, '0')}`;
553
+ if (!eventsByDate.has(dateKey)) {
554
+ eventsByDate.set(dateKey, []);
555
+ }
556
+ eventsByDate.get(dateKey)?.push(event);
557
+ }
558
+
559
+ // Check if multiple days
560
+ if (eventsByDate.size > 1) {
561
+ for (const [dateKey, dayEvents] of eventsByDate) {
562
+ const dayLabel = formatDate(dateKey);
563
+ console.log(`\n ${dayLabel}`);
564
+ for (const event of dayEvents) {
565
+ displayEvent(event, options.verbose ?? false);
566
+ }
567
+ }
568
+ } else {
569
+ for (const event of events) {
570
+ displayEvent(event, options.verbose ?? false);
571
+ }
572
+ }
573
+ }
574
+ console.log();
575
+ }
576
+ );
@@ -0,0 +1,87 @@
1
+ import { Command } from 'commander';
2
+ import { parseDay, parseTimeToDate } from '../lib/dates.js';
3
+ import { resolveGraphAuth } from '../lib/graph-auth.js';
4
+ import { proposeNewTime } from '../lib/graph-event.js';
5
+ import { checkReadOnly } from '../lib/utils.js';
6
+
7
+ export const counterCommand = new Command('counter')
8
+ .description('Propose a new time for a calendar event')
9
+ .alias('propose-new-time')
10
+ .argument('<eventId>', 'The ID of the event')
11
+ .argument('<start>', 'Proposed start time (e.g., 13:00, 1pm)')
12
+ .argument('<end>', 'Proposed end time (e.g., 14:00, 2pm)')
13
+ .option('--day <day>', 'Day for the proposed time (today, tomorrow, YYYY-MM-DD)', 'today')
14
+ .option('--token <token>', 'Use a specific token')
15
+ .option('--identity <name>', 'Graph token cache identity (default: default)')
16
+ .option('--user <email>', 'Mailbox whose calendar contains the event (delegation)')
17
+ .action(
18
+ async (
19
+ eventId: string,
20
+ startTime: string,
21
+ endTime: string,
22
+ options: { day: string; token?: string; json?: boolean; identity?: string; user?: string },
23
+ cmd: any
24
+ ) => {
25
+ checkReadOnly(cmd);
26
+ const authResult = await resolveGraphAuth({ token: options.token, identity: options.identity });
27
+ if (!authResult.success) {
28
+ console.error(`Error: ${authResult.error}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ // Parse dates and times
33
+ let baseDate: Date;
34
+ try {
35
+ baseDate = parseDay(options.day, { throwOnInvalid: true });
36
+ } catch (err) {
37
+ const message = err instanceof Error ? err.message : 'Invalid day value';
38
+ console.error(`Error: ${message}`);
39
+ process.exit(1);
40
+ }
41
+
42
+ let start: Date;
43
+ try {
44
+ start = parseTimeToDate(startTime, baseDate, { throwOnInvalid: true });
45
+ } catch (err) {
46
+ const message = err instanceof Error ? err.message : 'Invalid start time';
47
+ console.error(`Error: ${message}`);
48
+ process.exit(1);
49
+ }
50
+
51
+ let end: Date;
52
+ try {
53
+ end = parseTimeToDate(endTime, baseDate, { throwOnInvalid: true });
54
+ } catch (err) {
55
+ const message = err instanceof Error ? err.message : 'Invalid end time';
56
+ console.error(`Error: ${message}`);
57
+ process.exit(1);
58
+ }
59
+
60
+ // The Graph API expects times with the time zone context. For simplicity, we can pass
61
+ // the ISO 8601 string and UTC as the time zone.
62
+ const startDateTime = start.toISOString();
63
+ const endDateTime = end.toISOString();
64
+ const timeZone = 'UTC';
65
+
66
+ console.log(`Proposing new time for event...`);
67
+ console.log(` Event ID: ${eventId}`);
68
+ console.log(` Proposed Start: ${start.toLocaleString()}`);
69
+ console.log(` Proposed End: ${end.toLocaleString()}`);
70
+
71
+ const response = await proposeNewTime({
72
+ token: authResult.token!,
73
+ eventId,
74
+ startDateTime,
75
+ endDateTime,
76
+ timeZone,
77
+ user: options.user
78
+ });
79
+
80
+ if (!response.ok) {
81
+ console.error(`\nError: ${response.error?.message || 'Failed to propose new time'}`);
82
+ process.exit(1);
83
+ }
84
+
85
+ console.log('\n\u2713 Successfully proposed a new time for the event.');
86
+ }
87
+ );