@mcpher/gas-fakes 2.2.3 → 2.2.4

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.
@@ -1,4 +1,4 @@
1
1
  {
2
- "token": "EwBIBMl6BAAU9BatlgMxts2T1B5e3Mucgfs4jcAAAbOrFhh1DT9JCqHVvaxoNywXCmplxIRVetyLelc3EqF4pkc+xDtUROr/cPZLcma18ccSkLGBiLR0o6a2WF56j74Nry+iyd3laxPf5Ih7Pz10JZyxeK1gdoVflReeCqE08g0C9gUQw8V0s7S/hQmVdy304vlAaeAm+EbuzX4ApDd2/TvqFuiVlAHjNihBg/mkubYhJnsCOfvPWH+lokQ2d5hL3E/24FQuqE+C/4vspC87Ez6QASXit/Mvd7Cf3pAQIFYroM4yfNe8jQ3FTG+S/S7b/vVJXUYF4joomDkexVsp53Z6EgHvTifHe1Vai13kcKn885hhWC+NOaYpXpW4sqAQZgAAEI+JST+25WEaGMjW83FcpHoQA+WeXVDhjZFqXdnCt7zXBnhJylglnRyRdA3AdAqu/RNPJoBYqmS7v2Db7cIu7aG7nTod/RvrNXrjewvfbVlgwqBCkVMTVmRwbZl+fQa1NMjYr457YGUmfub/hF/z6FwWJmFSSSQnXCmf3LZZI6K3z19NAD4tNGBDU4z0JtS0hr0hQTW4hp/3unpz9S36dbIllqzZr9FI9Q6Q/bJ1mGNxrAarvqyaFS4RKpmoyWYOMTt49E/hQXs05VQtO5Gwq805tAgBqzeakmPLBphHAxi3+frd0T1JrrZu1YQJud68qDxHFaizNKIJHBk1CmaPQvBDbwKUa9Sa9Gr4/tLvn+TcgaZNjmcZk/Jr6XRLrAVV5wx+ID7UduAKoyER6nRY4Lhq3b2d2Fjj4+AUpL+ljuYOJz+s5wT6j8fbMG7VXb7bTowST4QQCsC8MNYNKNii/xsigr5NPiHAmV9kWE2XZ7nj0n29LWHkVotHuWcn0R4BWypDry/ZD852kOZ2QCZW3ssniSGW2fzXGMKHQ7oi/pWiS6RaDM+dxvFPgxYNYogEopddgyyZ57B2ONOIcysQL2lRR3dxctM6+1QTykB4vQXe6QYk35uFgKaglz2PEu7byCBcpSBaUQ97XQQPeQW8Mn8jL7imytUzGqE23y28WxZVRPpNG+ffZWG0wCqQ68kdn2xn+BkRRqClGCIm2shHaY0fRVkkNgkpiHtby28eITyUWfpJ7rNjmG8VsVNCGAM3gu84siIg+QDaOUpvuTd9uAeemWun9l38ysiifbgWkQBqy1vGIVljhNfew0QpfMNuCY8ayElb2LWqi11y8ZvLvQEvEGYJ0YfdzeHzPGS/Rs4gTl5IrWLgaD3AB4Oj3mShEYUtgNEssOnejVGOTUDEVdZKEw0c2uu+xyfW3+FjtsDDWqy8Mbsiqrsdty5O5oFTyXrNU6wcallx/qjXHNHbQBnqP6yskCZxYZW3NIm4e8guz/oOcPpEECRNROivvnaJkz4hxdYhoufTrFtvdUTC6Mw3H/QDHL2zeYQvucH3Virws+ZQAw==",
3
- "expiresOn": 1773398645000
2
+ "token": "EwBIBMl6BAAU9BatlgMxts2T1B5e3Mucgfs4jcAAAQbE+FWxF2964+qK5G5uaQ3pUy/MCJC0oOchzzIsAfEdZe5onY2EsmZQ4PATW2I6PuEagYUz7AKj565RuN8TB7q1URYyKGIajrSzsf1QSnzdOZqMr+dssABUDNjK+u8IEJViuglmAwJOIRzxRVsu3/cVoNzAi1V/RWmeQPD/BR7lUhmmNbQ0YYvlooW5GDu5M+vk3YZiz3ejDeHmFTJiMwHDohEKesRv4sVDXiFwukVOXtQ5C1ZRRt4y7zWJJyrIwHlmOYrhB33J9pa4xyRH7jXeNk04rnvTnFCLnrYLWqdlzOyWlJDzOQi7Y6rOubBMgkflSCk/cVLR6MO3YXQL5LkQZgAAEORwrlKhRbHoA3g6b8T1IkAQA99XwtXwfOoj8PC9RMUXN4qVo0gWDk1Lt/bsl9eqCQNkeXi/Li9mdnt+SLJa0DW50Gpk4OOVdH2SZ96d7aAUA30oLLsi5rdUD1TrjWLPO4uYeqvVOsT61whhVQJNHKsVQQcG3Cmm3iWGl+c7G0K/QZuNNvTNKh6GynIyNDJwVNRuk8FbqkC+WA2GnolDjstc28D2MZpFRQ7A0fG9Ik4tZUNtmUHWLKn6SQum6QVqbUGo4dayFwD/kYEUOYi/YVLmBZyVSpZAjbTFTNZqRp8rZzB3XOD7+ih362c/6ngEp7SpD55lLS0rtQUrByDQNXuLKcRjfNTv39YhgwGvDX9pARUK7Xu18t3gBt593nl3eDojClyCwlIDVzxR7UUGyZH1xYojiCPvWqsvnf6hxZZXDbNYWZt4xs/kjnKCr/5A7C5V8990/Zpxe52JbHDMxbCqoyl8L3CpTVymAyuP3PtwDXUBJfD20kvSNGfH6UiTX5uwCUr7MbhEwzAAnPehDVQcOFJ928WDWA3GPEOnK4+sQWl3M6W8CZPXGW3oHj8BraOu5yS226zT9MOQJSXPAiuFTaFOPsX+MJZqYectkSuJUa2ZbKYtSntbS3LAkv0bukTgDz+CwfXa/Sqq5dZtfx4CB1hM+fUsmBbcSQEi6BJnYpfyhCkU26NfTRhZv7sjbP9TGUAGtMuOHZSwxqflPAr2EdeYcaRt68pwQAtwrFQAkyeS9QiDItpVE3FMS6neWd07Yhua/ZIYzd2U8aSUQuHaEb74nqt2IvWZkUAkTA1RhRxDtSGtLEv3drrrLCkRSOP8gnCxJqVvMZz6nkXUI5lJIY57XP9n72n05nXvrSDMSuEi7fAHTgX0B80yAZywVSQgtBs/IPrkhV5ih16Py6CC5kf3mQBviITgBdgScvTi0FxAfpDRPNVTHrCCmvPGy4XG5d01YAkZh1OwIaZ/M01GspinBgVLVpH5wdPUnwYIOMHNk956KWQmDCU2TLWQv/kM2e898XIk0cmQ8PTUukOwP46Qjx1n9ZkRMoxvtwd6bAJMAw==",
3
+ "expiresOn": 1773667833000
4
4
  }
