atris 3.74.29 → 3.76.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/atris/skills/calendar/SKILL.md +9 -0
- package/atris/skills/drive/SKILL.md +7 -0
- package/atris/skills/email-agent/SKILL.md +7 -0
- package/atris/skills/magic-inbox/SKILL.md +7 -0
- package/atris/skills/slides/SKILL.md +7 -0
- package/bin/atris.js +14 -2
- package/commands/engine.js +2 -2
- package/commands/integrations.js +539 -54
- package/commands/mission.js +2 -2
- package/commands/rating.js +29 -5
- package/commands/task.js +72 -5
- package/commands/worktree.js +66 -18
- package/lib/fleet.js +602 -55
- package/lib/gm-season.js +47 -10
- package/lib/held-pull-requests.js +589 -0
- package/lib/known-commands.js +1 -1
- package/lib/pull-request-link.js +25 -0
- package/lib/release-kind.js +31 -7
- package/lib/review-integrity.js +52 -5
- package/lib/task-db.js +703 -160
- package/lib/worktree-space.js +4 -45
- package/package.json +1 -1
package/commands/integrations.js
CHANGED
|
@@ -24,7 +24,49 @@
|
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
26
|
const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
|
|
27
|
-
const { apiRequestJson } = require('../utils/api');
|
|
27
|
+
const { apiRequestJson: rawApiRequestJson } = require('../utils/api');
|
|
28
|
+
const { AsyncLocalStorage } = require('async_hooks');
|
|
29
|
+
const { createHash } = require('crypto');
|
|
30
|
+
const { resolveBusiness } = require('../lib/business-target');
|
|
31
|
+
const googleContext = new AsyncLocalStorage();
|
|
32
|
+
|
|
33
|
+
function apiRequestJson(url, options = {}) {
|
|
34
|
+
const context = googleContext.getStore();
|
|
35
|
+
if (!context || !/^\/integrations\/(gmail|google-calendar|google)(?:\/|$)/.test(url)) return rawApiRequestJson(url, options);
|
|
36
|
+
const next = { ...options };
|
|
37
|
+
if (context.business) next.headers = { ...(options.headers || {}), 'X-Business-Id': context.business };
|
|
38
|
+
if (context.calendarAccount && url.startsWith('/integrations/google-calendar/')) {
|
|
39
|
+
if ((options.method || 'GET') === 'GET' || options.method === 'DELETE') {
|
|
40
|
+
url += `${url.includes('?') ? '&' : '?'}account_id=${encodeURIComponent(context.calendarAccount)}`;
|
|
41
|
+
} else next.body = { ...(options.body || {}), account_id: context.calendarAccount };
|
|
42
|
+
}
|
|
43
|
+
if (context.serverDefaults && !context.mailAccount && url.startsWith('/integrations/gmail/') && !/\/accounts|\/start/.test(url)) {
|
|
44
|
+
const parts = url.split('?');
|
|
45
|
+
if (parts[1]) { const q = new URLSearchParams(parts[1]); q.delete('account_id'); url = parts[0] + (q.toString() ? `?${q}` : ''); }
|
|
46
|
+
if (next.body && Object.hasOwn(next.body, 'account_id')) { next.body = { ...next.body }; delete next.body.account_id; }
|
|
47
|
+
}
|
|
48
|
+
return rawApiRequestJson(url, next);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function takeContext(args) {
|
|
52
|
+
const rest = []; let businessFlag; let calendarAccount; let mailAccount;
|
|
53
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
54
|
+
const arg = String(args[i]);
|
|
55
|
+
if (arg === '--business' || arg.startsWith('--business=')) {
|
|
56
|
+
businessFlag = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
|
|
57
|
+
if (!businessFlag || String(businessFlag).startsWith('--')) throw new Error('--business needs a business slug or id');
|
|
58
|
+
} else if (arg === '--account' || arg.startsWith('--account=')) {
|
|
59
|
+
const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
|
|
60
|
+
if (!value || String(value).startsWith('--')) throw new Error('--account needs an account name or email');
|
|
61
|
+
calendarAccount = mailAccount = String(value);
|
|
62
|
+
rest.push('--account', String(value));
|
|
63
|
+
} else rest.push(args[i]);
|
|
64
|
+
}
|
|
65
|
+
const business = resolveBusiness(businessFlag === undefined ? {} : { business: businessFlag });
|
|
66
|
+
if (business && business.error) throw new Error(business.error);
|
|
67
|
+
return { rest, business: business && business.id, explicitBusiness: businessFlag !== undefined, calendarAccount, mailAccount };
|
|
68
|
+
}
|
|
69
|
+
|
|
28
70
|
const {
|
|
29
71
|
assertOutboundSendAllowedOrExit,
|
|
30
72
|
extractOutboundGateOptions,
|
|
@@ -202,9 +244,89 @@ function writeGmailStickyAccount(accountId) {
|
|
|
202
244
|
fs.writeFileSync(filePath, `${JSON.stringify({ account_id: accountId }, null, 2)}\n`, 'utf8');
|
|
203
245
|
}
|
|
204
246
|
|
|
247
|
+
function mailMigrationRecord(auth) {
|
|
248
|
+
const markerPath = `${gmailAccountStatePath()}.migrated`;
|
|
249
|
+
let subject = '';
|
|
250
|
+
try { subject = JSON.parse(Buffer.from(auth.token.split('.')[1], 'base64url').toString()).sub || ''; } catch {}
|
|
251
|
+
const owner = auth.userId || (auth.email !== 'unknown' ? auth.email.toLowerCase() : subject || auth.token);
|
|
252
|
+
const identity = createHash('sha256').update(owner).digest('hex');
|
|
253
|
+
let migrated = {};
|
|
254
|
+
try { migrated = JSON.parse(fs.readFileSync(markerPath, 'utf8')); } catch {}
|
|
255
|
+
if (!migrated || typeof migrated !== 'object' || Array.isArray(migrated)) migrated = {};
|
|
256
|
+
return { markerPath, identity, migrated };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function markMailMigration(record) {
|
|
260
|
+
record.migrated[record.identity] = true;
|
|
261
|
+
fs.mkdirSync(path.dirname(record.markerPath), { recursive: true });
|
|
262
|
+
fs.writeFileSync(record.markerPath, `${JSON.stringify(record.migrated)}\n`, { mode: 0o600 });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function googleAccountsCapability(token, { refresh = false } = {}) {
|
|
266
|
+
const context = googleContext.getStore();
|
|
267
|
+
if (!refresh && context?.googleCapability) return context.googleCapability;
|
|
268
|
+
const result = await apiRequestJson('/integrations/google/accounts', { method: 'GET', token });
|
|
269
|
+
if (!result.ok && result.status !== 404) {
|
|
270
|
+
const error = new Error(`could not check google accounts: ${result.error || result.data?.detail || 'request failed'}`);
|
|
271
|
+
error.status = result.status || 0;
|
|
272
|
+
error.result = result;
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
if (result.ok && !Array.isArray(result.data?.accounts)) {
|
|
276
|
+
const error = new Error('could not check google accounts: the server returned an incomplete account list');
|
|
277
|
+
error.status = result.status || 0;
|
|
278
|
+
error.result = result;
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
const capability = { supported: result.ok, data: result.data || {}, status: result.status || 0 };
|
|
282
|
+
if (context) context.googleCapability = capability;
|
|
283
|
+
return capability;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function prepareMailDefaults(context, { migrate = true } = {}) {
|
|
287
|
+
const auth = await getAuth();
|
|
288
|
+
const capability = await googleAccountsCapability(auth.token);
|
|
289
|
+
if (!capability.supported) {
|
|
290
|
+
const sticky = readGmailStickyAccount();
|
|
291
|
+
if (sticky && sticky.includes('@')) {
|
|
292
|
+
const account = resolveAccountSelector(await fetchGmailAccounts(auth.token), sticky);
|
|
293
|
+
if (!account) throw new Error('saved gmail account is not connected');
|
|
294
|
+
context.resolvedMail = gmailAccountIdentity(account).id;
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
let data = capability.data;
|
|
299
|
+
if (!data || !Array.isArray(data.accounts) || !data.defaults || !Object.hasOwn(data.defaults, 'gmail') || !data.resolved || !Object.hasOwn(data.resolved, 'gmail')) {
|
|
300
|
+
throw new Error('could not resolve mail account: the server returned an incomplete account list');
|
|
301
|
+
}
|
|
302
|
+
const record = mailMigrationRecord(auth);
|
|
303
|
+
if (migrate && !record.migrated[record.identity]) {
|
|
304
|
+
const sticky = readGmailStickyAccount();
|
|
305
|
+
const legacy = sticky && resolveAccountSelector(data.accounts, sticky);
|
|
306
|
+
const eligible = legacy && legacy.products?.gmail?.connected;
|
|
307
|
+
if (sticky && data.defaults.gmail == null && !eligible) console.error('saved mail account is no longer connected; using the server choice.');
|
|
308
|
+
if (sticky && data.defaults.gmail == null && eligible) {
|
|
309
|
+
const canonical = gmailAccountIdentity(legacy).id;
|
|
310
|
+
const saved = await rawApiRequestJson('/integrations/google/defaults', {
|
|
311
|
+
method: 'PUT', token: auth.token, body: { account_id: canonical, products: ['gmail'] },
|
|
312
|
+
});
|
|
313
|
+
if (!saved.ok) return googleFailure(saved, 'move the saved mail default');
|
|
314
|
+
const refreshed = await googleAccountsCapability(auth.token, { refresh: true });
|
|
315
|
+
if (!refreshed.supported) throw new Error('could not refresh mail account');
|
|
316
|
+
data = refreshed.data;
|
|
317
|
+
if (!data || !data.resolved || !Object.hasOwn(data.resolved, 'gmail')) throw new Error('could not refresh mail account');
|
|
318
|
+
}
|
|
319
|
+
markMailMigration(record);
|
|
320
|
+
}
|
|
321
|
+
context.serverDefaults = true;
|
|
322
|
+
context.resolvedMail = data.resolved.gmail || null;
|
|
323
|
+
}
|
|
324
|
+
|
|
205
325
|
function resolveGmailAccountId(explicitId) {
|
|
206
326
|
const trimmed = String(explicitId || '').trim();
|
|
207
327
|
if (trimmed) return trimmed;
|
|
328
|
+
const context = googleContext.getStore();
|
|
329
|
+
if (context?.resolvedMail) return context.resolvedMail;
|
|
208
330
|
const sticky = readGmailStickyAccount();
|
|
209
331
|
if (sticky) return sticky;
|
|
210
332
|
return 'default';
|
|
@@ -257,16 +379,22 @@ async function fetchGmailAccounts(token, options = {}) {
|
|
|
257
379
|
return Array.isArray(accounts) ? accounts : [];
|
|
258
380
|
}
|
|
259
381
|
|
|
260
|
-
function
|
|
261
|
-
const wanted = String(accountId || '').trim().toLowerCase();
|
|
262
|
-
return accounts.find((account) => gmailAccountIdentity(account).id.toLowerCase() === wanted) || null;
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
function findGmailAccountForUse(accounts, selector) {
|
|
382
|
+
function resolveAccountSelector(accounts, selector, { displayName = false } = {}) {
|
|
266
383
|
const wanted = String(selector || '').trim().toLowerCase();
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
384
|
+
if (!wanted) return null;
|
|
385
|
+
const exact = accounts.filter((account) => {
|
|
386
|
+
const identity = gmailAccountIdentity(account);
|
|
387
|
+
return identity.id.toLowerCase() === wanted || identity.email === wanted;
|
|
388
|
+
});
|
|
389
|
+
const matches = exact.length || !displayName ? exact : accounts.filter((account) => gmailAccountIdentity(account).name === wanted);
|
|
390
|
+
if (matches.length > 1) {
|
|
391
|
+
const labels = matches.map((account) => {
|
|
392
|
+
const { id, email } = gmailAccountIdentity(account);
|
|
393
|
+
return `${id} (${email || 'email pending'})`;
|
|
394
|
+
});
|
|
395
|
+
throw new Error(`ambiguous account "${selector}": ${labels.join(', ')}. use an account name or email.`);
|
|
396
|
+
}
|
|
397
|
+
return matches[0] || null;
|
|
270
398
|
}
|
|
271
399
|
|
|
272
400
|
async function promptForGmailAccount(deps = {}) {
|
|
@@ -294,7 +422,7 @@ async function getAuth() {
|
|
|
294
422
|
}
|
|
295
423
|
process.exit(1);
|
|
296
424
|
}
|
|
297
|
-
return { token: creds.token, email: creds.email || 'unknown' };
|
|
425
|
+
return { token: creds.token, email: creds.email || 'unknown', userId: creds.user_id || null };
|
|
298
426
|
}
|
|
299
427
|
|
|
300
428
|
async function getAuthToken() {
|
|
@@ -344,7 +472,7 @@ async function gmailInbox(options = {}) {
|
|
|
344
472
|
const limit = options.limit || 10;
|
|
345
473
|
const accountId = resolveGmailAccountId(options.accountId);
|
|
346
474
|
const accounts = await fetchGmailAccounts(token, { quiet: true });
|
|
347
|
-
const account =
|
|
475
|
+
const account = resolveAccountSelector(accounts, accountId);
|
|
348
476
|
const mailboxEmail = account ? gmailAccountIdentity(account).email : '';
|
|
349
477
|
|
|
350
478
|
if (!options.quiet) {
|
|
@@ -615,7 +743,7 @@ async function gmailSend(to, subject, body, options = {}) {
|
|
|
615
743
|
const token = await getAuthToken();
|
|
616
744
|
const accountId = resolveGmailAccountId(options.accountId);
|
|
617
745
|
const accounts = await fetchGmailAccounts(token);
|
|
618
|
-
const account =
|
|
746
|
+
const account = resolveAccountSelector(accounts, accountId);
|
|
619
747
|
|
|
620
748
|
if (!account) {
|
|
621
749
|
console.error(`gmail account "${accountId}" is not connected.`);
|
|
@@ -686,7 +814,7 @@ async function gmailVoice(accountId, options = {}) {
|
|
|
686
814
|
const token = await getAuthToken();
|
|
687
815
|
const resolvedAccountId = resolveGmailAccountId(accountId);
|
|
688
816
|
const accounts = await fetchGmailAccounts(token);
|
|
689
|
-
const account =
|
|
817
|
+
const account = resolveAccountSelector(accounts, resolvedAccountId);
|
|
690
818
|
|
|
691
819
|
if (!account) {
|
|
692
820
|
console.error(`gmail account "${resolvedAccountId}" is not connected.`);
|
|
@@ -742,48 +870,29 @@ async function gmailConnect(accountName, deps = {}) {
|
|
|
742
870
|
|
|
743
871
|
if (!result.ok) {
|
|
744
872
|
const detail = String(result.error || 'request failed').toLowerCase();
|
|
873
|
+
if (deps.json) return googleSignInFailure(result, { error: detail, retry: deps.retry });
|
|
745
874
|
console.error(`could not start gmail connection: ${detail}`);
|
|
746
875
|
process.exit(1);
|
|
747
876
|
}
|
|
748
877
|
|
|
749
878
|
const authUrl = String(result.data?.auth_url || result.data?.url || '').trim();
|
|
750
879
|
if (!authUrl) {
|
|
880
|
+
if (deps.json) return googleSignInFailure(result, { error: 'the server did not return a sign-in url', retry: deps.retry });
|
|
751
881
|
console.error('could not start gmail connection: the server did not return an auth url.');
|
|
752
882
|
process.exit(1);
|
|
753
883
|
}
|
|
754
884
|
|
|
755
885
|
const openAuthUrl = deps.openAuthUrl || openGmailAuthUrl;
|
|
756
|
-
|
|
757
|
-
|
|
886
|
+
const opened = openAuthUrl(authUrl, deps);
|
|
887
|
+
if (deps.json) {
|
|
888
|
+
console.log(JSON.stringify({ ok: false, status: 'unconfirmed', auth_url: authUrl, opened, notice: googleOldServer }, null, 2));
|
|
889
|
+
return 1;
|
|
758
890
|
}
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
const pollIntervalMs = deps.pollIntervalMs ?? GMAIL_CONNECT_POLL_MS;
|
|
763
|
-
const timeoutMs = deps.timeoutMs ?? GMAIL_CONNECT_TIMEOUT_MS;
|
|
764
|
-
const sleep = deps.sleep || ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
765
|
-
const maxPolls = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs));
|
|
766
|
-
|
|
767
|
-
for (let poll = 0; poll < maxPolls; poll += 1) {
|
|
768
|
-
await sleep(pollIntervalMs);
|
|
769
|
-
const accountsResult = await apiRequestJson('/integrations/gmail/accounts', {
|
|
770
|
-
method: 'GET',
|
|
771
|
-
token,
|
|
772
|
-
});
|
|
773
|
-
const accounts = accountsResult.ok
|
|
774
|
-
? (accountsResult.data?.accounts || accountsResult.data || [])
|
|
775
|
-
: [];
|
|
776
|
-
const match = Array.isArray(accounts) ? findGmailAccount(accounts, accountId) : null;
|
|
777
|
-
const email = match ? gmailAccountIdentity(match).email.trim() : '';
|
|
778
|
-
if (email && email !== 'unknown') {
|
|
779
|
-
console.log(`connected ${email} as ${accountId}`);
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
891
|
+
if (!opened) {
|
|
892
|
+
console.log(`open this url in your browser: ${authUrl}`);
|
|
782
893
|
}
|
|
783
894
|
|
|
784
|
-
console.
|
|
785
|
-
console.error(`or connect from settings: https://atris.ai/dashboard/settings?connect=gmail&account=${accountId}`);
|
|
786
|
-
process.exit(1);
|
|
895
|
+
console.log(`${opened ? 'sign-in opened' : 'sign-in link is ready to open'}. it is not confirmed yet. finish in your browser and check with atris google accounts --account.`);
|
|
787
896
|
}
|
|
788
897
|
|
|
789
898
|
async function gmailAccounts() {
|
|
@@ -800,7 +909,8 @@ async function gmailAccounts() {
|
|
|
800
909
|
}
|
|
801
910
|
|
|
802
911
|
async function gmailUse(accountId, deps = {}) {
|
|
803
|
-
const
|
|
912
|
+
const auth = await getAuth();
|
|
913
|
+
const token = auth.token;
|
|
804
914
|
const accounts = await fetchGmailAccounts(token);
|
|
805
915
|
let selector = String(accountId || '').trim();
|
|
806
916
|
|
|
@@ -826,7 +936,7 @@ async function gmailUse(accountId, deps = {}) {
|
|
|
826
936
|
: answer;
|
|
827
937
|
}
|
|
828
938
|
|
|
829
|
-
const match =
|
|
939
|
+
const match = resolveAccountSelector(accounts, selector, { displayName: true });
|
|
830
940
|
if (!match) {
|
|
831
941
|
console.error(`unknown gmail account "${selector}".`);
|
|
832
942
|
if (accounts.length) {
|
|
@@ -842,11 +952,311 @@ async function gmailUse(accountId, deps = {}) {
|
|
|
842
952
|
}
|
|
843
953
|
|
|
844
954
|
const { id, name, email } = gmailAccountIdentity(match);
|
|
845
|
-
|
|
955
|
+
await saveGoogleDefault(auth, id, ['gmail'], googleContext.getStore()?.business, { mailAccounts: accounts });
|
|
846
956
|
console.log(`now using ${name} (${email || 'email pending'})`);
|
|
847
957
|
}
|
|
848
958
|
|
|
959
|
+
const googleOldServer = 'calendar and drive accounts need the newest atris server.';
|
|
960
|
+
const googleProductNames = ['gmail', 'calendar', 'drive'];
|
|
961
|
+
|
|
962
|
+
function googleProducts(value) {
|
|
963
|
+
if (value == null) return null;
|
|
964
|
+
const products = String(value).split(',').map((part) => part.trim().toLowerCase() === 'mail' ? 'gmail' : part.trim().toLowerCase());
|
|
965
|
+
if (!products.length || products.some((part) => !googleProductNames.includes(part)) || new Set(products).size !== products.length) throw new Error('--only needs mail, calendar, or drive');
|
|
966
|
+
return products;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
function googleCache(defaults, business) {
|
|
970
|
+
const file = path.join(stateHome(), '.atris', 'google-accounts.json');
|
|
971
|
+
let cache = {};
|
|
972
|
+
try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
|
|
973
|
+
if (business) cache.businesses = { ...(cache.businesses || {}), [business]: defaults };
|
|
974
|
+
else cache.defaults = { ...(cache.defaults || {}), ...defaults };
|
|
975
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
976
|
+
fs.writeFileSync(file, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function googleFailure(result, action) {
|
|
980
|
+
console.error(`could not ${action}: ${result.error || result.data?.detail || 'request failed'}`);
|
|
981
|
+
process.exit(1);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
async function saveGoogleDefault(auth, selector, products, business, { mailAccounts, warnLegacy = false } = {}) {
|
|
985
|
+
const capability = await googleAccountsCapability(auth.token);
|
|
986
|
+
if (!capability.supported && !products.includes('gmail')) {
|
|
987
|
+
if (warnLegacy) console.log(googleOldServer);
|
|
988
|
+
return { id: String(selector), legacy: true };
|
|
989
|
+
}
|
|
990
|
+
const roster = capability.supported ? (Array.isArray(capability.data.accounts) ? capability.data.accounts : []) : (mailAccounts || await fetchGmailAccounts(auth.token));
|
|
991
|
+
const match = resolveAccountSelector(roster, selector, { displayName: !capability.supported });
|
|
992
|
+
if (!match && (String(selector).includes('@') || !capability.supported)) throw new Error('unknown google account');
|
|
993
|
+
const id = match ? gmailAccountIdentity(match).id : String(selector);
|
|
994
|
+
if (!capability.supported) {
|
|
995
|
+
if (warnLegacy) console.log(googleOldServer);
|
|
996
|
+
if (products.includes('gmail')) writeGmailStickyAccount(id);
|
|
997
|
+
return { id, legacy: true };
|
|
998
|
+
}
|
|
999
|
+
const url = business ? `/business/${encodeURIComponent(business)}/integrations/google` : '/integrations/google/defaults';
|
|
1000
|
+
const body = business ? { account_id: id } : { account_id: id, products };
|
|
1001
|
+
const options = { method: 'PUT', token: auth.token, body };
|
|
1002
|
+
if (business) options.headers = { 'X-Business-Id': business };
|
|
1003
|
+
const saved = await rawApiRequestJson(url, options);
|
|
1004
|
+
if (!saved.ok) return googleFailure(saved, 'save google default');
|
|
1005
|
+
googleCache(Object.fromEntries((business ? googleProductNames : products).map((product) => [product, id])), business);
|
|
1006
|
+
if (products.includes('gmail') && !business) {
|
|
1007
|
+
markMailMigration(mailMigrationRecord(auth));
|
|
1008
|
+
writeGmailStickyAccount(id);
|
|
1009
|
+
}
|
|
1010
|
+
return { id, legacy: false };
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function googleSignInRetry(name, products, business) {
|
|
1014
|
+
return ['atris google connect', name, products && `--only ${products.map((p) => p === 'gmail' ? 'mail' : p).join(',')}`, business ? `--business ${business}` : '--account'].filter(Boolean).join(' ');
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
function googleSignInFailure(result, { error, retry, status = 'error', extra = {} } = {}) {
|
|
1018
|
+
const data = result?.data && typeof result.data === 'object' && !Array.isArray(result.data) ? result.data : {};
|
|
1019
|
+
console.log(JSON.stringify({ ok: false, status, http_status: result?.status || 0, error: error || result?.error || 'google sign-in did not finish', retry, ...extra, ...data }, null, 2));
|
|
1020
|
+
return 1;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function googleSignInErrorLines(status, business) {
|
|
1024
|
+
const reason = String(status.error || 'unknown error').replace(/_/g, ' ').toLowerCase();
|
|
1025
|
+
const lines = [status.error === 'superseded'
|
|
1026
|
+
? 'the google connection changed before sign-in finished.'
|
|
1027
|
+
: `google sign-in did not finish: ${reason}.`];
|
|
1028
|
+
if (status.account_id) lines.push(`account: ${status.account_id}.`);
|
|
1029
|
+
if (Array.isArray(status.products)) {
|
|
1030
|
+
const products = status.products.map((p) => p === 'gmail' ? 'mail' : p);
|
|
1031
|
+
lines.push(products.length ? `products still connected: ${products.join(', ')}.` : 'no products remain connected.');
|
|
1032
|
+
} else lines.push('which products remain connected is unknown.');
|
|
1033
|
+
if (Array.isArray(status.failed) && status.failed.length) {
|
|
1034
|
+
const failed = status.failed.map((p) => p === 'gmail' ? 'mail' : p === 'result' ? 'saving the sign-in result' : p);
|
|
1035
|
+
lines.push(`could not finish: ${failed.join(', ')}.`);
|
|
1036
|
+
}
|
|
1037
|
+
if (Array.isArray(status.missing) && status.missing.length) lines.push(`google did not grant: ${status.missing.map((p) => p === 'gmail' ? 'mail' : p).join(', ')}.`);
|
|
1038
|
+
if (status.cleanup_incomplete) lines.push('some changes could not be undone.');
|
|
1039
|
+
if (status.business_assignment_unknown) lines.push('the business account choice may not have been saved.');
|
|
1040
|
+
const check = business ? `atris google accounts --business ${business}` : 'atris google accounts --account';
|
|
1041
|
+
lines.push(`check ${check} before trying again.`);
|
|
1042
|
+
return lines;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
async function googleConnect(name, products, business, deps = {}) {
|
|
1046
|
+
if (name && (!/^[a-z0-9-]{1,32}$/.test(name) || name === 'all')) throw new Error('invalid google account name. use 1 to 32 lowercase letters, numbers, or hyphens');
|
|
1047
|
+
const retry = googleSignInRetry(name, products, business);
|
|
1048
|
+
const token = await getAuthToken();
|
|
1049
|
+
let before;
|
|
1050
|
+
try { before = await googleAccountsCapability(token); }
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
if (!deps.json) throw error;
|
|
1053
|
+
return googleSignInFailure(error.result || { status: error.status }, { error: error.message, retry });
|
|
1054
|
+
}
|
|
1055
|
+
if (!before.supported) {
|
|
1056
|
+
if (!deps.quietOldServer && !deps.json) console.log(googleOldServer);
|
|
1057
|
+
if (!products || products.includes('gmail')) return gmailConnect(name, { ...deps, retry });
|
|
1058
|
+
if (deps.json) return googleSignInFailure({ status: before.status, data: before.data }, { error: googleOldServer, retry });
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
const body = {};
|
|
1062
|
+
if (name) body.account_id = name;
|
|
1063
|
+
if (products) body.products = products;
|
|
1064
|
+
if (business) body.business_id = business;
|
|
1065
|
+
const result = await apiRequestJson('/integrations/google/start', { method: 'POST', token, body });
|
|
1066
|
+
if (result.status === 404) {
|
|
1067
|
+
if (!deps.quietOldServer && !deps.json) console.log(googleOldServer);
|
|
1068
|
+
if (!products || products.includes('gmail')) return gmailConnect(name, { ...deps, retry });
|
|
1069
|
+
if (deps.json) return googleSignInFailure(result, { error: googleOldServer, retry });
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (!result.ok) {
|
|
1073
|
+
if (deps.json) return googleSignInFailure(result, { error: result.error || 'could not start google connection', retry });
|
|
1074
|
+
return googleFailure(result, 'start google connection');
|
|
1075
|
+
}
|
|
1076
|
+
const url = result.data?.auth_url;
|
|
1077
|
+
const state = result.data?.state;
|
|
1078
|
+
const missing = !url ? `the server did not return a sign-in url. start again with ${retry}.` : !state ? `the server did not return a sign-in state. start again with ${retry}.` : '';
|
|
1079
|
+
if (missing) {
|
|
1080
|
+
if (deps.json) return googleSignInFailure(result, { error: missing, retry });
|
|
1081
|
+
throw new Error(missing);
|
|
1082
|
+
}
|
|
1083
|
+
if (!(deps.openAuthUrl || openGmailAuthUrl)(url, deps)) (deps.json ? console.error : console.log)(`open this url in your browser: ${url}`);
|
|
1084
|
+
if (!deps.json) console.log('waiting for google sign-in...');
|
|
1085
|
+
const interval = deps.pollIntervalMs ?? GMAIL_CONNECT_POLL_MS;
|
|
1086
|
+
const timeout = deps.timeoutMs ?? GMAIL_CONNECT_TIMEOUT_MS;
|
|
1087
|
+
const count = Math.max(1, Math.ceil(timeout / interval));
|
|
1088
|
+
const now = deps.now || Date.now;
|
|
1089
|
+
const deadline = now() + timeout;
|
|
1090
|
+
const sleep = deps.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
1091
|
+
const id = result.data?.account_id || name;
|
|
1092
|
+
let lastFailure = null;
|
|
1093
|
+
for (let i = 0; i < count && now() < deadline; i += 1) {
|
|
1094
|
+
await sleep(Math.min(interval, Math.max(0, deadline - now())));
|
|
1095
|
+
if (now() >= deadline) break;
|
|
1096
|
+
const pollOptions = { method: 'GET', token, timeoutMs: Math.max(1, Math.min(interval, deadline - now())), retries: 0 };
|
|
1097
|
+
const check = await apiRequestJson(`/integrations/google/start/${encodeURIComponent(state)}`, pollOptions);
|
|
1098
|
+
if (check.status === 404) {
|
|
1099
|
+
if (deps.json) return googleSignInFailure(check, { error: 'sign_in_expired', retry });
|
|
1100
|
+
console.error(`google sign-in expired. start again with ${retry}.`);
|
|
1101
|
+
process.exit(1);
|
|
1102
|
+
}
|
|
1103
|
+
if (!check.ok) {
|
|
1104
|
+
if (check.status === 401 || check.status === 403) {
|
|
1105
|
+
if (deps.json) return googleSignInFailure(check, { error: check.error || 'could not check google sign-in', retry });
|
|
1106
|
+
return googleFailure(check, 'check google sign-in');
|
|
1107
|
+
}
|
|
1108
|
+
lastFailure = check;
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
lastFailure = null;
|
|
1112
|
+
const status = check.data || {};
|
|
1113
|
+
if (status.status === 'error') {
|
|
1114
|
+
if (deps.json) return googleSignInFailure(check, { retry });
|
|
1115
|
+
for (const line of googleSignInErrorLines(status, business)) console.error(line);
|
|
1116
|
+
process.exit(1);
|
|
1117
|
+
}
|
|
1118
|
+
if (status.status === 'connected') {
|
|
1119
|
+
if (deps.json) { console.log(JSON.stringify({ ok: true, ...status }, null, 2)); return; }
|
|
1120
|
+
const accountId = status.account_id || id || 'account';
|
|
1121
|
+
const identity = status.email || accountId;
|
|
1122
|
+
const granted = Array.isArray(status.products) ? status.products : [];
|
|
1123
|
+
const missing = Array.isArray(status.missing) ? status.missing : [];
|
|
1124
|
+
console.log(`connected ${identity} as ${accountId}${granted.length ? `: ${granted.map((p) => p === 'gmail' ? 'mail' : p).join(', ')}` : ''}`);
|
|
1125
|
+
if (missing.length) console.log(`google did not grant: ${missing.map((p) => p === 'gmail' ? 'mail' : p).join(', ')}`);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
if (deps.json) return googleSignInFailure(lastFailure, { status: 'pending', error: 'sign_in_unconfirmed', retry, extra: { auth_url: url } });
|
|
1130
|
+
console.error(`connection could not be confirmed. finish in your browser and check atris google: ${url}`);
|
|
1131
|
+
process.exit(1);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function gmailConnectUnified(name, deps = {}) {
|
|
1135
|
+
const id = String(name || 'default').trim();
|
|
1136
|
+
if (!/^[a-z0-9-]{1,32}$/.test(id)) {
|
|
1137
|
+
console.error('invalid gmail account name. use 1 to 32 lowercase letters, numbers, or hyphens.');
|
|
1138
|
+
process.exit(1);
|
|
1139
|
+
}
|
|
1140
|
+
return googleConnect(id, ['gmail'], null, { ...deps, quietOldServer: true });
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
async function googleCommand(subcommand, ...args) {
|
|
1144
|
+
if ([subcommand, ...args].some((arg) => ['help', '--help', '-h'].includes(arg))) {
|
|
1145
|
+
console.log('usage: atris google [accounts|connect|use|disconnect] [name] [--only mail,calendar,drive] [--business slug]');
|
|
1146
|
+
console.log('use --json to list accounts or report a sign-in result.');
|
|
1147
|
+
console.log('disconnect requires --approved.');
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
if (subcommand && subcommand.startsWith('-')) { args.unshift(subcommand); subcommand = undefined; }
|
|
1151
|
+
const context = takeContext(args);
|
|
1152
|
+
return googleContext.run(context, async () => {
|
|
1153
|
+
const rest = context.rest;
|
|
1154
|
+
let only; let approved = false; let json = false; const positional = [];
|
|
1155
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1156
|
+
if (rest[i] === '--only') { if (!rest[i + 1] || String(rest[i + 1]).startsWith('--')) throw new Error('--only needs mail, calendar, or drive'); only = googleProducts(rest[++i]); }
|
|
1157
|
+
else if (rest[i].startsWith('--only=')) only = googleProducts(rest[i].slice(7));
|
|
1158
|
+
else if (rest[i] === '--json') json = true;
|
|
1159
|
+
else if (rest[i] === '--approved' || rest[i] === '--confirm-approved') approved = true;
|
|
1160
|
+
else if (rest[i].startsWith('-')) throw new Error(`unknown option ${rest[i]}`);
|
|
1161
|
+
else positional.push(rest[i]);
|
|
1162
|
+
}
|
|
1163
|
+
const action = subcommand || 'accounts';
|
|
1164
|
+
if (json && !['accounts', 'connect'].includes(action)) throw new Error('--json is available for google accounts and connect');
|
|
1165
|
+
if (action === 'connect') return googleConnect(positional[0], only, context.explicitBusiness ? context.business : null, { json });
|
|
1166
|
+
if (!['accounts', 'use', 'disconnect'].includes(action)) {
|
|
1167
|
+
console.log('usage: atris google [accounts|connect|use|disconnect] [name] [--only mail,calendar,drive] [--business slug]'); return;
|
|
1168
|
+
}
|
|
1169
|
+
if (action !== 'accounts' && !positional[0]) throw new Error(`usage: atris google ${action} <name or email>`);
|
|
1170
|
+
if (action === 'disconnect' && !approved) throw new Error('disconnect needs --approved after the account and products are confirmed');
|
|
1171
|
+
const auth = await getAuth();
|
|
1172
|
+
const token = auth.token;
|
|
1173
|
+
if (action === 'accounts') {
|
|
1174
|
+
const capability = await googleAccountsCapability(token);
|
|
1175
|
+
if (!capability.supported) {
|
|
1176
|
+
if (json) {
|
|
1177
|
+
const accounts = await fetchGmailAccounts(token);
|
|
1178
|
+
console.log(JSON.stringify({ accounts, defaults: {}, resolved: {}, business: null, legacy: true, notice: googleOldServer }, null, 2));
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
console.log(googleOldServer); return gmailAccounts();
|
|
1182
|
+
}
|
|
1183
|
+
const data = capability.data;
|
|
1184
|
+
const accounts = data.accounts.map((row) => ({ ...row, products: Object.fromEntries(Object.entries(row.products || {}).map(([product, value]) => {
|
|
1185
|
+
const reconnect = value?.reauth_required && googleProductNames.includes(product)
|
|
1186
|
+
? `atris google connect ${row.account_id} --only ${product === 'gmail' ? 'mail' : product} --account` : null;
|
|
1187
|
+
return [product, reconnect ? { ...value, reconnect } : value];
|
|
1188
|
+
})) }));
|
|
1189
|
+
if (json) { console.log(JSON.stringify({ ...data, accounts }, null, 2)); return; }
|
|
1190
|
+
if (!data.accounts?.length) console.log('no connected accounts.');
|
|
1191
|
+
for (const row of accounts) {
|
|
1192
|
+
const products = googleProductNames.filter((p) => row.products?.[p]?.connected || row.products?.[p]?.reauth_required).map((p) => `${p === 'gmail' ? 'mail' : p}${row.products[p].reauth_required ? ' needs reconnecting' : ''}`);
|
|
1193
|
+
console.log(`${row.account_id}: ${row.email || 'email pending'} (${products.join(', ') || 'no products'})`);
|
|
1194
|
+
for (const p of googleProductNames) if (row.products?.[p]?.reconnect) console.log(` reconnect ${p === 'gmail' ? 'mail' : p}: ${row.products[p].reconnect}`);
|
|
1195
|
+
}
|
|
1196
|
+
for (const product of googleProductNames) console.log(`${product === 'gmail' ? 'mail' : product} default: ${data.defaults?.[product] || 'automatic'}`);
|
|
1197
|
+
if (data.business) console.log(`business account: ${data.business.account_id || 'automatic'}`);
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
const id = positional[0];
|
|
1201
|
+
if (action === 'use' && context.business && only && only.length !== 3) throw new Error('a business has one account for all products, leave off --only');
|
|
1202
|
+
if (action === 'disconnect') {
|
|
1203
|
+
const suffix = only ? `?${new URLSearchParams({ products: only.join(',') })}` : '';
|
|
1204
|
+
const result = await apiRequestJson(`/integrations/google/accounts/${encodeURIComponent(id)}${suffix}`, { method: 'DELETE', token });
|
|
1205
|
+
if (result.status === 404) {
|
|
1206
|
+
const probe = await googleAccountsCapability(token);
|
|
1207
|
+
if (probe.supported) return googleFailure(result, 'disconnect google account');
|
|
1208
|
+
console.log(googleOldServer);
|
|
1209
|
+
if (only && !only.includes('gmail')) return;
|
|
1210
|
+
const accounts = await fetchGmailAccounts(token);
|
|
1211
|
+
const match = resolveAccountSelector(accounts, id, { displayName: true });
|
|
1212
|
+
if (!match) throw new Error('unknown gmail account');
|
|
1213
|
+
const mailId = gmailAccountIdentity(match).id;
|
|
1214
|
+
const removed = await apiRequestJson(`/integrations/gmail?account_id=${encodeURIComponent(mailId)}`, { method: 'DELETE', token });
|
|
1215
|
+
if (!removed.ok) return googleFailure(removed, 'disconnect mail');
|
|
1216
|
+
console.log(`disconnected mail for ${mailId}`); return;
|
|
1217
|
+
}
|
|
1218
|
+
if (!result.ok) return googleFailure(result, 'disconnect google account');
|
|
1219
|
+
console.log(`disconnected ${id}: ${(result.data?.removed || only || googleProductNames).map((p) => p === 'gmail' ? 'mail' : p).join(', ')}`); return;
|
|
1220
|
+
}
|
|
1221
|
+
const products = only || googleProductNames;
|
|
1222
|
+
const saved = await saveGoogleDefault(auth, id, products, context.business, { warnLegacy: true });
|
|
1223
|
+
if (saved.legacy) {
|
|
1224
|
+
if (products.includes('gmail')) console.log(`using ${saved.id}: mail`);
|
|
1225
|
+
return;
|
|
1226
|
+
}
|
|
1227
|
+
const chosen = saved.id;
|
|
1228
|
+
console.log(`using ${chosen}${context.business ? ' for this business' : ''}: ${products.map((p) => p === 'gmail' ? 'mail' : p).join(', ')}`);
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
|
|
849
1232
|
async function gmailCommand(subcommand, ...args) {
|
|
1233
|
+
const context = takeContext(args);
|
|
1234
|
+
return googleContext.run(context, async () => {
|
|
1235
|
+
const needsDefault = ['inbox', 'list', 'read', 'archive', 'triage', 'send', 'voice', 'accounts'].includes(subcommand);
|
|
1236
|
+
const hasVoiceAccount = subcommand === 'voice' && context.rest.some((arg) => !String(arg).startsWith('--'));
|
|
1237
|
+
let valid = true;
|
|
1238
|
+
if (subcommand === 'send') {
|
|
1239
|
+
const parsed = parseGmailSendArgs(context.rest);
|
|
1240
|
+
valid = parsed.approved;
|
|
1241
|
+
} else if (subcommand === 'read' || subcommand === 'archive') {
|
|
1242
|
+
valid = context.rest.some((arg) => !String(arg).startsWith('--'));
|
|
1243
|
+
}
|
|
1244
|
+
if (needsDefault && valid && context.mailAccount && context.mailAccount.includes('@')) {
|
|
1245
|
+
const auth = await getAuth();
|
|
1246
|
+
const capability = await googleAccountsCapability(auth.token);
|
|
1247
|
+
const accounts = capability.supported ? (capability.data.accounts || []) : await fetchGmailAccounts(auth.token);
|
|
1248
|
+
const match = resolveAccountSelector(accounts, context.mailAccount);
|
|
1249
|
+
if (!match) throw new Error('unknown gmail account');
|
|
1250
|
+
context.mailAccount = gmailAccountIdentity(match).id;
|
|
1251
|
+
const index = context.rest.indexOf('--account');
|
|
1252
|
+
if (index >= 0) context.rest[index + 1] = context.mailAccount;
|
|
1253
|
+
}
|
|
1254
|
+
if (needsDefault && valid && !context.mailAccount && !hasVoiceAccount) await prepareMailDefaults(context, { migrate: subcommand !== 'accounts' });
|
|
1255
|
+
return gmailCommandInner(subcommand, ...context.rest);
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function gmailCommandInner(subcommand, ...args) {
|
|
850
1260
|
switch (subcommand) {
|
|
851
1261
|
case 'inbox':
|
|
852
1262
|
case 'list': {
|
|
@@ -885,7 +1295,7 @@ async function gmailCommand(subcommand, ...args) {
|
|
|
885
1295
|
break;
|
|
886
1296
|
}
|
|
887
1297
|
case 'connect':
|
|
888
|
-
await
|
|
1298
|
+
await gmailConnectUnified(args[0]);
|
|
889
1299
|
break;
|
|
890
1300
|
case 'accounts':
|
|
891
1301
|
await gmailAccounts();
|
|
@@ -946,6 +1356,30 @@ function isDateOnly(value) {
|
|
|
946
1356
|
return /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
|
|
947
1357
|
}
|
|
948
1358
|
|
|
1359
|
+
// All-day dates start at local midnight; invalid or missing dates sort last.
|
|
1360
|
+
function compareCalendarEvents(left, right) {
|
|
1361
|
+
const instant = (event) => {
|
|
1362
|
+
const value = calendarEventTimeValue(event.start || event.start_time || event.startTime);
|
|
1363
|
+
if (!value) return NaN;
|
|
1364
|
+
if (isDateOnly(value)) {
|
|
1365
|
+
const parsed = new Date(`${value}T00:00:00`);
|
|
1366
|
+
if (Number.isNaN(parsed.getTime()) || `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}` !== value) return NaN;
|
|
1367
|
+
return parsed.getTime();
|
|
1368
|
+
}
|
|
1369
|
+
return Date.parse(value);
|
|
1370
|
+
};
|
|
1371
|
+
const a = instant(left), b = instant(right);
|
|
1372
|
+
if (!Number.isFinite(a)) return Number.isFinite(b) ? 1 : 0;
|
|
1373
|
+
if (!Number.isFinite(b)) return -1;
|
|
1374
|
+
return a - b;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function calendarReadMetadata(data) {
|
|
1378
|
+
const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
|
|
1379
|
+
const errors = Array.isArray(data?.errors) ? data.errors : [];
|
|
1380
|
+
return { accounts, errors, partial: errors.length > 0 };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
949
1383
|
function formatCalendarTimeRange(event) {
|
|
950
1384
|
const start = calendarEventTimeValue(event.start || event.start_time || event.startTime);
|
|
951
1385
|
const end = calendarEventTimeValue(event.end || event.end_time || event.endTime);
|
|
@@ -968,11 +1402,13 @@ function formatCalendarEvents(label, events) {
|
|
|
968
1402
|
|
|
969
1403
|
console.log('─'.repeat(50));
|
|
970
1404
|
|
|
1405
|
+
const multipleAccounts = new Set(events.map((event) => event.account_id).filter(Boolean)).size > 1;
|
|
971
1406
|
for (const event of events) {
|
|
972
1407
|
const time = formatCalendarTimeRange(event);
|
|
973
1408
|
const title = event.summary || '(no title)';
|
|
974
1409
|
|
|
975
|
-
|
|
1410
|
+
const account = multipleAccounts ? ` (${event.account_email || event.account_id || 'unknown'})` : '';
|
|
1411
|
+
console.log(`${time} ${title}${account}`);
|
|
976
1412
|
if (event.location) {
|
|
977
1413
|
console.log(` 📍 ${event.location}`);
|
|
978
1414
|
}
|
|
@@ -1052,6 +1488,8 @@ function compactCalendarEvent(event = {}) {
|
|
|
1052
1488
|
summary: event.summary || event.title || '(no title)',
|
|
1053
1489
|
location: event.location || '',
|
|
1054
1490
|
htmlLink: event.htmlLink || event.link || '',
|
|
1491
|
+
...(event.account_id ? { account_id: event.account_id } : {}),
|
|
1492
|
+
...(event.account_email ? { account_email: event.account_email } : {}),
|
|
1055
1493
|
};
|
|
1056
1494
|
}
|
|
1057
1495
|
|
|
@@ -1062,6 +1500,9 @@ function calendarCachePacket(events = [], meta = {}) {
|
|
|
1062
1500
|
source: meta.source || 'live',
|
|
1063
1501
|
days: meta.days || null,
|
|
1064
1502
|
count: events.length,
|
|
1503
|
+
accounts: meta.accounts || [],
|
|
1504
|
+
errors: meta.errors || [],
|
|
1505
|
+
partial: Boolean(meta.partial),
|
|
1065
1506
|
events: events.map(compactCalendarEvent),
|
|
1066
1507
|
};
|
|
1067
1508
|
}
|
|
@@ -1085,6 +1526,9 @@ function readCalendarEventsCache() {
|
|
|
1085
1526
|
ok: true,
|
|
1086
1527
|
path: CALENDAR_CACHE_PATH,
|
|
1087
1528
|
updatedAt: packet.updatedAt || '',
|
|
1529
|
+
accounts: Array.isArray(packet.accounts) ? packet.accounts : [],
|
|
1530
|
+
errors: Array.isArray(packet.errors) ? packet.errors : [],
|
|
1531
|
+
partial: Boolean(packet.partial || packet.errors?.length),
|
|
1088
1532
|
events,
|
|
1089
1533
|
};
|
|
1090
1534
|
} catch {
|
|
@@ -1099,6 +1543,9 @@ function formatCalendarSearchResults(query, matches, {
|
|
|
1099
1543
|
liveOk = true,
|
|
1100
1544
|
cacheUpdatedAt = '',
|
|
1101
1545
|
error = '',
|
|
1546
|
+
accounts = [],
|
|
1547
|
+
errors = [],
|
|
1548
|
+
partial = false,
|
|
1102
1549
|
} = {}) {
|
|
1103
1550
|
if (json) {
|
|
1104
1551
|
console.log(JSON.stringify({
|
|
@@ -1108,6 +1555,9 @@ function formatCalendarSearchResults(query, matches, {
|
|
|
1108
1555
|
liveOk,
|
|
1109
1556
|
cacheUpdatedAt,
|
|
1110
1557
|
error,
|
|
1558
|
+
accounts,
|
|
1559
|
+
errors,
|
|
1560
|
+
partial: Boolean(partial || errors.length),
|
|
1111
1561
|
totalEvents: total,
|
|
1112
1562
|
matchCount: matches.length,
|
|
1113
1563
|
matches: matches.map(compactCalendarEvent),
|
|
@@ -1167,8 +1617,10 @@ async function calendarRange(label, { timeMin, timeMax, days } = {}) {
|
|
|
1167
1617
|
process.exit(1);
|
|
1168
1618
|
}
|
|
1169
1619
|
|
|
1170
|
-
const events = result.data?.events || result.data
|
|
1171
|
-
|
|
1620
|
+
const events = [...(result.data?.events || (Array.isArray(result.data) ? result.data : []))].sort(compareCalendarEvents);
|
|
1621
|
+
for (const error of result.data?.errors || []) console.error(`calendar ${error.account_id || 'account'}: ${error.message || error.code || 'could not read events'}`);
|
|
1622
|
+
const meta = calendarReadMetadata(result.data);
|
|
1623
|
+
if (!googleContext.getStore()?.calendarAccount && !googleContext.getStore()?.business) writeCalendarEventsCache(events, { source: 'calendarRange', days: days || null, ...meta });
|
|
1172
1624
|
formatCalendarEvents(label.toLowerCase(), events);
|
|
1173
1625
|
}
|
|
1174
1626
|
|
|
@@ -1195,7 +1647,7 @@ async function calendarSearch(args = []) {
|
|
|
1195
1647
|
|
|
1196
1648
|
if (!result.ok) {
|
|
1197
1649
|
const cache = readCalendarEventsCache();
|
|
1198
|
-
if (json && cache.ok) {
|
|
1650
|
+
if (json && cache.ok && !googleContext.getStore()?.calendarAccount && !googleContext.getStore()?.business) {
|
|
1199
1651
|
const matches = filterCalendarEventsForSearch(cache.events, query, limit);
|
|
1200
1652
|
formatCalendarSearchResults(query, matches, {
|
|
1201
1653
|
json,
|
|
@@ -1204,6 +1656,7 @@ async function calendarSearch(args = []) {
|
|
|
1204
1656
|
liveOk: false,
|
|
1205
1657
|
cacheUpdatedAt: cache.updatedAt,
|
|
1206
1658
|
error: result.error || 'Failed to fetch events',
|
|
1659
|
+
accounts: cache.accounts, errors: cache.errors, partial: cache.partial,
|
|
1207
1660
|
});
|
|
1208
1661
|
return;
|
|
1209
1662
|
}
|
|
@@ -1219,10 +1672,12 @@ async function calendarSearch(args = []) {
|
|
|
1219
1672
|
return;
|
|
1220
1673
|
}
|
|
1221
1674
|
|
|
1222
|
-
const events = result.data?.events || result.data
|
|
1223
|
-
|
|
1675
|
+
const events = [...(result.data?.events || (Array.isArray(result.data) ? result.data : []))].sort(compareCalendarEvents);
|
|
1676
|
+
for (const error of result.data?.errors || []) console.error(`calendar ${error.account_id || 'account'}: ${error.message || error.code || 'could not read events'}`);
|
|
1677
|
+
const meta = calendarReadMetadata(result.data);
|
|
1678
|
+
if (!googleContext.getStore()?.calendarAccount && !googleContext.getStore()?.business) writeCalendarEventsCache(events, { source: 'calendarSearch', days, ...meta });
|
|
1224
1679
|
const matches = filterCalendarEventsForSearch(events, query, limit);
|
|
1225
|
-
formatCalendarSearchResults(query, matches, { json, total: events.length, source: 'live', liveOk:
|
|
1680
|
+
formatCalendarSearchResults(query, matches, { json, total: events.length, source: 'live', liveOk: !meta.partial, error: meta.partial ? 'some calendar accounts could not be read' : '', ...meta });
|
|
1226
1681
|
}
|
|
1227
1682
|
|
|
1228
1683
|
async function calendarToday() {
|
|
@@ -1291,12 +1746,39 @@ async function calendarAdd(args) {
|
|
|
1291
1746
|
}
|
|
1292
1747
|
|
|
1293
1748
|
const created = result.data || {};
|
|
1294
|
-
|
|
1749
|
+
const createdAccount = created.account_email || created.account_id;
|
|
1750
|
+
console.log(`Created: ${created.summary || summary}${createdAccount ? ` (${createdAccount})` : ''}`);
|
|
1751
|
+
if (!createdAccount && (googleContext.getStore()?.business || googleContext.getStore()?.calendarAccount)) console.log('account not reported by the server.');
|
|
1295
1752
|
if (created.htmlLink) console.log(`Link: ${created.htmlLink}`);
|
|
1296
1753
|
console.log(`Verified on calendar: ${created.verified ? 'yes' : 'no'}`);
|
|
1297
1754
|
}
|
|
1298
1755
|
|
|
1299
1756
|
async function calendarCommand(subcommand, ...args) {
|
|
1757
|
+
const context = takeContext(args);
|
|
1758
|
+
if ((subcommand === 'add' || subcommand === 'create') && context.calendarAccount === 'all') throw new Error('calendar writes need one account');
|
|
1759
|
+
const clean = context.rest.filter((arg, index, array) => arg !== '--account' && array[index - 1] !== '--account');
|
|
1760
|
+
return googleContext.run(context, async () => {
|
|
1761
|
+
if ((context.business || context.calendarAccount) && ['today', 'yesterday', 'yday', 'week', 'date', 'search', 'add', 'create'].includes(subcommand)) {
|
|
1762
|
+
try {
|
|
1763
|
+
const token = await getAuthToken();
|
|
1764
|
+
const capability = await googleAccountsCapability(token);
|
|
1765
|
+
if (!capability.supported) {
|
|
1766
|
+
const error = new Error(googleOldServer);
|
|
1767
|
+
error.status = 404;
|
|
1768
|
+
throw error;
|
|
1769
|
+
}
|
|
1770
|
+
} catch (error) {
|
|
1771
|
+
if (subcommand !== 'search' || !clean.includes('--json')) throw error;
|
|
1772
|
+
const { query } = parseCalendarSearchArgs(clean);
|
|
1773
|
+
formatCalendarSearchError(query, error.message, { json: true, status: error.status || 0 });
|
|
1774
|
+
return 1;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
return calendarCommandInner(subcommand, ...clean);
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
async function calendarCommandInner(subcommand, ...args) {
|
|
1300
1782
|
switch (subcommand) {
|
|
1301
1783
|
case 'today':
|
|
1302
1784
|
await calendarToday();
|
|
@@ -2409,6 +2891,9 @@ async function integrationsStatus(args = []) {
|
|
|
2409
2891
|
}
|
|
2410
2892
|
|
|
2411
2893
|
module.exports = {
|
|
2894
|
+
googleCommand,
|
|
2895
|
+
googleConnect,
|
|
2896
|
+
gmailConnectUnified,
|
|
2412
2897
|
gmailCommand,
|
|
2413
2898
|
gmailConnect,
|
|
2414
2899
|
gmailVoice,
|