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.
- package/LICENSE +22 -0
- package/README.md +916 -0
- package/package.json +50 -0
- package/src/cli.ts +100 -0
- package/src/commands/auto-reply.ts +182 -0
- package/src/commands/calendar.ts +576 -0
- package/src/commands/counter.ts +87 -0
- package/src/commands/create-event.ts +544 -0
- package/src/commands/delegates.ts +286 -0
- package/src/commands/delete-event.ts +321 -0
- package/src/commands/drafts.ts +502 -0
- package/src/commands/files.ts +532 -0
- package/src/commands/find.ts +195 -0
- package/src/commands/findtime.ts +270 -0
- package/src/commands/folders.ts +177 -0
- package/src/commands/forward-event.ts +49 -0
- package/src/commands/graph-calendar.ts +217 -0
- package/src/commands/login.ts +195 -0
- package/src/commands/mail.ts +950 -0
- package/src/commands/oof.ts +263 -0
- package/src/commands/outlook-categories.ts +173 -0
- package/src/commands/outlook-graph.ts +880 -0
- package/src/commands/planner.ts +1678 -0
- package/src/commands/respond.ts +291 -0
- package/src/commands/rooms.ts +210 -0
- package/src/commands/rules.ts +511 -0
- package/src/commands/schedule.ts +109 -0
- package/src/commands/send.ts +204 -0
- package/src/commands/serve.ts +14 -0
- package/src/commands/sharepoint.ts +179 -0
- package/src/commands/site-pages.ts +163 -0
- package/src/commands/subscribe.ts +103 -0
- package/src/commands/subscriptions.ts +29 -0
- package/src/commands/suggest.ts +155 -0
- package/src/commands/todo.ts +2092 -0
- package/src/commands/update-event.ts +608 -0
- package/src/commands/update.ts +88 -0
- package/src/commands/verify-token.ts +62 -0
- package/src/commands/whoami.ts +74 -0
- package/src/index.ts +190 -0
- package/src/lib/atomic-write.ts +20 -0
- package/src/lib/attach-link-spec.test.ts +24 -0
- package/src/lib/attach-link-spec.ts +70 -0
- package/src/lib/attachments.ts +79 -0
- package/src/lib/auth.ts +192 -0
- package/src/lib/calendar-range.test.ts +41 -0
- package/src/lib/calendar-range.ts +103 -0
- package/src/lib/dates.test.ts +74 -0
- package/src/lib/dates.ts +137 -0
- package/src/lib/delegate-client.test.ts +74 -0
- package/src/lib/delegate-client.ts +322 -0
- package/src/lib/ews-client.ts +3418 -0
- package/src/lib/git-commit.ts +4 -0
- package/src/lib/glitchtip-eligibility.ts +220 -0
- package/src/lib/glitchtip.ts +253 -0
- package/src/lib/global-env.ts +3 -0
- package/src/lib/graph-auth.ts +223 -0
- package/src/lib/graph-calendar-client.test.ts +118 -0
- package/src/lib/graph-calendar-client.ts +112 -0
- package/src/lib/graph-client.test.ts +107 -0
- package/src/lib/graph-client.ts +1058 -0
- package/src/lib/graph-constants.ts +12 -0
- package/src/lib/graph-directory.ts +116 -0
- package/src/lib/graph-event.ts +134 -0
- package/src/lib/graph-schedule.ts +173 -0
- package/src/lib/graph-subscriptions.ts +94 -0
- package/src/lib/graph-user-path.ts +13 -0
- package/src/lib/jwt-utils.ts +34 -0
- package/src/lib/markdown.test.ts +21 -0
- package/src/lib/markdown.ts +174 -0
- package/src/lib/mime-type.ts +106 -0
- package/src/lib/oof-client.test.ts +59 -0
- package/src/lib/oof-client.ts +122 -0
- package/src/lib/outlook-graph-client.test.ts +146 -0
- package/src/lib/outlook-graph-client.ts +649 -0
- package/src/lib/outlook-master-categories.ts +145 -0
- package/src/lib/package-info.ts +59 -0
- package/src/lib/places-client.ts +144 -0
- package/src/lib/planner-client.ts +1226 -0
- package/src/lib/rules-client.ts +178 -0
- package/src/lib/sharepoint-client.ts +101 -0
- package/src/lib/site-pages-client.ts +73 -0
- package/src/lib/todo-client.test.ts +298 -0
- package/src/lib/todo-client.ts +1309 -0
- package/src/lib/url-validation.ts +40 -0
- package/src/lib/utils.ts +45 -0
- package/src/lib/webhook-server.ts +51 -0
- package/src/test/auth.test.ts +104 -0
- package/src/test/cli.integration.test.ts +1083 -0
- package/src/test/ews-client.test.ts +268 -0
- package/src/test/mocks/index.ts +375 -0
- package/src/test/mocks/responses.ts +861 -0
package/src/lib/auth.ts
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { atomicWriteUtf8File } from './atomic-write.js';
|
|
5
|
+
import { getJwtExpiration, getMicrosoftTenantPathSegment, isValidJwtStructure } from './jwt-utils.js';
|
|
6
|
+
|
|
7
|
+
export interface AuthResult {
|
|
8
|
+
success: boolean;
|
|
9
|
+
token?: string;
|
|
10
|
+
error?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CachedToken {
|
|
14
|
+
accessToken: string;
|
|
15
|
+
refreshToken: string;
|
|
16
|
+
expiresAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function assertCachedToken(data: unknown): CachedToken {
|
|
20
|
+
if (!data || typeof data !== 'object') throw new Error('invalid token cache');
|
|
21
|
+
const o = data as Record<string, unknown>;
|
|
22
|
+
if (typeof o.accessToken !== 'string' || o.accessToken.length > 100_000) throw new Error('invalid token cache');
|
|
23
|
+
if (typeof o.refreshToken !== 'string' || o.refreshToken.length > 100_000) throw new Error('invalid token cache');
|
|
24
|
+
if (typeof o.expiresAt !== 'number' || !Number.isFinite(o.expiresAt)) throw new Error('invalid token cache');
|
|
25
|
+
return { accessToken: o.accessToken, refreshToken: o.refreshToken, expiresAt: o.expiresAt };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Security model: cache file stores bearer/refresh tokens and must be owner-only.
|
|
29
|
+
// Directory is created as 0700 and file writes enforce 0600 to satisfy least-privilege.
|
|
30
|
+
// The cache path is anchored to a fixed, local per-user directory under homedir();
|
|
31
|
+
// network values (token contents) are written only as file data, never used to select
|
|
32
|
+
// an arbitrary write location.
|
|
33
|
+
const TOKEN_CACHE_FILE_TEMPLATE = join(homedir(), '.config', 'm365-agent-cli', 'token-cache-{identity}.json');
|
|
34
|
+
const OLD_TOKEN_CACHE_FILE_TEMPLATE = join(homedir(), '.config', 'clippy', 'token-cache-{identity}.json');
|
|
35
|
+
|
|
36
|
+
async function migrateTokenCache(identity: string): Promise<void> {
|
|
37
|
+
const TOKEN_CACHE_FILE = TOKEN_CACHE_FILE_TEMPLATE.replace('{identity}', identity);
|
|
38
|
+
const OLD_TOKEN_CACHE_FILE = OLD_TOKEN_CACHE_FILE_TEMPLATE.replace('{identity}', identity);
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const newStats = await stat(TOKEN_CACHE_FILE).catch(() => null);
|
|
42
|
+
if (!newStats) {
|
|
43
|
+
const oldStats = await stat(OLD_TOKEN_CACHE_FILE).catch(() => null);
|
|
44
|
+
if (oldStats) {
|
|
45
|
+
const dir = join(homedir(), '.config', 'm365-agent-cli');
|
|
46
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
47
|
+
await rename(OLD_TOKEN_CACHE_FILE, TOKEN_CACHE_FILE);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} catch (_err) {
|
|
51
|
+
// Ignore migration errors
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function loadCachedToken(identity: string): Promise<CachedToken | null> {
|
|
56
|
+
await migrateTokenCache(identity);
|
|
57
|
+
try {
|
|
58
|
+
const TOKEN_CACHE_FILE = TOKEN_CACHE_FILE_TEMPLATE.replace('{identity}', identity);
|
|
59
|
+
const data = await readFile(TOKEN_CACHE_FILE, 'utf-8');
|
|
60
|
+
return assertCachedToken(JSON.parse(data));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function saveCachedToken(identity: string, token: CachedToken): Promise<void> {
|
|
67
|
+
try {
|
|
68
|
+
const safe = assertCachedToken(token);
|
|
69
|
+
const TOKEN_CACHE_FILE = TOKEN_CACHE_FILE_TEMPLATE.replace('{identity}', identity);
|
|
70
|
+
await atomicWriteUtf8File(TOKEN_CACHE_FILE, JSON.stringify(safe, null, 2), 0o600);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
console.error(`Failed to write token cache for identity '${identity}':`, err instanceof Error ? err.message : err);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function refreshAccessToken(clientId: string, refreshToken: string, tenant: string): Promise<CachedToken> {
|
|
77
|
+
const scopes = [
|
|
78
|
+
'https://outlook.office365.com/EWS.AccessAsUser.All offline_access',
|
|
79
|
+
'https://outlook.office365.com/.default offline_access'
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
let lastError = '';
|
|
83
|
+
|
|
84
|
+
for (const scope of scopes) {
|
|
85
|
+
const response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
88
|
+
body: new URLSearchParams({
|
|
89
|
+
client_id: clientId,
|
|
90
|
+
grant_type: 'refresh_token',
|
|
91
|
+
refresh_token: refreshToken,
|
|
92
|
+
scope
|
|
93
|
+
}).toString()
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const json = (await response.json()) as {
|
|
97
|
+
access_token?: string;
|
|
98
|
+
refresh_token?: string;
|
|
99
|
+
expires_in?: number;
|
|
100
|
+
error?: string;
|
|
101
|
+
error_description?: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
if (response.ok && json.access_token) {
|
|
105
|
+
const accessToken = json.access_token;
|
|
106
|
+
|
|
107
|
+
// Refuse to cache tokens that are not well-formed JWTs
|
|
108
|
+
if (!isValidJwtStructure(accessToken)) {
|
|
109
|
+
throw new Error('OAuth server returned an invalid token structure — refusing to cache');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const expiresAt = getJwtExpiration(accessToken) ?? Date.now() + (json.expires_in || 3600) * 1000;
|
|
113
|
+
if (expiresAt <= Date.now()) {
|
|
114
|
+
throw new Error('OAuth server returned an already-expired token — refusing to cache');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
accessToken,
|
|
119
|
+
refreshToken: json.refresh_token || refreshToken,
|
|
120
|
+
expiresAt
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
lastError = [json.error, json.error_description].filter(Boolean).join(': ') || `HTTP ${response.status}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
throw new Error(`Token refresh failed: ${lastError}`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function resolveAuth(options?: { token?: string; identity?: string }): Promise<AuthResult> {
|
|
131
|
+
if (options?.token) {
|
|
132
|
+
return { success: true, token: options.token };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const clientId = process.env.EWS_CLIENT_ID;
|
|
137
|
+
const envRefreshToken = process.env.EWS_REFRESH_TOKEN;
|
|
138
|
+
|
|
139
|
+
if (!clientId || !envRefreshToken) {
|
|
140
|
+
return {
|
|
141
|
+
success: false,
|
|
142
|
+
error: 'Missing EWS_CLIENT_ID or EWS_REFRESH_TOKEN in environment. Check your .env file.'
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const identity = options?.identity || 'default';
|
|
147
|
+
|
|
148
|
+
// Validate identity to prevent path traversal
|
|
149
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(identity)) {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
error: 'Invalid identity name. Only alphanumeric characters, hyphens, and underscores are allowed.'
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tenant = getMicrosoftTenantPathSegment();
|
|
157
|
+
|
|
158
|
+
// Check cached token
|
|
159
|
+
const cached = await loadCachedToken(identity);
|
|
160
|
+
if (cached && cached.expiresAt > Date.now() + 60_000) {
|
|
161
|
+
// Guard against corrupted cache: validate JWT structure before returning
|
|
162
|
+
if (!isValidJwtStructure(cached.accessToken)) {
|
|
163
|
+
// Treat a malformed cached token as if there were no cache
|
|
164
|
+
} else {
|
|
165
|
+
return { success: true, token: cached.accessToken };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Refresh - try cached refresh token first (may have been rotated), then .env
|
|
170
|
+
const refreshTokens = [...new Set([cached?.refreshToken, envRefreshToken].filter((t): t is string => !!t))];
|
|
171
|
+
|
|
172
|
+
for (const rt of refreshTokens) {
|
|
173
|
+
try {
|
|
174
|
+
const result = await refreshAccessToken(clientId, rt, tenant);
|
|
175
|
+
await saveCachedToken(identity, result);
|
|
176
|
+
return { success: true, token: result.accessToken };
|
|
177
|
+
} catch {
|
|
178
|
+
// Try next
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
success: false,
|
|
184
|
+
error: 'Token refresh failed. You may need to update EWS_REFRESH_TOKEN in .env.'
|
|
185
|
+
};
|
|
186
|
+
} catch (err) {
|
|
187
|
+
return {
|
|
188
|
+
success: false,
|
|
189
|
+
error: err instanceof Error ? err.message : 'Authentication failed'
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
businessDaysBackward,
|
|
4
|
+
businessDaysForward,
|
|
5
|
+
calendarDaysBackward,
|
|
6
|
+
calendarDaysForward
|
|
7
|
+
} from './calendar-range.js';
|
|
8
|
+
|
|
9
|
+
describe('calendar-range', () => {
|
|
10
|
+
test('calendarDaysForward: 3 days from Thu', () => {
|
|
11
|
+
const anchor = new Date(2026, 3, 2); // Thu Apr 2 2026
|
|
12
|
+
const { start, endExclusive } = calendarDaysForward(anchor, 3);
|
|
13
|
+
expect(start.getDate()).toBe(2);
|
|
14
|
+
expect(endExclusive.getDate()).toBe(5); // exclusive end = Apr 5 00:00
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('calendarDaysBackward: 3 days ending Wed', () => {
|
|
18
|
+
const anchor = new Date(2026, 3, 8); // Wed Apr 8
|
|
19
|
+
const { start, endExclusive } = calendarDaysBackward(anchor, 3);
|
|
20
|
+
expect(start.getDate()).toBe(6);
|
|
21
|
+
expect(endExclusive.getDate()).toBe(9);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('businessDaysForward: 5 days from Thu skips weekend', () => {
|
|
25
|
+
const anchor = new Date(2026, 3, 2); // Thu
|
|
26
|
+
const { start, endExclusive } = businessDaysForward(anchor, 5);
|
|
27
|
+
expect(start.getDay()).toBe(4); // Thu
|
|
28
|
+
const last = new Date(endExclusive);
|
|
29
|
+
last.setDate(last.getDate() - 1);
|
|
30
|
+
expect(last.getDay()).toBe(3); // Wed next week
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('businessDaysBackward: 5 days ending Wed', () => {
|
|
34
|
+
const anchor = new Date(2026, 3, 8); // Wed
|
|
35
|
+
const { start, endExclusive } = businessDaysBackward(anchor, 5);
|
|
36
|
+
expect(start.getDate()).toBe(2); // Thu prior week
|
|
37
|
+
const last = new Date(endExclusive);
|
|
38
|
+
last.setDate(last.getDate() - 1);
|
|
39
|
+
expect(last.getDate()).toBe(8);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Helpers for calendar date windows (business days vs calendar days). */
|
|
2
|
+
|
|
3
|
+
export function isWeekend(d: Date): boolean {
|
|
4
|
+
const day = d.getDay();
|
|
5
|
+
return day === 0 || day === 6;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const WEEK_KEYWORDS = new Set(['week', 'thisweek', 'lastweek', 'nextweek']);
|
|
9
|
+
|
|
10
|
+
export function isWeekRangeKeyword(startDay: string): boolean {
|
|
11
|
+
return WEEK_KEYWORDS.has(startDay.toLowerCase().trim());
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Forward N calendar days inclusive from anchor (first day = anchor at local midnight). */
|
|
15
|
+
export function calendarDaysForward(anchor: Date, n: number): { start: Date; endExclusive: Date } {
|
|
16
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
17
|
+
throw new Error('--days must be a positive integer');
|
|
18
|
+
}
|
|
19
|
+
const start = new Date(anchor);
|
|
20
|
+
start.setHours(0, 0, 0, 0);
|
|
21
|
+
const lastDay = new Date(start);
|
|
22
|
+
lastDay.setDate(lastDay.getDate() + n - 1);
|
|
23
|
+
const endExclusive = new Date(lastDay);
|
|
24
|
+
endExclusive.setDate(endExclusive.getDate() + 1);
|
|
25
|
+
endExclusive.setHours(0, 0, 0, 0);
|
|
26
|
+
return { start, endExclusive };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Last N calendar days inclusive ending on anchor (anchor day included). */
|
|
30
|
+
export function calendarDaysBackward(anchor: Date, n: number): { start: Date; endExclusive: Date } {
|
|
31
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
32
|
+
throw new Error('--previous-days must be a positive integer');
|
|
33
|
+
}
|
|
34
|
+
const endDay = new Date(anchor);
|
|
35
|
+
endDay.setHours(0, 0, 0, 0);
|
|
36
|
+
const start = new Date(endDay);
|
|
37
|
+
start.setDate(start.getDate() - (n - 1));
|
|
38
|
+
const endExclusive = new Date(endDay);
|
|
39
|
+
endExclusive.setDate(endExclusive.getDate() + 1);
|
|
40
|
+
endExclusive.setHours(0, 0, 0, 0);
|
|
41
|
+
return { start, endExclusive };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** N weekdays (Mon–Fri) inclusive forward; if anchor is Sat/Sun, first counted day is next Monday. */
|
|
45
|
+
export function businessDaysForward(anchor: Date, n: number): { start: Date; endExclusive: Date } {
|
|
46
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
47
|
+
throw new Error('--business-days must be a positive integer');
|
|
48
|
+
}
|
|
49
|
+
const cur = new Date(anchor);
|
|
50
|
+
cur.setHours(0, 0, 0, 0);
|
|
51
|
+
while (isWeekend(cur)) {
|
|
52
|
+
cur.setDate(cur.getDate() + 1);
|
|
53
|
+
}
|
|
54
|
+
const start = new Date(cur);
|
|
55
|
+
let count = 0;
|
|
56
|
+
while (count < n) {
|
|
57
|
+
if (!isWeekend(cur)) {
|
|
58
|
+
count++;
|
|
59
|
+
}
|
|
60
|
+
if (count === n) {
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
cur.setDate(cur.getDate() + 1);
|
|
64
|
+
}
|
|
65
|
+
const endExclusive = new Date(cur);
|
|
66
|
+
endExclusive.setDate(endExclusive.getDate() + 1);
|
|
67
|
+
endExclusive.setHours(0, 0, 0, 0);
|
|
68
|
+
return { start, endExclusive };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** N weekdays inclusive looking backward from anchor; if anchor is Sat/Sun, range ends on previous Friday. */
|
|
72
|
+
export function businessDaysBackward(anchor: Date, n: number): { start: Date; endExclusive: Date } {
|
|
73
|
+
if (!Number.isFinite(n) || n < 1) {
|
|
74
|
+
throw new Error('--previous-business-days must be a positive integer');
|
|
75
|
+
}
|
|
76
|
+
const cur = new Date(anchor);
|
|
77
|
+
cur.setHours(0, 0, 0, 0);
|
|
78
|
+
while (isWeekend(cur)) {
|
|
79
|
+
cur.setDate(cur.getDate() - 1);
|
|
80
|
+
}
|
|
81
|
+
let count = 0;
|
|
82
|
+
let start = new Date(cur);
|
|
83
|
+
while (count < n) {
|
|
84
|
+
if (!isWeekend(cur)) {
|
|
85
|
+
count++;
|
|
86
|
+
start = new Date(cur);
|
|
87
|
+
}
|
|
88
|
+
if (count === n) {
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
cur.setDate(cur.getDate() - 1);
|
|
92
|
+
}
|
|
93
|
+
start.setHours(0, 0, 0, 0);
|
|
94
|
+
const endDay = new Date(anchor);
|
|
95
|
+
endDay.setHours(0, 0, 0, 0);
|
|
96
|
+
while (isWeekend(endDay)) {
|
|
97
|
+
endDay.setDate(endDay.getDate() - 1);
|
|
98
|
+
}
|
|
99
|
+
const endExclusive = new Date(endDay);
|
|
100
|
+
endExclusive.setDate(endExclusive.getDate() + 1);
|
|
101
|
+
endExclusive.setHours(0, 0, 0, 0);
|
|
102
|
+
return { start, endExclusive };
|
|
103
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { parseDay, parseTimeToDate, toLocalUnzonedISOString, toUTCISOString } from './dates.js';
|
|
3
|
+
|
|
4
|
+
describe('dates helpers', () => {
|
|
5
|
+
it('parseTimeToDate handles HH:MM and am/pm inputs', () => {
|
|
6
|
+
const base = new Date('2026-03-27T00:00:00');
|
|
7
|
+
|
|
8
|
+
expect(parseTimeToDate('13:45', base).getHours()).toBe(13);
|
|
9
|
+
expect(parseTimeToDate('13:45', base).getMinutes()).toBe(45);
|
|
10
|
+
expect(parseTimeToDate('1pm', base).getHours()).toBe(13);
|
|
11
|
+
expect(parseTimeToDate('12am', base).getHours()).toBe(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('toLocalUnzonedISOString formats as local time without Z', () => {
|
|
15
|
+
const date = new Date(2026, 2, 27, 9, 5, 7); // local time
|
|
16
|
+
const result = toLocalUnzonedISOString(date);
|
|
17
|
+
expect(result).toBe('2026-03-27T09:05:07');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('parseTimeToDate throws on invalid input when configured', () => {
|
|
21
|
+
const base = new Date('2026-03-27T00:00:00');
|
|
22
|
+
const opts = { throwOnInvalid: true };
|
|
23
|
+
|
|
24
|
+
// Format errors
|
|
25
|
+
expect(() => parseTimeToDate('not-a-time', base, opts)).toThrow(
|
|
26
|
+
'Invalid time format: "not-a-time" — expected HH:MM, H:MM, or H(am|pm)'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Value bounds
|
|
30
|
+
expect(() => parseTimeToDate('25:00', base, opts)).toThrow(
|
|
31
|
+
'Invalid time: "25:00" — hours must be 0–23 and minutes 0–59'
|
|
32
|
+
);
|
|
33
|
+
expect(() => parseTimeToDate('9:60', base, opts)).toThrow(
|
|
34
|
+
'Invalid time: "9:60" — hours must be 0–23 and minutes 0–59'
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// AM/PM bounds
|
|
38
|
+
expect(() => parseTimeToDate('13pm', base, opts)).toThrow('Invalid time: "13pm" — 12-hour values must be 1–12');
|
|
39
|
+
expect(() => parseTimeToDate('0am', base, opts)).toThrow('Invalid time: "0am" — 12-hour values must be 1–12');
|
|
40
|
+
|
|
41
|
+
// 24-hour hour-only bounds
|
|
42
|
+
expect(() => parseTimeToDate('24', base, opts)).toThrow('Invalid time: "24" — 24-hour values must be 0–23');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('toUTCISOString formats as UTC with Z suffix', () => {
|
|
46
|
+
const date = new Date(Date.UTC(2026, 2, 27, 9, 5, 7));
|
|
47
|
+
const result = toUTCISOString(date);
|
|
48
|
+
expect(result).toBe('2026-03-27T09:05:07.000Z');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('parseDay supports relative values and weekday directions', () => {
|
|
52
|
+
const base = new Date('2026-03-25T10:00:00'); // Wednesday
|
|
53
|
+
|
|
54
|
+
expect(parseDay('today', { baseDate: base }).getDate()).toBe(25);
|
|
55
|
+
expect(parseDay('tomorrow', { baseDate: base }).getDate()).toBe(26);
|
|
56
|
+
expect(parseDay('yesterday', { baseDate: base }).getDate()).toBe(24);
|
|
57
|
+
|
|
58
|
+
const nextMonday = parseDay('monday', { baseDate: base, weekdayDirection: 'next' });
|
|
59
|
+
expect(nextMonday.toISOString().slice(0, 10)).toBe('2026-03-30');
|
|
60
|
+
|
|
61
|
+
const prevMonday = parseDay('monday', { baseDate: base, weekdayDirection: 'previous' });
|
|
62
|
+
expect(prevMonday.toISOString().slice(0, 10)).toBe('2026-03-23');
|
|
63
|
+
|
|
64
|
+
const forwardMonday = parseDay('monday', {
|
|
65
|
+
baseDate: new Date('2026-03-30T10:00:00'),
|
|
66
|
+
weekdayDirection: 'nearestForward'
|
|
67
|
+
});
|
|
68
|
+
expect(forwardMonday.toISOString().slice(0, 10)).toBe('2026-03-30');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('parseDay throws on invalid input when configured', () => {
|
|
72
|
+
expect(() => parseDay('not-a-date', { throwOnInvalid: true })).toThrow('Invalid day value: not-a-date');
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/lib/dates.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] as const;
|
|
2
|
+
|
|
3
|
+
export interface ParseDayOptions {
|
|
4
|
+
baseDate?: Date;
|
|
5
|
+
weekdayDirection?: 'next' | 'previous' | 'nearestForward';
|
|
6
|
+
throwOnInvalid?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ParseTimeToDateOptions {
|
|
10
|
+
throwOnInvalid?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function parseTimeToDate(
|
|
14
|
+
timeStr: string,
|
|
15
|
+
baseDate: Date = new Date(),
|
|
16
|
+
options: ParseTimeToDateOptions = {}
|
|
17
|
+
): Date {
|
|
18
|
+
const { throwOnInvalid = false } = options;
|
|
19
|
+
const result = new Date(baseDate);
|
|
20
|
+
|
|
21
|
+
const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})$/);
|
|
22
|
+
if (timeMatch) {
|
|
23
|
+
const hours = parseInt(timeMatch[1], 10);
|
|
24
|
+
const minutes = parseInt(timeMatch[2], 10);
|
|
25
|
+
if (throwOnInvalid && (hours < 0 || hours > 23 || minutes < 0 || minutes > 59)) {
|
|
26
|
+
throw new Error(`Invalid time: "${timeStr}" — hours must be 0–23 and minutes 0–59`);
|
|
27
|
+
}
|
|
28
|
+
result.setHours(hours, minutes, 0, 0);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const hourMatch = timeStr.match(/^(\d{1,2})(am|pm)?$/i);
|
|
33
|
+
if (hourMatch) {
|
|
34
|
+
const rawHour = parseInt(hourMatch[1], 10);
|
|
35
|
+
const isPM = hourMatch[2]?.toLowerCase() === 'pm';
|
|
36
|
+
if (throwOnInvalid) {
|
|
37
|
+
if (hourMatch[2]) {
|
|
38
|
+
if (rawHour < 1 || rawHour > 12) {
|
|
39
|
+
throw new Error(`Invalid time: "${timeStr}" — 12-hour values must be 1–12`);
|
|
40
|
+
}
|
|
41
|
+
} else {
|
|
42
|
+
if (rawHour < 0 || rawHour > 23) {
|
|
43
|
+
throw new Error(`Invalid time: "${timeStr}" — 24-hour values must be 0–23`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
let hour = rawHour;
|
|
48
|
+
if (hourMatch[2]) {
|
|
49
|
+
if (isPM && rawHour < 12) hour += 12;
|
|
50
|
+
if (!isPM && rawHour === 12) hour = 0;
|
|
51
|
+
}
|
|
52
|
+
result.setHours(hour, 0, 0, 0);
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (throwOnInvalid) {
|
|
57
|
+
throw new Error(`Invalid time format: "${timeStr}" — expected HH:MM, H:MM, or H(am|pm)`);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function toUTCISOString(date: Date): string {
|
|
63
|
+
return date.toISOString();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse a date string that may have a timezone offset suffix (e.g. "+01:00")
|
|
68
|
+
* and return the local date components in the user's system timezone.
|
|
69
|
+
* This avoids the bug where `new Date("2026-03-29")` defaults to midnight UTC
|
|
70
|
+
* instead of interpreting it as the local date.
|
|
71
|
+
*/
|
|
72
|
+
export function parseLocalDate(dateStr: string): Date {
|
|
73
|
+
// Handle date-only strings (YYYY-MM-DD) as local midnight
|
|
74
|
+
const dateOnlyMatch = dateStr.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
|
75
|
+
if (dateOnlyMatch) {
|
|
76
|
+
const [, yearStr, monthStr, dayOfMonthStr] = dateOnlyMatch;
|
|
77
|
+
return new Date(parseInt(yearStr, 10), parseInt(monthStr, 10) - 1, parseInt(dayOfMonthStr, 10), 0, 0, 0, 0);
|
|
78
|
+
}
|
|
79
|
+
// Handle the "+01:00" suffix format by inserting a 'T' before the time
|
|
80
|
+
const withTime = dateStr.replace(' ', 'T');
|
|
81
|
+
return new Date(withTime);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function parseDay(day: string, options: ParseDayOptions = {}): Date {
|
|
85
|
+
const { baseDate = new Date(), weekdayDirection = 'next', throwOnInvalid = false } = options;
|
|
86
|
+
|
|
87
|
+
const now = new Date(baseDate);
|
|
88
|
+
const normalized = day.toLowerCase();
|
|
89
|
+
|
|
90
|
+
if (normalized === 'today') return now;
|
|
91
|
+
if (normalized === 'tomorrow') {
|
|
92
|
+
now.setDate(now.getDate() + 1);
|
|
93
|
+
return now;
|
|
94
|
+
}
|
|
95
|
+
if (normalized === 'yesterday') {
|
|
96
|
+
now.setDate(now.getDate() - 1);
|
|
97
|
+
return now;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const targetDay = WEEKDAYS.indexOf(normalized as (typeof WEEKDAYS)[number]);
|
|
101
|
+
if (targetDay >= 0) {
|
|
102
|
+
const currentDay = now.getDay();
|
|
103
|
+
let diff = targetDay - currentDay;
|
|
104
|
+
|
|
105
|
+
if (weekdayDirection === 'next') {
|
|
106
|
+
if (diff <= 0) diff += 7;
|
|
107
|
+
} else if (weekdayDirection === 'previous') {
|
|
108
|
+
if (diff > 0) diff -= 7;
|
|
109
|
+
} else {
|
|
110
|
+
if (diff < 0) diff += 7;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
now.setDate(now.getDate() + diff);
|
|
114
|
+
return now;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Parse YYYY-MM-DD as local midnight to avoid UTC off-by-one
|
|
118
|
+
const parsed = parseLocalDate(day);
|
|
119
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
120
|
+
if (throwOnInvalid) {
|
|
121
|
+
throw new Error(`Invalid day value: ${day}`);
|
|
122
|
+
}
|
|
123
|
+
return now;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return parsed;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function toLocalUnzonedISOString(date: Date): string {
|
|
130
|
+
const year = date.getFullYear();
|
|
131
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
132
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
133
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
134
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
135
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
136
|
+
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
|
137
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { addDelegate, getDelegates } from './delegate-client.js';
|
|
3
|
+
|
|
4
|
+
describe('delegate-client', () => {
|
|
5
|
+
const token = 'test-token';
|
|
6
|
+
|
|
7
|
+
it('getDelegates parses SOAP response properly', async () => {
|
|
8
|
+
const fetchCalls: any[] = [];
|
|
9
|
+
const originalFetch = globalThis.fetch;
|
|
10
|
+
try {
|
|
11
|
+
globalThis.fetch = (async (input, init) => {
|
|
12
|
+
fetchCalls.push({ input, init });
|
|
13
|
+
const xml = `
|
|
14
|
+
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/">
|
|
15
|
+
<s:Body>
|
|
16
|
+
<m:GetDelegateResponse xmlns:m="http://schemas.microsoft.com/exchange/services/2006/messages" xmlns:t="http://schemas.microsoft.com/exchange/services/2006/types">
|
|
17
|
+
<m:ResponseMessages>
|
|
18
|
+
<m:DelegateUserResponseMessageType ResponseClass="Success">
|
|
19
|
+
<m:DelegateUser>
|
|
20
|
+
<t:UserId>
|
|
21
|
+
<t:PrimarySmtpAddress>del@example.com</t:PrimarySmtpAddress>
|
|
22
|
+
</t:UserId>
|
|
23
|
+
<t:DelegatePermissions>
|
|
24
|
+
<t:CalendarFolderPermissionLevel>Editor</t:CalendarFolderPermissionLevel>
|
|
25
|
+
</t:DelegatePermissions>
|
|
26
|
+
<t:ViewPrivateItems>true</t:ViewPrivateItems>
|
|
27
|
+
</m:DelegateUser>
|
|
28
|
+
</m:DelegateUserResponseMessageType>
|
|
29
|
+
</m:ResponseMessages>
|
|
30
|
+
<m:DeliverMeetingRequests>DelegatesAndSendInformationToMe</m:DeliverMeetingRequests>
|
|
31
|
+
</m:GetDelegateResponse>
|
|
32
|
+
</s:Body>
|
|
33
|
+
</s:Envelope>
|
|
34
|
+
`;
|
|
35
|
+
return new Response(xml, { status: 200, headers: { 'content-type': 'text/xml' } });
|
|
36
|
+
}) as typeof fetch;
|
|
37
|
+
|
|
38
|
+
const res = await getDelegates(token, 'me@example.com');
|
|
39
|
+
expect(res.ok).toBe(true);
|
|
40
|
+
expect(res.data?.length).toBe(1);
|
|
41
|
+
expect(res.data?.[0].userId).toBe('del@example.com');
|
|
42
|
+
expect(res.data?.[0].permissions.calendar).toBe('Editor');
|
|
43
|
+
expect(res.data?.[0].viewPrivateItems).toBe(true);
|
|
44
|
+
expect(res.data?.[0].deliverMeetingRequests).toBe('DelegatesAndSendInformationToMe');
|
|
45
|
+
} finally {
|
|
46
|
+
globalThis.fetch = originalFetch;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('addDelegate generates correct SOAP body', async () => {
|
|
51
|
+
const fetchCalls: any[] = [];
|
|
52
|
+
const originalFetch = globalThis.fetch;
|
|
53
|
+
try {
|
|
54
|
+
globalThis.fetch = (async (input, init) => {
|
|
55
|
+
fetchCalls.push({ input, init });
|
|
56
|
+
const xml = `<m:AddDelegateResponse ResponseClass="Success"></m:AddDelegateResponse>`;
|
|
57
|
+
return new Response(xml, { status: 200, headers: { 'content-type': 'text/xml' } });
|
|
58
|
+
}) as typeof fetch;
|
|
59
|
+
|
|
60
|
+
await addDelegate({
|
|
61
|
+
token,
|
|
62
|
+
delegateEmail: 'del@example.com',
|
|
63
|
+
permissions: { inbox: 'Reviewer' },
|
|
64
|
+
deliverMeetingRequests: 'DelegatesAndSendInformationToMe'
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const body = fetchCalls[0].init.body as string;
|
|
68
|
+
expect(body).toContain('<m:DeliverMeetingRequests>DelegatesAndSendInformationToMe</m:DeliverMeetingRequests>');
|
|
69
|
+
expect(body).toContain('<t:InboxFolderPermissionLevel>Reviewer</t:InboxFolderPermissionLevel>');
|
|
70
|
+
} finally {
|
|
71
|
+
globalThis.fetch = originalFetch;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|