package/README.md CHANGED
@@ -161,6 +161,13 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
161
161
 
162
162
  ## <img src="./logo.png" alt="gas-fakes logo" width="50" align="top"> Further Reading
163
163
 
164
+ ## Watch the video
165
+
166
+ [![Watch the video](introvideo.png)](https://youtu.be/oEjpIrkYpEM)
167
+
168
+ ## Read more docs
169
+
170
+ - [gas fakes intro video](https://youtu.be/oEjpIrkYpEM)
164
171
  - [getting started](GETTING_STARTED.md) - how to handle authentication for restricted scopes.
165
172
  - [readme](README.md)
166
173
  - [gas fakes cli](gas-fakes-cli.md)
package/introvideo.png ADDED
Binary file
package/package.json CHANGED
@@ -26,12 +26,6 @@
26
26
  "unzipper": "^0.12.3",
27
27
  "zod": "^4.3.6"
28
28
  },
29
- "overrides": {
30
- "glob": "^13.0.0",
31
- "rimraf": "^6.1.3",
32
- "minimatch": "^10.2.1",
33
- "node-domexception": "^1.0.0"
34
- },
35
29
  "type": "module",
36
30
  "scripts": {
37
31
  "pub": "npm publish --access public",
@@ -41,7 +35,7 @@
41
35
  },
42
36
  "name": "@mcpher/gas-fakes",
43
37
  "author": "bruce mcpherson",
44
- "version": "2.2.3",
38
+ "version": "2.2.4",
45
39
  "license": "MIT",
46
40
  "main": "main.js",
47
41
  "description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
package/src/cli/app.js CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  enableGoogleAPIs,
11
11
  } from "./setup.js";
12
12
  import { startMcpServer } from "./mcp.js";
13
+ import { Platforms, PlatformDefaults } from "../services/enums/platformenums.js";
13
14
 
14
15
  export async function main() {
15
16
  const program = new Command();
@@ -121,11 +122,12 @@ export async function main() {
121
122
  });
122
123
 
123
124
  // --- Setup Commands ---
125
+ const validPlatforms = Object.values(Platforms).join(", ");
124
126
  program
125
127
  .command("init")
126
128
  .description("Initializes the configuration (.env).")
127
129
  .option("-e, --env <path>", "Path to a custom .env file.")
