atris 3.74.29 → 3.75.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.
@@ -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 findGmailAccount(accounts, accountId) {
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
- return findGmailAccount(accounts, wanted)
268
- || accounts.find((account) => gmailAccountIdentity(account).name === wanted)
269
- || null;
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 = findGmailAccount(accounts, accountId);
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 = findGmailAccount(accounts, accountId);
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 = findGmailAccount(accounts, resolvedAccountId);
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
- if (!openAuthUrl(authUrl, deps)) {
757
- console.log(`open this url in your browser: ${authUrl}`);
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
- console.log(`waiting for gmail account ${accountId}...`);
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.error(`connection timed out. finish in your browser: ${authUrl}`);
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 token = await getAuthToken();
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 = findGmailAccountForUse(accounts, selector);
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
- writeGmailStickyAccount(id);
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 gmailConnect(args[0]);
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
- console.log(`${time} ${title}`);
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
- writeCalendarEventsCache(events, { source: 'calendarRange', days: days || null });
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
- writeCalendarEventsCache(events, { source: 'calendarSearch', days });
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: true });
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
- console.log(`Created: ${created.summary || summary}`);
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,