m365-cli 0.1.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.
@@ -0,0 +1,237 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'fs';
2
+ import { dirname, join } from 'path';
3
+ import config from '../utils/config.js';
4
+ import { AuthError, TokenExpiredError } from '../utils/error.js';
5
+ import { deviceCodeFlow } from './device-flow.js';
6
+
7
+ /**
8
+ * Token Manager
9
+ * Handles token storage, refresh, and validation
10
+ */
11
+
12
+ /**
13
+ * Load credentials from file
14
+ * Supports migration from old token file
15
+ */
16
+ export function loadCreds() {
17
+ const credsPath = config.getCredsPath();
18
+ const oldTokenPath = join(dirname(credsPath), '../.m365-token.json');
19
+
20
+ // Try to load from new creds file
21
+ try {
22
+ const data = readFileSync(credsPath, 'utf-8');
23
+ const creds = JSON.parse(data);
24
+
25
+ // Validate it's a proper JSON object
26
+ if (creds && typeof creds === 'object' && creds.accessToken) {
27
+ return creds;
28
+ }
29
+ } catch (error) {
30
+ // File doesn't exist or not valid JSON, continue to migration
31
+ }
32
+
33
+ // Try to migrate from old token file
34
+ try {
35
+ const oldData = readFileSync(oldTokenPath, 'utf-8');
36
+ const oldToken = JSON.parse(oldData);
37
+
38
+ if (oldToken && oldToken.access_token) {
39
+ // Migrate to new format
40
+ const creds = {
41
+ tenantId: config.get('tenantId'),
42
+ clientId: config.get('clientId'),
43
+ accessToken: oldToken.access_token,
44
+ refreshToken: oldToken.refresh_token,
45
+ expiresAt: oldToken.expires_at,
46
+ };
47
+
48
+ // Save to new location
49
+ saveCreds(creds);
50
+
51
+ console.log('ℹ️ Migrated token from old format.');
52
+
53
+ return creds;
54
+ }
55
+ } catch (error) {
56
+ // Old file doesn't exist either
57
+ }
58
+
59
+ return null; // No credentials found
60
+ }
61
+
62
+ /**
63
+ * Save credentials to file
64
+ */
65
+ export function saveCreds(creds) {
66
+ const credsPath = config.getCredsPath();
67
+
68
+ try {
69
+ // Ensure directory exists
70
+ mkdirSync(dirname(credsPath), { recursive: true });
71
+
72
+ // Write credentials
73
+ writeFileSync(credsPath, JSON.stringify(creds, null, 2), 'utf-8');
74
+
75
+ // Set permissions to 600 (user read/write only)
76
+ chmodSync(credsPath, 0o600);
77
+ } catch (error) {
78
+ throw new AuthError(`Failed to save credentials: ${error.message}`);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Check if token is expired
84
+ */
85
+ export function isTokenExpired(creds) {
86
+ if (!creds || !creds.expiresAt) {
87
+ return true;
88
+ }
89
+
90
+ const now = Math.floor(Date.now() / 1000);
91
+ const buffer = config.get('tokenRefreshBuffer') || 60;
92
+
93
+ return creds.expiresAt <= (now + buffer);
94
+ }
95
+
96
+ /**
97
+ * Refresh access token using refresh token
98
+ */
99
+ export async function refreshToken(refreshToken) {
100
+ const tenantId = config.get('tenantId');
101
+ const clientId = config.get('clientId');
102
+ const scopes = config.get('scopes').join(' ');
103
+ const authUrl = config.get('authUrl');
104
+
105
+ const url = `${authUrl}/${tenantId}/oauth2/v2.0/token`;
106
+
107
+ try {
108
+ const response = await fetch(url, {
109
+ method: 'POST',
110
+ headers: {
111
+ 'Content-Type': 'application/x-www-form-urlencoded',
112
+ },
113
+ body: new URLSearchParams({
114
+ client_id: clientId,
115
+ grant_type: 'refresh_token',
116
+ refresh_token: refreshToken,
117
+ scope: scopes,
118
+ }),
119
+ });
120
+
121
+ if (!response.ok) {
122
+ const error = await response.json();
123
+ throw new AuthError(
124
+ error.error_description || 'Token refresh failed',
125
+ error
126
+ );
127
+ }
128
+
129
+ const data = await response.json();
130
+
131
+ return {
132
+ accessToken: data.access_token,
133
+ refreshToken: data.refresh_token || refreshToken,
134
+ expiresIn: data.expires_in || 3600,
135
+ };
136
+ } catch (error) {
137
+ if (error instanceof AuthError) {
138
+ throw error;
139
+ }
140
+ throw new AuthError(`Failed to refresh token: ${error.message}`);
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Get valid access token (auto-refresh if needed)
146
+ */
147
+ export async function getAccessToken() {
148
+ const creds = loadCreds();
149
+
150
+ if (!creds || !creds.accessToken) {
151
+ throw new AuthError('Not authenticated. Please run: m365 login');
152
+ }
153
+
154
+ // Check if token is expired
155
+ if (!isTokenExpired(creds)) {
156
+ return creds.accessToken;
157
+ }
158
+
159
+ // Try to refresh
160
+ if (!creds.refreshToken) {
161
+ throw new TokenExpiredError();
162
+ }
163
+
164
+ try {
165
+ const refreshed = await refreshToken(creds.refreshToken);
166
+
167
+ // Save new credentials
168
+ const newCreds = {
169
+ tenantId: config.get('tenantId'),
170
+ clientId: config.get('clientId'),
171
+ accessToken: refreshed.accessToken,
172
+ refreshToken: refreshed.refreshToken,
173
+ expiresAt: Math.floor(Date.now() / 1000) + refreshed.expiresIn,
174
+ };
175
+
176
+ saveCreds(newCreds);
177
+
178
+ return refreshed.accessToken;
179
+ } catch (error) {
180
+ throw new TokenExpiredError();
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Perform login (device code flow)
186
+ */
187
+ export async function login() {
188
+ try {
189
+ const result = await deviceCodeFlow();
190
+
191
+ const creds = {
192
+ tenantId: config.get('tenantId'),
193
+ clientId: config.get('clientId'),
194
+ accessToken: result.accessToken,
195
+ refreshToken: result.refreshToken,
196
+ expiresAt: Math.floor(Date.now() / 1000) + result.expiresIn,
197
+ };
198
+
199
+ saveCreds(creds);
200
+
201
+ console.log('\n✅ Authentication successful!');
202
+ console.log(` Credentials saved to: ${config.getCredsPath()}`);
203
+
204
+ return true;
205
+ } catch (error) {
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Logout (clear credentials)
212
+ */
213
+ export function logout() {
214
+ const credsPath = config.getCredsPath();
215
+
216
+ try {
217
+ unlinkSync(credsPath);
218
+ console.log('✅ Logged out successfully.');
219
+ return true;
220
+ } catch (error) {
221
+ if (error.code === 'ENOENT') {
222
+ console.log('ℹ️ No credentials found.');
223
+ return true;
224
+ }
225
+ throw new AuthError(`Failed to logout: ${error.message}`);
226
+ }
227
+ }
228
+
229
+ export default {
230
+ loadCreds,
231
+ saveCreds,
232
+ isTokenExpired,
233
+ refreshToken,
234
+ getAccessToken,
235
+ login,
236
+ logout,
237
+ };
@@ -0,0 +1,279 @@
1
+ import graphClient from '../graph/client.js';
2
+ import { outputCalendarList, outputCalendarDetail, outputCalendarResult } from '../utils/output.js';
3
+ import { handleError } from '../utils/error.js';
4
+
5
+ /**
6
+ * Calendar commands
7
+ */
8
+
9
+ /**
10
+ * List calendar events
11
+ */
12
+ export async function listEvents(options) {
13
+ try {
14
+ const { days = 7, top = 50, json = false } = options;
15
+
16
+ // Calculate date range
17
+ const startDateTime = new Date();
18
+ const endDateTime = new Date();
19
+ endDateTime.setDate(endDateTime.getDate() + days);
20
+
21
+ const events = await graphClient.calendar.list({
22
+ startDateTime: startDateTime.toISOString(),
23
+ endDateTime: endDateTime.toISOString(),
24
+ top,
25
+ });
26
+
27
+ outputCalendarList(events, { json, days });
28
+ } catch (error) {
29
+ handleError(error, { json: options.json });
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Get calendar event by ID
35
+ */
36
+ export async function getEvent(id, options) {
37
+ try {
38
+ const { json = false } = options;
39
+
40
+ if (!id) {
41
+ throw new Error('Event ID is required');
42
+ }
43
+
44
+ const event = await graphClient.calendar.get(id);
45
+
46
+ outputCalendarDetail(event, { json });
47
+ } catch (error) {
48
+ handleError(error, { json: options.json });
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Create calendar event
54
+ */
55
+ export async function createEvent(title, options) {
56
+ try {
57
+ const {
58
+ start,
59
+ end,
60
+ location = '',
61
+ body = '',
62
+ attendees = [],
63
+ allday = false,
64
+ json = false,
65
+ } = options;
66
+
67
+ if (!title) {
68
+ throw new Error('Event title is required');
69
+ }
70
+
71
+ if (!start || !end) {
72
+ throw new Error('Start and end time are required');
73
+ }
74
+
75
+ // Parse datetime strings
76
+ let startDateTime, endDateTime;
77
+
78
+ if (allday) {
79
+ // All-day events use date only (YYYY-MM-DD)
80
+ startDateTime = start.includes('T') ? start.split('T')[0] : start;
81
+ endDateTime = end.includes('T') ? end.split('T')[0] : end;
82
+ } else {
83
+ // Regular events use full datetime
84
+ startDateTime = start.includes('T') ? start : `${start}T00:00:00`;
85
+ endDateTime = end.includes('T') ? end : `${end}T00:00:00`;
86
+ }
87
+
88
+ // Build event object
89
+ const event = {
90
+ subject: title,
91
+ start: {
92
+ dateTime: startDateTime,
93
+ timeZone: 'Asia/Shanghai',
94
+ },
95
+ end: {
96
+ dateTime: endDateTime,
97
+ timeZone: 'Asia/Shanghai',
98
+ },
99
+ isAllDay: allday,
100
+ };
101
+
102
+ // Add location if provided
103
+ if (location) {
104
+ event.location = {
105
+ displayName: location,
106
+ };
107
+ }
108
+
109
+ // Add body if provided
110
+ if (body) {
111
+ event.body = {
112
+ contentType: 'HTML',
113
+ content: body,
114
+ };
115
+ }
116
+
117
+ // Add attendees if provided
118
+ if (attendees && attendees.length > 0) {
119
+ event.attendees = attendees.map(email => ({
120
+ emailAddress: {
121
+ address: email.trim(),
122
+ },
123
+ type: 'required',
124
+ }));
125
+ }
126
+
127
+ // Create event
128
+ const created = await graphClient.calendar.create(event);
129
+
130
+ // Build result with properly formatted times
131
+ // Note: Graph API returns dateTime in the specified timeZone, but without zone suffix
132
+ // We need to add the timezone suffix for correct display
133
+ const getTimeWithZone = (timeObj) => {
134
+ const tz = timeObj.timeZone;
135
+ if (tz === 'Asia/Shanghai') {
136
+ return timeObj.dateTime + '+08:00';
137
+ } else if (tz === 'UTC') {
138
+ return timeObj.dateTime + 'Z';
139
+ } else {
140
+ // For other timezones, use as-is (may not display correctly)
141
+ return timeObj.dateTime;
142
+ }
143
+ };
144
+
145
+ const result = {
146
+ status: 'created',
147
+ id: created.id,
148
+ subject: created.subject,
149
+ start: getTimeWithZone(created.start),
150
+ end: getTimeWithZone(created.end),
151
+ };
152
+
153
+ outputCalendarResult(result, { json });
154
+ } catch (error) {
155
+ handleError(error, { json: options.json });
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Update calendar event
161
+ */
162
+ export async function updateEvent(id, options) {
163
+ try {
164
+ const {
165
+ title,
166
+ start,
167
+ end,
168
+ location,
169
+ body,
170
+ json = false,
171
+ } = options;
172
+
173
+ if (!id) {
174
+ throw new Error('Event ID is required');
175
+ }
176
+
177
+ // Build update object (only include fields that are provided)
178
+ const updates = {};
179
+
180
+ if (title) {
181
+ updates.subject = title;
182
+ }
183
+
184
+ if (start) {
185
+ const startDateTime = start.includes('T') ? start : `${start}T00:00:00`;
186
+ updates.start = {
187
+ dateTime: startDateTime,
188
+ timeZone: 'Asia/Shanghai',
189
+ };
190
+ }
191
+
192
+ if (end) {
193
+ const endDateTime = end.includes('T') ? end : `${end}T00:00:00`;
194
+ updates.end = {
195
+ dateTime: endDateTime,
196
+ timeZone: 'Asia/Shanghai',
197
+ };
198
+ }
199
+
200
+ if (location !== undefined) {
201
+ updates.location = {
202
+ displayName: location,
203
+ };
204
+ }
205
+
206
+ if (body !== undefined) {
207
+ updates.body = {
208
+ contentType: 'HTML',
209
+ content: body,
210
+ };
211
+ }
212
+
213
+ if (Object.keys(updates).length === 0) {
214
+ throw new Error('No updates provided. Use --title, --start, --end, --location, or --body');
215
+ }
216
+
217
+ // Update event
218
+ const updated = await graphClient.calendar.update(id, updates);
219
+
220
+ // Build result with properly formatted times
221
+ // Note: Graph API returns dateTime in the specified timeZone, but without zone suffix
222
+ // We need to add the timezone suffix for correct display
223
+ const getTimeWithZone = (timeObj) => {
224
+ const tz = timeObj.timeZone;
225
+ if (tz === 'Asia/Shanghai') {
226
+ return timeObj.dateTime + '+08:00';
227
+ } else if (tz === 'UTC') {
228
+ return timeObj.dateTime + 'Z';
229
+ } else {
230
+ // For other timezones, use as-is (may not display correctly)
231
+ return timeObj.dateTime;
232
+ }
233
+ };
234
+
235
+ const result = {
236
+ status: 'updated',
237
+ id: updated.id,
238
+ subject: updated.subject,
239
+ start: getTimeWithZone(updated.start),
240
+ end: getTimeWithZone(updated.end),
241
+ };
242
+
243
+ outputCalendarResult(result, { json });
244
+ } catch (error) {
245
+ handleError(error, { json: options.json });
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Delete calendar event
251
+ */
252
+ export async function deleteEvent(id, options) {
253
+ try {
254
+ const { json = false } = options;
255
+
256
+ if (!id) {
257
+ throw new Error('Event ID is required');
258
+ }
259
+
260
+ await graphClient.calendar.delete(id);
261
+
262
+ const result = {
263
+ status: 'deleted',
264
+ id,
265
+ };
266
+
267
+ outputCalendarResult(result, { json });
268
+ } catch (error) {
269
+ handleError(error, { json: options.json });
270
+ }
271
+ }
272
+
273
+ export default {
274
+ list: listEvents,
275
+ get: getEvent,
276
+ create: createEvent,
277
+ update: updateEvent,
278
+ delete: deleteEvent,
279
+ };