128
- .option("-b, --backends <string...>", "List of backends to initialize (google, ksuite).", ["google"])
130
+ .option("-b, --backends <string...>", `List of backends to initialize (${validPlatforms}).`, [PlatformDefaults.DEFAULT])
129
131
  .addOption(
130
132
  new Option(
131
133
  "--at,--auth-type <string>",
@@ -139,7 +141,7 @@ export async function main() {
139
141
  program
140
142
  .command("auth")
141
143
  .description("Runs the authentication flow for a backend.")
142
- .option("-b, --backend <string>", "Backend to authenticate (google, ksuite).", "google")
144
+ .option("-b, --backend <string>", `Backend to authenticate (${validPlatforms}). Defaults to configured platforms.`)
143
145
  .action(authenticateUser);
144
146
 
145
147
  program
package/src/cli/setup.js CHANGED
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
7
7
  import { execSync } from "child_process";
8
8
  import { checkForGcloudCli, checkForAzCli, runCommandSync } from "./utils.js";
9
9
  import { getMsGraphToken, mapGasScopesToMsGraph } from "../support/msgraph/msauth.js";
10
+ import { Platforms, PlatformDefaults } from "../services/enums/platformenums.js";
10
11
 
11
12
  // --- Utility Functions ---
12
13
 
@@ -32,6 +32,32 @@ export class FakeDriveMeta {
32
32
  constructor(meta) {
33
33
  this.meta = meta
34
34
  this.__gas_fake_service = "DriveApp"
35
+ // The resource platform takes precedence over ScriptApp.__platform.
36
+ // ScriptApp.__platform is only used to determine the backend for new resources.
37
+ this.platform = ScriptApp.__platform || "google"
38
+ }
39
+
40
+ /**
41
+ * Internal helper to get the platform ID.
42
+ * @returns {string} The platform ID.
43
+ */
44
+ __getPlatform() {
45
+ return this.platform
46
+ }
47
+
48
+ /**
49
+ * Execute a function within the context of this resource's platform.
50
+ * This ensures the correct backend is used regardless of the global ScriptApp.__platform setting.
51
+ * @param {function} fn
52
+ */
53
+ __withPlatform(fn) {
54
+ const currentPlatform = ScriptApp.__platform;
55
+ try {
56
+ ScriptApp.__platform = this.platform;
57
+ return fn();
58
+ } finally {
59
+ ScriptApp.__platform = currentPlatform;
60
+ }
35
61
  }
36
62
 
37
63
  __preventRootDamage = (operation) => {
@@ -62,7 +88,7 @@ export class FakeDriveMeta {
62
88
  return this
63
89
  }
64
90
 
65
- const newMeta = Drive.Files.get(this.getId(), { fields }, { allow404: false })
91
+ const newMeta = this.__withPlatform(() => Drive.Files.get(this.getId(), { fields }, { allow404: false }))
66
92
  // need to merge this with already known fields
67
93
  this.meta = { ...this.meta, ...newMeta }
68
94
  improveFileCache(this.getId(), this.meta, fields)
@@ -103,7 +129,7 @@ export class FakeDriveMeta {
103
129
  const file = {}
104
130
  file[prop] = value
105
131
 
106
- const data = Drive.Files.update(file, this.getId(), null, prop)
132
+ const data = this.__withPlatform(() => Drive.Files.update(file, this.getId(), null, prop))
107
133
  this.meta = { ...this.meta, ...data }
108
134
  improveFileCache(this.getId(), data)
109
135
 
@@ -232,7 +258,7 @@ export class FakeDriveMeta {
232
258
  }
233
259
 
234
260
  // need to make sure we get the new parents field back to improve cache with
235
- const data = Drive.Files.update({}, this.getId(), null, "parents", params)
261
+ const data = this.__withPlatform(() => Drive.Files.update({}, this.getId(), null, "parents", params))
236
262
 
237
263
  // merge this with already known fields and improve cache
238
264
  this.meta = { ...this.meta, ...data }
@@ -294,10 +320,10 @@ export class FakeDriveMeta {
294
320
  allowFileDiscovery = (access === Access.DOMAIN);
295
321
  } else if (access === Access.PRIVATE) {
296
322
  // For PRIVATE, we typically remove any 'anyone' or 'domain' permissions.
297
- const { permissions } = Drive.Permissions.list(this.getId());
323
+ const { permissions } = this.__withPlatform(() => Drive.Permissions.list(this.getId()));
298
324
  permissions.forEach(p => {
299
325
  if (p.type === 'anyone' || p.type === 'domain') {
300
- Drive.Permissions.delete(this.getId(), p.id);
326
+ this.__withPlatform(() => Drive.Permissions.delete(this.getId(), p.id));
301
327
  }
302
328
  });
303
329
  return this;
@@ -315,9 +341,9 @@ export class FakeDriveMeta {
315
341
  }
316
342
 
317
343
  // 3. Find existing permission of this type or create new
318
- const { permissions } = Drive.Permissions.list(this.getId(), {
344
+ const { permissions } = this.__withPlatform(() => Drive.Permissions.list(this.getId(), {
319
345
  fields: "permissions(id,role,type,allowFileDiscovery,domain)"
320
- });
346
+ }));
321
347
  const existing = permissions.find(p => p.type === type);
322
348
 
323
349
  if (existing) {
@@ -327,18 +353,18 @@ export class FakeDriveMeta {
327
353
  (type === 'domain' && existing.domain !== domain);
328
354
 
329
355
  if (identityChanged) {
330
- Drive.Permissions.delete(this.getId(), existing.id);
356
+ this.__withPlatform(() => Drive.Permissions.delete(this.getId(), existing.id));
331
357
  const resource = { role, type, allowFileDiscovery };
332
358
  if (type === 'domain') resource.domain = domain;
333
- Drive.Permissions.create(resource, this.getId());
359
+ this.__withPlatform(() => Drive.Permissions.create(resource, this.getId()));
334
360
  } else {
335
361
  // Only role is writable in update
336
- Drive.Permissions.update({ role }, this.getId(), existing.id);
362
+ this.__withPlatform(() => Drive.Permissions.update({ role }, this.getId(), existing.id));
337
363
  }
338
364
  } else {
339
365
  const resource = { role, type, allowFileDiscovery };
340
366
  if (type === 'domain') resource.domain = Session.getActiveUser().getDomain();
341
- Drive.Permissions.create(resource, this.getId());
367
+ this.__withPlatform(() => Drive.Permissions.create(resource, this.getId()));
342
368
  }
343
369
 
344
370
  improveFileCache(this.getId(), null);
@@ -379,18 +405,18 @@ export class FakeDriveMeta {
379
405
  // In Apps Script, this is not atomic. It just loops.
380
406
  emailAddresses.forEach(emailAddress => {
381
407
  const resource = { role, type: 'user', emailAddress };
382
- Drive.Permissions.create(resource, this.getId());
408
+ this.__withPlatform(() => Drive.Permissions.create(resource, this.getId()));
383
409
  });
384
410
  } else {
385
411
  // To remove, we need to find the permission ID for each email.
386
- const { permissions } = Drive.Permissions.list(this.getId(), {
412
+ const { permissions } = this.__withPlatform(() => Drive.Permissions.list(this.getId(), {
387
413
  fields: 'permissions(id,role,emailAddress)'
388
- });
414
+ }));
389
415
 
390
416
  emailAddresses.forEach(emailAddress => {
391
417
  const permission = permissions.find(p => p.emailAddress === emailAddress && p.role === role);
392
418
  if (permission) {
393
- Drive.Permissions.delete(this.getId(), permission.id);
419
+ this.__withPlatform(() => Drive.Permissions.delete(this.getId(), permission.id));
394
420
  }
395
421
  // Apps Script doesn't throw an error if the user isn't found.
396
422
  });
@@ -0,0 +1,9 @@
1
+ export const Platforms = {
2
+ GOOGLE: 'google',
3
+ KSUITE: 'ksuite',
4
+ MSGRAPH: 'msgraph'
5
+ };
6
+
7
+ export const PlatformDefaults = {
8
+ DEFAULT: Platforms.GOOGLE
9
+ };
@@ -35,10 +35,17 @@ class FakeLibrary {
35
35
  get libContent() {
36
36
  if (!this.__libContent) {
37
37
  this.__allowSandboxAccess();
38
- const data = Drive.Files.export(
39
- this.libraryId,
40
- 'application/vnd.google-apps.script+json',
41
- );
38
+ const currentPlatform = ScriptApp.__platform;
39
+ let data;
40
+ try {
41
+ ScriptApp.__platform = 'google';
42
+ data = Drive.Files.export(
43
+ this.libraryId,
44
+ 'application/vnd.google-apps.script+json',
45
+ );
46
+ } finally {
47
+ ScriptApp.__platform = currentPlatform;
48
+ }
42
49
  if (!data) {
43
50
  throw new Error(`Library ${this.libraryId} not found`);
44
51
  }
@@ -160,7 +160,7 @@ if (typeof globalThis[name] === typeof undefined) {
160
160
  return Auth.getScriptId()
161
161
  },
162
162
  get __platform() {
163
- return Auth.getPlatform()
163
+ return Auth.getPlatform() || 'google'
164
164
  },
165
165
  set __platform(value) {
166
166
  Auth.setPlatform(value)
@@ -251,13 +251,13 @@ class FakeBehavior {
251
251
  // this is a set of all the files this instance of gas-fakes has created
252
252
  // the idea is that we can use this to clean up after tests or to emulate drive.file scope
253
253
  // key is the file id
254
- this.__createdIds = new Set();
255
- this.__createdGmailIds = new Set();
256
- this.__createdCalendarIds = new Set();
254
+ this.__createdIds = new Map();
255
+ this.__createdGmailIds = new Map();
256
+ this.__createdCalendarIds = new Map();
257
257
  // Specifically for sandbox mode - files created when sandbox mode is on
258
- this.__allowedIds = new Set();
259
- this.__allowedGmailIds = new Set();
260
- this.__allowedCalendarIds = new Set();
258
+ this.__allowedIds = new Map();
259
+ this.__allowedGmailIds = new Map();
260
+ this.__allowedCalendarIds = new Map();
261
261
  // in sandbox mode we only allow access to files created in this instance
262
262
  // this is to emulate the behavior of a drive.file scope
263
263
  this.__sandboxMode = false;
@@ -406,40 +406,75 @@ class FakeBehavior {
406
406
  if (isRootId) return id;
407
407
 
408
408
  if (this.sandboxMode || force) {
409
- this.__createdIds.add(id);
410
- if (!this.__allowedIds.has(id)) {
411
- slogger.log(`...adding file ${id} to sandbox allowed list`);
412
- this.__allowedIds.add(id);
413
- }
409
+ const platform = ScriptApp.__platform;
410
+ this.__createdIds.set(id, platform);
411
+ this.whitelistFile(id);
414
412
  }
415
413
  return id
416
414
  }
415
+
416
+ whitelistFile(id) {
417
+ if (!is.nonEmptyString(id)) {
418
+ throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
419
+ }
420
+ const isRootId = id === 'root' || (globalThis.DriveApp?.getRootFolder()?.getId() === id);
421
+ if (isRootId) return id;
422
+
423
+ const platform = ScriptApp.__platform;
424
+ if (!this.__allowedIds.has(id)) {
425
+ slogger.log(`...whitelisting file ${id} on ${platform}`);
426
+ this.__allowedIds.set(id, platform);
427
+ }
428
+ return id;
429
+ }
430
+
417
431
  addGmailId(id, force = false) {
418
432
  if (!is.nonEmptyString(id)) {
419
433
  throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
420
434
  }
421
435
  if (this.sandboxMode || force) {
422
- this.__createdGmailIds.add(id);
423
- if (!this.__allowedGmailIds.has(id)) {
424
- slogger.log(`...adding gmail id ${id} to sandbox allowed list`);
425
- this.__allowedGmailIds.add(id);
426
- }
436
+ const platform = ScriptApp.__platform;
437
+ this.__createdGmailIds.set(id, platform);
438
+ this.whitelistGmailId(id);
427
439
  }
428
440
  return id
429
441
  }
442
+
443
+ whitelistGmailId(id) {
444
+ if (!is.nonEmptyString(id)) {
445
+ throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
446
+ }
447
+ const platform = ScriptApp.__platform;
448
+ if (!this.__allowedGmailIds.has(id)) {
449
+ slogger.log(`...whitelisting gmail id ${id} on ${platform}`);
450
+ this.__allowedGmailIds.set(id, platform);
451
+ }
452
+ return id;
453
+ }
454
+
430
455
  addCalendarId(id, force = false) {
431
456
  if (!is.nonEmptyString(id)) {
432
457
  throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
433
458
  }
434
459
  if (this.sandboxMode || force) {
435
- this.__createdCalendarIds.add(id);
436
- if (!this.__allowedCalendarIds.has(id)) {
437
- slogger.log(`...adding calendar id ${id} to sandbox allowed list`);
438
- this.__allowedCalendarIds.add(id);
439
- }
460
+ const platform = ScriptApp.__platform;
461
+ this.__createdCalendarIds.set(id, platform);
462
+ this.whitelistCalendarId(id);
440
463
  }
441
464
  return id
442
465
  }
466
+
467
+ whitelistCalendarId(id) {
468
+ if (!is.nonEmptyString(id)) {
469
+ throw new Error(`Invalid sandbox id parameter (${id}) - must be a non-empty string`);
470
+ }
471
+ const platform = ScriptApp.__platform;
472
+ if (!this.__allowedCalendarIds.has(id)) {
473
+ slogger.log(`...whitelisting calendar id ${id} on ${platform}`);
474
+ this.__allowedCalendarIds.set(id, platform);
475
+ }
476
+ return id;
477
+ }
443
478
  isAccessible(id, serviceName, accessType = 'read') {
444
479
  if (!is.nonEmptyString(id)) {
445
480
  throw new Error(`API call to ${serviceName} failed with error: Invalid argument: id`);
@@ -528,21 +563,24 @@ class FakeBehavior {
528
563
  trash() {
529
564
  let trashed = [];
530
565
  const wasSandbox = this.sandboxMode;
566
+ const currentPlatform = ScriptApp.__platform;
531
567
  this.sandboxMode = false;
532
568
  try {
533
569
  // Drive cleanup
534
570
  if (this.__cleanup) {
535
571
  const rootId = DriveApp.getRootFolder().getId();
536
- trashed = Array.from(this.__createdIds).reduce((acc, id) => {
572
+ trashed = Array.from(this.__createdIds.entries()).reduce((acc, [id, platform]) => {
537
573
  if (id === rootId) {
538
574
  slogger.log(`...skipping trashing of root folder`);
539
575
  return acc;
540
576
  }
541
577
  let d = null
542
578
  try {
579
+ ScriptApp.__platform = platform;
543
580
  d = DriveApp.getFileById(id)
544
581
  } catch (e) {
545
582
  try {
583
+ ScriptApp.__platform = platform;
546
584
  d = DriveApp.getFolderById(id)
547
585
  } catch (ee) {
548
586
  // Ignore if not found
@@ -550,10 +588,11 @@ class FakeBehavior {
550
588
  }
551
589
  if (d && d.getId() !== rootId) {
552
590
  try {
591
+ ScriptApp.__platform = platform;
553
592
  d.setTrashed(true);
554
593
  const name = d.getName();
555
594
  const logLabel = name ? `${name} (${id})` : id;
556
- slogger.log(`...trashed file ${logLabel}`);
595
+ slogger.log(`...trashed file ${logLabel} on ${platform}`);
557
596
  acc.push(id);
558
597
  } catch (e) {
559
598
  slogger.error(`...failed to trash file ${id}: ${e.message}`);
@@ -573,8 +612,9 @@ class FakeBehavior {
573
612
  const gmailCleanup = gmailSettings && gmailSettings.cleanup; // This will return true/false (inherits or specific)
574
613
 
575
614
  if (gmailCleanup) {
576
- trashedGmail = Array.from(this.__createdGmailIds).reduce((acc, id) => {
615
+ trashedGmail = Array.from(this.__createdGmailIds.entries()).reduce((acc, [id, platform]) => {
577
616
  try {
617
+ ScriptApp.__platform = platform;
578
618
  // Try as label
579
619
  Gmail.Users.Labels.remove('me', id);
580
620
  slogger.log(`...deleted gmail label ${id}`);
@@ -583,6 +623,7 @@ class FakeBehavior {
583
623
  } catch (e) { /* not a label or failed */ }
584
624
 
585
625
  try {
626
+ ScriptApp.__platform = platform;
586
627
  // Try as thread - move to trash
587
628
  Gmail.Users.Threads.trash('me', id);
588
629
  slogger.log(`...trashed gmail thread ${id}`);
@@ -591,6 +632,7 @@ class FakeBehavior {
591
632
  } catch (e) { /* not a thread */ }
592
633
 
593
634
  try {
635
+ ScriptApp.__platform = platform;
594
636
  Gmail.Users.Messages.trash('me', id);
595
637
  slogger.log(`...trashed gmail message ${id}`);
596
638
  acc.push(id);
@@ -611,8 +653,9 @@ class FakeBehavior {
611
653
  const calendarCleanup = calendarSettings && calendarSettings.cleanup;
612
654
 
613
655
  if (calendarCleanup) {
614
- trashedCalendars = Array.from(this.__createdCalendarIds).reduce((acc, id) => {
656
+ trashedCalendars = Array.from(this.__createdCalendarIds.entries()).reduce((acc, [id, platform]) => {
615
657
  try {
658
+ ScriptApp.__platform = platform;
616
659
  // Delete calendar
617
660
  Calendar.Calendars.delete(id, { noLog404: true });
618
661
  slogger.log(`...deleted calendar ${id}`);
@@ -631,6 +674,7 @@ class FakeBehavior {
631
674
  slogger.log(`...trashed ${trashed.length} sandboxed files, ${trashedGmail.length} gmail items, and ${trashedCalendars.length} calendars`);
632
675
  } finally {
633
676
  this.sandboxMode = wasSandbox;
677
+ ScriptApp.__platform = currentPlatform;
634
678
  }
635
679
  return trashed;
636
680
  }
@@ -6,9 +6,8 @@ import { clearFileCache } from "./filecache.js";
6
6
 
7
7
  // Multi-identity storage
8
8
  export const _identities = new Map();
9
- // Default to the first authorized platform if specified in environment
10
- // Default to the first authorized platform if specified in environment
11
- let _platform = process.env.GF_PLATFORM_AUTH ? process.env.GF_PLATFORM_AUTH.split(',')[0] : 'google';
9
+ // Prefer 'google' as the default platform if it is authorized in the environment
10
+ let _platform = process.env.GF_PLATFORM_AUTH ? (process.env.GF_PLATFORM_AUTH.includes('google') ? 'google' : process.env.GF_PLATFORM_AUTH.split(',')[0]) : 'google';
12
11
  let _manifest = null;
13
12
  let _clasp = null;
14
13
  let _settings = null;
@@ -7,6 +7,7 @@ import { readFile, writeFile } from 'fs/promises';
7
7
  import { existsSync } from 'fs';
8
8
  import path from 'path';
9
9
  import { chmodSync } from 'fs';
10
+ import got from 'got';
10
11
 
11
12
  const TOKEN_CACHE_FILE = path.join(process.cwd(), '.msgraph-token.json');
12
13
 
@@ -71,6 +72,11 @@ export function mapGasScopesToMsGraph(gasScopes = []) {
71
72
  return Array.from(msScopes);
72
73
  }
73
74
 
75
+ async function isGcpEnv() {
76
+ if (process.env.K_SERVICE || process.env.FUNCTION_NAME) return true;
77
+ return false;
78
+ }
79
+
74
80
  /**
75
81
  * Gets a Microsoft Graph token.
76
82
  */
@@ -78,13 +84,9 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
78
84
  const envTenant = process.env.MS_GRAPH_TENANT_ID || 'common';
79
85
  const clientId = process.env.MS_GRAPH_CLIENT_ID;
80
86
  const clientSecret = process.env.MS_GRAPH_CLIENT_SECRET;
87
+ const msAuthType = process.env.MS_AUTH_TYPE;
81
88
 
82
- // No local token caching - strictly "Keyless"
83
-
84
- // Ensure no duplicate or empty scopes
85
89
  const uniqueScopes = Array.from(new Set(scopes)).filter(s => s);
86
-
87
- // Format for MS Graph: core scopes are as are, resource scopes with full URL prefix
88
90
  const msScopes = uniqueScopes.map(s => {
89
91
  const core = ['openid', 'profile', 'email', 'offline_access'];
90
92
  if (core.some(c => s.startsWith(c))) return s;
@@ -92,17 +94,63 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
92
94
  return `https://graph.microsoft.com/${s.startsWith('/') ? s.slice(1) : s}`;
93
95
  });
94
96
 
95
- // Check local cache first
96
- const cachedToken = await loadTokenCache();
97
- if (cachedToken) {
98
- syncLog('...retrieved MS Graph token via local file cache');
99
- return cachedToken;
97
+ // Federated/WIF bypasses cache to ensure fresh identity exchange
98
+ if (msAuthType !== 'federated') {
99
+ const cachedToken = await loadTokenCache();
100
+ if (cachedToken) {
101
+ syncLog('...retrieved MS Graph token via local file cache');
102
+ return cachedToken;
103
+ }
100
104
  }
101
105
 
102
106
  try {
103
- // 1. Service Principal (if configured) - "Keyless" for Business
107
+ // 1. Federated Flow (WIF) - For Cloud Run/GKE only
108
+ if (msAuthType === 'federated') {
109
+ const isGcp = await isGcpEnv();
110
+ if (isGcp) {
111
+ try {
112
+ syncLog('...initiating MS Graph Federated Identity (WIF) exchange (GCP)');
113
+ const audience = clientId || 'api://AzureADTokenExchange';
114
+ const metadataUrl = `http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=${audience}`;
115
+
116
+ const googleTokenResponse = await got(metadataUrl, {
117
+ headers: { 'Metadata-Flavor': 'Google' },
118
+ timeout: { request: 2000 },
119
+ retry: { limit: 0 }
120
+ });
121
+ const googleIdToken = googleTokenResponse.body;
122
+
123
+ // Note: Personal apps registered in 'consumers' typically do not support WIF.
124
+ // This flow expects an App Registration in a Work/School tenant.
125
+ const tenant = (envTenant && envTenant !== 'common' && envTenant !== 'consumers') ? envTenant : 'organizations';
126
+ const msTokenUrl = `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`;
127
+
128
+ const form = {
129
+ client_id: clientId,
130
+ grant_type: 'client_credentials',
131
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
132
+ client_assertion: googleIdToken,
133
+ scope: 'https://graph.microsoft.com/.default'
134
+ };
135
+
136
+ const msTokenResponse = await got.post(msTokenUrl, { form, timeout: { request: 5000 } }).json();
137
+ const token = msTokenResponse.access_token;
138
+ const expiresOn = new Date(Date.now() + (msTokenResponse.expires_in * 1000)).toISOString();
139
+
140
+ syncLog('...retrieved MS Graph token via Federated Identity (WIF)');
141
+ await saveTokenCache(token, expiresOn);
142
+ return token;
143
+ } catch (wifErr) {
144
+ syncLog(`...MS WIF exchange failed: ${wifErr.message}`);
145
+ }
146
+ } else {
147
+ syncLog('...skipping Federated Identity (Local environment)');
148
+ }
149
+ }
150
+
151
+ // 2. Service Principal
104
152
  if (clientId && clientSecret) {
105
- const tenantId = envTenant === 'common' || envTenant === 'consumers' ? 'organizations' : envTenant;
153
+ const tenantId = (envTenant === 'common' || envTenant === 'consumers') ? 'organizations' : envTenant;
106
154
  const credential = new ClientSecretCredential(tenantId, clientId, clientSecret);
107
155
  const tokenResponse = await credential.getToken(msScopes);
108
156
  syncLog('...retrieved MS Graph token via Client Secret (Service Principal)');
@@ -110,64 +158,62 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
110
158
  return tokenResponse.token;
111
159
  }
112
160
 
113
- // 2. Azure CLI Direct Exec (Silent flow - OS level "Keyless")
161
+ // 3. Azure CLI Direct Exec (Silent) - Primary Local "Keyless" Path
114
162
  const isAuthFlow = process.env.GF_AUTH_FLOW === 'true';
115
- const targetTenant = envTenant === 'common' ? 'consumers' : envTenant;
116
- const scopeArg = msScopes.join(' ');
117
-
118
- if (!isAuthFlow) {
119
- // Revert to strictly tenant-aware fallback to avoid picking up business/EXT sessions.
120
- const tenantArg = targetTenant ? `--tenant "${targetTenant}" ` : '';
121
- const clientArg = clientId ? `--client-id "${clientId}" ` : '';
163
+ if (!isAuthFlow && !clientId) {
164
+ const tenantArg = (envTenant && envTenant !== 'common' && envTenant !== 'consumers') ? `--tenant "${envTenant}" ` : '';
122
165
 
123
166
  try {
124
- // Strategy 1: Custom Client ID + Tenant
125
- const cmd = `az account get-access-token --resource-type ms-graph ${tenantArg}${clientArg}--scope "https://graph.microsoft.com/.default" --output json`;
167
+ const cmd = `az account get-access-token --resource-type ms-graph ${tenantArg}--output json`;
126
168
  const stdout = execSync(cmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], shell: true });
127
169
  const res = JSON.parse(stdout);
128
170
  const token = res.accessToken;
129
171
  if (token && (token.match(/\./g) || []).length >= 2) {
130
172
  syncLog('...retrieved valid MS Graph token via Azure CLI (silent)');
131
- // az returns expiresOn in a format new Date() can parse
132
173
  await saveTokenCache(token, res.expiresOn);
133
174
  return token;
134
175
  }
135
176
  } catch (e) {
136
- try {
137
- // Strategy 2: First-party + Tenant (Magic bullet for SPO license fix)
138
- const fallbackCmd = `az account get-access-token --resource-type ms-graph ${tenantArg}--scope "https://graph.microsoft.com/.default" --output json`;
139
- const stdout = execSync(fallbackCmd, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], shell: true });
140
- const res = JSON.parse(stdout);
141
- const token = res.accessToken;
142
- if (token && (token.match(/\./g) || []).length >= 2) {
143
- syncLog(`...retrieved valid MS Graph token via Azure CLI (first-party ${targetTenant} fallback)`);
144
- await saveTokenCache(token, res.expiresOn);
145
- return token;
146
- }
147
- } catch (ee) {
148
- syncLog(`...silent CLI fallback failed: ${ee.message}`);
149
- }
177
+ syncLog(`...silent CLI fallback failed: ${e.message}`);
150
178
  }
151
179
  }
152
180
 
153
- // 3. Auth Flow - Interactive Fallback
154
-
155
- // If we're not in the dedicated auth flow, we still attempt interactive fallback
156
- // if silent login failed, as requested by the user for "asynchronous" operations in the worker.
157
- if (!isAuthFlow) {
181
+ // 4. Interactive Fallback
182
+ if (!isAuthFlow && !clientId) {
158
183
  console.log(`...silent CLI login failed. Falling back to interactive SDK login in the worker...`);
184
+ } else if (!isAuthFlow && clientId) {
185
+ console.log(`...using custom MS_GRAPH_CLIENT_ID. Bypassing Azure CLI for Interactive SDK login...`);
159
186
  }
160
187
 
161
- // Interactive login for setup or runtime fallback
162
- const credential = new InteractiveBrowserCredential({
188
+ // Try silent refresh first, then interactive if required
189
+ let promptBehavior = isAuthFlow ? 'select_account' : 'none';
190
+ const credentialSilent = new InteractiveBrowserCredential({
163
191
  tenantId: envTenant === 'common' ? 'consumers' : envTenant,
164
192
  clientId,
165
- prompt: 'select_account consent'
193
+ prompt: promptBehavior
166
194
  });
167
- const tokenResponse = await credential.getToken(msScopes);
168
- syncLog('...retrieved MS Graph token via interactive login');
169
- await saveTokenCache(tokenResponse.token, tokenResponse.expiresOnTimestamp);
170
- return tokenResponse.token;
195
+
196
+ try {
197
+ const tokenResponse = await credentialSilent.getToken(msScopes);
198
+ syncLog(`...retrieved MS Graph token via interactive login (prompt: ${promptBehavior})`);
199
+ await saveTokenCache(tokenResponse.token, tokenResponse.expiresOnTimestamp);
200
+ return tokenResponse.token;
201
+ } catch (silentErr) {
202
+ if (promptBehavior === 'none') {
203
+ console.log(`...silent refresh failed, prompting user...`);
204
+ const credentialInteractive = new InteractiveBrowserCredential({
205
+ tenantId: envTenant === 'common' ? 'consumers' : envTenant,
206
+ clientId,
207
+ prompt: 'select_account consent'
208
+ });
209
+ const tokenResponse = await credentialInteractive.getToken(msScopes);
210
+ syncLog('...retrieved MS Graph token via interactive login');
211
+ await saveTokenCache(tokenResponse.token, tokenResponse.expiresOnTimestamp);
212
+ return tokenResponse.token;
213
+ } else {
214
+ throw silentErr;
215
+ }
216
+ }
171
217
 
172
218
  } catch (err) {
173
219
  throw new Error(`MS Graph Auth failed. Error: ${err.message}`);
@@ -175,6 +221,5 @@ export async function getMsGraphToken(scopes = ['User.Read']) {
175
221
  }
176
222
 
177
223
  function syncLog(msg) {
178
- // Use worker sync log if available, otherwise console
179
224
  console.log(msg);
180
225
  }
@@ -205,7 +205,8 @@ export class OneDrive {
205
205
 
206
206
  const response = await this._request('PATCH', `${this.userPath}/drive/items/${fileId}`, {
207
207
  json: {
208
- parentReference: { id: destId }
208
+ parentReference: { id: destId },
209
+ "@microsoft.graph.conflictBehavior": "replace"
209
210
  }
210
211
  });
211
212
  return this.translateFile(response.body);
@@ -324,8 +324,9 @@ export const fxInit = ({
324
324
  const currentPlatform = Auth.getPlatform();
325
325
  if (currentPlatform === 'google' || !currentPlatform) {
326
326
  const initialPlatforms = platformAuth || global.ScriptApp?.__platformAuth || ['google'];
327
- const firstPlatform = initialPlatforms[0];
328
- Auth.setPlatform(firstPlatform);
327
+ // Prefer google if available in authorized platforms, otherwise use the first available.
328
+ const defaultPlatform = initialPlatforms.includes('google') ? 'google' : initialPlatforms[0];
329
+ Auth.setPlatform(defaultPlatform);
329
330
  }
330
331
 
331
332
  return synced;