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.
- package/README.md +683 -0
- package/bin/m365.js +489 -0
- package/config/default.json +18 -0
- package/package.json +36 -0
- package/src/auth/device-flow.js +154 -0
- package/src/auth/token-manager.js +237 -0
- package/src/commands/calendar.js +279 -0
- package/src/commands/mail.js +353 -0
- package/src/commands/onedrive.js +423 -0
- package/src/commands/sharepoint.js +312 -0
- package/src/graph/client.js +875 -0
- package/src/utils/config.js +60 -0
- package/src/utils/error.js +114 -0
- package/src/utils/output.js +850 -0
- package/src/utils/trusted-senders.js +190 -0
|
@@ -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
|
+
};
|