@mcpher/gas-fakes 2.0.11 → 2.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # <img src="./logo.png" alt="gas-fakes logo" width="50" align="top"> A proof of concept implementation of Apps Script Environment on Node
1
+ # <img src="./logo.png" alt="gas-fakes logo" width="50" align="top"> Run Native Apps Script code anywhere with gas-fakes
2
2
 
3
3
  I use clasp/antigravity to develop Google Apps Script (GAS) applications, but when using GAS native services, there's way too much back and forwards to the GAS IDE going while testing. I set myself the ambition of implementing a fake version of the GAS runtime environment on Node so I could at least do some testing and debugging of Apps Scripts locally on Node.
4
4
 
@@ -170,6 +170,8 @@ As I mentioned earlier, to take this further, I'm going to need a lot of help to
170
170
  - [gas fakes cli](gas-fakes-cli.md)
171
171
  - [running gas-fakes on google cloud run](https://github.com/brucemcpherson/gas-fakes-containers)
172
172
  - [running gas-fakes on google kubernetes engine](https://github.com/brucemcpherson/gas-fakes-containers)
173
+ - [running gas-fakes on Amazon AWS lambda](https://github.com/brucemcpherson/gas-fakes-containers)
174
+ - [Yes – you can run native apps script code on AWS Lambda!](https://ramblings.mcpher.com/apps-script-on-aws-lambda/)
173
175
  - [initial idea and thoughts](https://ramblings.mcpher.com/a-proof-of-concept-implementation-of-apps-script-environment-on-node/)
174
176
  - [Inside the volatile world of a Google Document](https://ramblings.mcpher.com/inside-the-volatile-world-of-a-google-document/)
175
177
  - [Apps Script Services on Node – using apps script libraries](https://ramblings.mcpher.com/apps-script-services-on-node-using-apps-script-libraries/)
package/package.json CHANGED
@@ -24,6 +24,10 @@
24
24
  "unzipper": "^0.12.3",
25
25
  "zod": "^4.3.6"
26
26
  },
27
+ "overrides": {
28
+ "minimatch": "^10.2.1",
29
+ "glob": "^13.0.0"
30
+ },
27
31
  "type": "module",
28
32
  "scripts": {
29
33
  "pub": "npm publish --access public",
@@ -33,7 +37,7 @@
33
37
  },
34
38
  "name": "@mcpher/gas-fakes",
35
39
  "author": "bruce mcpherson",
36
- "version": "2.0.11",
40
+ "version": "2.0.13",
37
41
  "license": "MIT",
38
42
  "main": "main.js",
39
43
  "description": "An implementation of the Google Workspace Apps Script runtime: Run native App Script Code on Node and Cloud Run",
@@ -64,6 +64,18 @@ export class FakePageElement {
64
64
  throw new Error('PageElement is not a line.');
65
65
  }
66
66
 
67
+ /**
68
+ * Returns the page element as a table.
69
+ * @returns {FakeTable} The table.
70
+ */
71
+ asTable() {
72
+ if (this.__resource.table) {
73
+ const { newFakeTable } = PageElementRegistry;
74
+ return newFakeTable(this.__resource, this.__page);
75
+ }
76
+ throw new Error('PageElement is not a table.');
77
+ }
78
+
67
79
  /**
68
80
  * Gets the type of the page element.
69
81
  * @returns {SlidesApp.PageElementType} The type.
@@ -391,5 +403,6 @@ export class FakePageElement {
391
403
 
392
404
  export const PageElementRegistry = {
393
405
  newFakeShape: null,
394
- newFakeLine: null
406
+ newFakeLine: null,
407
+ newFakeTable: null
395
408
  };
@@ -167,6 +167,23 @@ export class FakeSlide {
167
167
  return newElement.asShape();
168
168
  }
169
169
 
170
+ /**
171
+ * Inserts a text box.
172
+ * @param {string} text The text to insert.
173
+ * @param {number} left The left position.
174
+ * @param {number} top The top position.
175
+ * @param {number} width The width.
176
+ * @param {number} height The height.
177
+ * @returns {FakeShape} The new text box.
178
+ */
179
+ insertTextBox(text, left, top, width, height) {
180
+ const shape = this.insertShape('TEXT_BOX', left, top, width, height);
181
+ if (text) {
182
+ shape.getText().setText(text);
183
+ }
184
+ return shape;
185
+ }
186
+
170
187
  /**
171
188
  * Inserts a line.
172
189
  * @param {SlidesApp.LineCategory} lineCategory The line category.
@@ -212,6 +229,105 @@ export class FakeSlide {
212
229
  return newElement.asLine();
213
230
  }
214
231
 
232
+ /**
233
+ * Inserts a table.
234
+ * @param {number|FakeTable} rowsOrTable The number of rows or a table to copy.
235
+ * @param {number} [columns] The number of columns (if rowsOrTable is a number).
236
+ * @param {number} [left]
237
+ * @param {number} [top]
238
+ * @param {number} [width]
239
+ * @param {number} [height]
240
+ * @returns {FakeTable} The new table.
241
+ */
242
+ insertTable(rowsOrTable, columns, left = 0, top = 0, width = 300, height = 300) {
243
+ const presentationId = this.__presentation.getId();
244
+ const objectId = `table_${Math.random().toString(36).substring(2, 11)}`;
245
+ let request = null;
246
+
247
+ if (typeof rowsOrTable === 'number') {
248
+ request = {
249
+ createTable: {
250
+ objectId,
251
+ rows: rowsOrTable,
252
+ columns: columns,
253
+ elementProperties: {
254
+ pageObjectId: this.getObjectId(),
255
+ size: {
256
+ width: { magnitude: width, unit: 'PT' },
257
+ height: { magnitude: height, unit: 'PT' }
258
+ },
259
+ transform: {
260
+ scaleX: 1,
261
+ scaleY: 1,
262
+ translateX: left,
263
+ translateY: top,
264
+ unit: 'PT'
265
+ }
266
+ }
267
+ }
268
+ };
269
+ } else {
270
+ // Copy table logic - use duplicateObject if it's the same presentation?
271
+ // Or manually create new table with same rows/cols.
272
+ // Slide.insertTable(Table) usually means copy.
273
+ const table = rowsOrTable;
274
+ request = {
275
+ createTable: {
276
+ objectId,
277
+ rows: table.getNumRows(),
278
+ columns: table.getNumColumns(),
279
+ elementProperties: {
280
+ pageObjectId: this.getObjectId(),
281
+ size: {
282
+ width: { magnitude: table.getWidth(), unit: 'PT' },
283
+ height: { magnitude: table.getHeight(), unit: 'PT' }
284
+ },
285
+ transform: {
286
+ scaleX: 1,
287
+ scaleY: 1,
288
+ translateX: table.getLeft(),
289
+ translateY: table.getTop(),
290
+ unit: 'PT'
291
+ }
292
+ }
293
+ }
294
+ };
295
+ }
296
+
297
+ try {
298
+ Slides.Presentations.batchUpdate([request], presentationId);
299
+ } catch (err) {
300
+ if (!err?.message?.includes('already exists')) throw err;
301
+ }
302
+
303
+ const elements = this.getPageElements();
304
+ const newElement = elements.find(e => e.getObjectId() === objectId);
305
+ if (!newElement) throw new Error('New table not found');
306
+
307
+ const newTable = newElement.asTable();
308
+
309
+ // If copying, we should probably copy cell contents too.
310
+ if (typeof rowsOrTable !== 'number') {
311
+ const sourceTable = rowsOrTable;
312
+ const targetTable = newTable;
313
+ const rows = sourceTable.getRows();
314
+ const targetRows = targetTable.getRows();
315
+
316
+ for (let r = 0; r < rows.length; r++) {
317
+ const cells = rows[r].getCells();
318
+ const targetCells = targetRows[r].getCells();
319
+ for (let c = 0; c < cells.length; c++) {
320
+ const text = cells[c].getText().asString();
321
+ if (text) {
322
+ targetCells[c].getText().setText(text);
323
+ }
324
+ }
325
+ }
326
+ }
327
+
328
+ return newTable;
329
+ }
330
+
215
331
  duplicate() {
216
332
  const presentationId = this.__presentation.getId();
217
333
  const objectId = `slide_${Math.random().toString(36).substring(2, 11)}`;
@@ -0,0 +1,63 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { FakePageElement, PageElementRegistry } from './fakepageelement.js';
3
+ import { newFakeTableRow } from './faketablerow.js';
4
+
5
+ export const newFakeTable = (...args) => {
6
+ return Proxies.guard(new FakeTable(...args));
7
+ };
8
+
9
+ PageElementRegistry.newFakeTable = newFakeTable;
10
+
11
+ export class FakeTable extends FakePageElement {
12
+ constructor(resource, page) {
13
+ super(resource, page);
14
+ }
15
+
16
+ get __presentation() {
17
+ return this.__page.__presentation || this.__page.__slide?.__presentation;
18
+ }
19
+
20
+ /**
21
+ * Gets the rows in the table.
22
+ * @returns {FakeTableRow[]} The rows.
23
+ */
24
+ getRows() {
25
+ return (this.__resource.table?.tableRows || []).map((row, index) =>
26
+ newFakeTableRow(row, this, index)
27
+ );
28
+ }
29
+
30
+ /**
31
+ * Gets a row by its index.
32
+ * @param {number} index The row index.
33
+ * @returns {FakeTableRow} The row.
34
+ */
35
+ getRow(index) {
36
+ const rows = this.getRows();
37
+ if (index < 0 || index >= rows.length) {
38
+ throw new Error(`Row index ${index} out of bounds`);
39
+ }
40
+ return rows[index];
41
+ }
42
+
43
+ /**
44
+ * Gets the number of rows in the table.
45
+ * @returns {number} The number of rows.
46
+ */
47
+ getNumRows() {
48
+ return (this.__resource.table?.tableRows || []).length;
49
+ }
50
+
51
+ /**
52
+ * Gets the number of columns in the table.
53
+ * @returns {number} The number of columns.
54
+ */
55
+ getNumColumns() {
56
+ const rows = this.__resource.table?.tableRows || [];
57
+ return rows.length > 0 ? (rows[0].tableCells || []).length : 0;
58
+ }
59
+
60
+ toString() {
61
+ return 'Table';
62
+ }
63
+ }
@@ -0,0 +1,51 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { newFakeTextRange } from './faketextrange.js';
3
+
4
+ export const newFakeTableCell = (...args) => {
5
+ return Proxies.guard(new FakeTableCell(...args));
6
+ };
7
+
8
+ export class FakeTableCell {
9
+ constructor(resource, table, rowIndex, colIndex) {
10
+ this.__resource = resource;
11
+ this.__table = table;
12
+ this.__rowIndex = rowIndex;
13
+ this.__colIndex = colIndex;
14
+ }
15
+
16
+ /**
17
+ * Gets the text in the cell.
18
+ * @returns {FakeTextRange} The text range.
19
+ */
20
+ getText() {
21
+ // FakeTableCell in Slides API doesn't have a direct shape resource to pass to TextRange?
22
+ // Actually, TableCell has text property in Slides API.
23
+ // Wait, let's check Slides API TableCell resource.
24
+ // It has `text` field of type `TextContent`.
25
+ // FakeTextRange expects a `shape` with `__resource.shape.text`.
26
+ // We might need to adapt FakeTextRange or mock a shape-like object.
27
+
28
+ // Let's create a proxy for shape so FakeTextRange can work.
29
+ const mockShape = {
30
+ getObjectId: () => this.__table.getObjectId(),
31
+ __resource: {
32
+ shape: {
33
+ text: this.__resource.text || { textElements: [] }
34
+ }
35
+ },
36
+ __cellLocation: {
37
+ rowIndex: this.__rowIndex,
38
+ columnIndex: this.__colIndex
39
+ },
40
+ __presentation: this.__table.__presentation
41
+ };
42
+ if (!this.__resource.text) {
43
+ this.__resource.text = mockShape.__resource.shape.text;
44
+ }
45
+ return newFakeTextRange(mockShape);
46
+ }
47
+
48
+ toString() {
49
+ return 'TableCell';
50
+ }
51
+ }
@@ -0,0 +1,49 @@
1
+ import { Proxies } from '../../support/proxies.js';
2
+ import { newFakeTableCell } from './faketablecell.js';
3
+
4
+ export const newFakeTableRow = (...args) => {
5
+ return Proxies.guard(new FakeTableRow(...args));
6
+ };
7
+
8
+ export class FakeTableRow {
9
+ constructor(resource, table, rowIndex) {
10
+ this.__resource = resource;
11
+ this.__table = table;
12
+ this.__rowIndex = rowIndex;
13
+ }
14
+
15
+ /**
16
+ * Gets the cells in the row.
17
+ * @returns {FakeTableCell[]} The cells.
18
+ */
19
+ getCells() {
20
+ return (this.__resource.tableCells || []).map((cell, colIndex) =>
21
+ newFakeTableCell(cell, this.__table, this.__rowIndex, colIndex)
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Gets a cell by its index.
27
+ * @param {number} index The cell index.
28
+ * @returns {FakeTableCell} The cell.
29
+ */
30
+ getCell(index) {
31
+ const cells = this.getCells();
32
+ if (index < 0 || index >= cells.length) {
33
+ throw new Error(`Cell index ${index} out of bounds`);
34
+ }
35
+ return cells[index];
36
+ }
37
+
38
+ /**
39
+ * Gets the number of cells in the row.
40
+ * @returns {number} The number of cells.
41
+ */
42
+ getNumCells() {
43
+ return (this.__resource.tableCells || []).length;
44
+ }
45
+
46
+ toString() {
47
+ return 'TableRow';
48
+ }
49
+ }
@@ -83,6 +83,7 @@ export class FakeTextRange {
83
83
  requests.push({
84
84
  deleteText: {
85
85
  objectId: objectId,
86
+ cellLocation: this.__shape.__cellLocation,
86
87
  textRange: {
87
88
  type: 'FROM_START_INDEX',
88
89
  startIndex: 0
@@ -104,6 +105,7 @@ export class FakeTextRange {
104
105
  requests.push({
105
106
  insertText: {
106
107
  objectId: objectId,
108
+ cellLocation: this.__shape.__cellLocation,
107
109
  insertionIndex: 0,
108
110
  text: newText
109
111
  }
@@ -1,5 +1,6 @@
1
1
  import { newFakeShape } from './fakeshape.js';
2
2
  import { newFakeLine } from './fakeline.js';
3
+ import { newFakeTable } from './faketable.js';
3
4
 
4
5
  /**
5
6
  * Converts a base PageElement to a more specific subclass (Shape, Line, etc.)
@@ -14,6 +15,9 @@ export const asSpecificPageElement = (pageElement) => {
14
15
  if (resource.line) {
15
16
  return newFakeLine(resource, pageElement.__page);
16
17
  }
18
+ if (resource.table) {
19
+ return newFakeTable(resource, pageElement.__page);
20
+ }
17
21
  // Add other types as they are implemented
18
22
  return pageElement;
19
23
  };
@@ -56,7 +56,25 @@ const getHashedUserId = () =>
56
56
  const _getTokenInfo = async (client) => {
57
57
  const tokenResponse = await client.getAccessToken();
58
58
  const token = tokenResponse.token;
59
- const tokenInfo = await client.getTokenInfo(token);
59
+
60
+ let tokenInfo;
61
+ if (typeof client.getTokenInfo === 'function') {
62
+ tokenInfo = await client.getTokenInfo(token);
63
+ } else {
64
+ // Fallback for clients like AwsClient that don't have getTokenInfo
65
+ // Call the Token Info API directly
66
+ const response = await client.request({
67
+ url: `https://oauth2.googleapis.com/tokeninfo?access_token=${token}`,
68
+ method: 'GET'
69
+ });
70
+ tokenInfo = response.data;
71
+
72
+ // Ensure email is populated from subject if missing (WIF fallback)
73
+ if (!tokenInfo.email && process.env.GOOGLE_WORKSPACE_SUBJECT) {
74
+ tokenInfo.email = process.env.GOOGLE_WORKSPACE_SUBJECT;
75
+ }
76
+ }
77
+
60
78
  return {
61
79
  tokenInfo,
62
80
  token
@@ -112,9 +130,6 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
112
130
  mayLog(`...discovered project ID: ${_projectId}`)
113
131
 
114
132
  // steering for auth type
115
- // 1. if AUTH_TYPE is DWD, use DWD
116
- // 2. if AUTH_TYPE is ADC, use ADC
117
- // 3. if AUTH_TYPE is not set, use DWD if saName is present, else ADC
118
133
  const saName = process.env.GOOGLE_SERVICE_ACCOUNT_NAME
119
134
  const authType = process.env.AUTH_TYPE?.toLowerCase()
120
135
  const useDwd = authType === 'dwd' || (authType !== 'adc' && saName)
@@ -134,19 +149,21 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
134
149
  mayLog(`...attempting to use service account: ${targetPrincipal}`)
135
150
 
136
151
  /// _sourceClient is the identity of the person/thing running the code
137
- // we'll try to get the openid and email scopes for the source client too if they are in the manifest
138
152
  const sourceScopes = scopes.filter(s => s === 'openid' || s === 'https://www.googleapis.com/auth/userinfo.email')
139
153
  _sourceClient = await _auth.getClient(sourceScopes.length > 0 ? { scopes: sourceScopes } : {})
140
154
 
141
155
  // now to get who the real user is
142
156
  const { tokenInfo: userInfo } = await getSourceAccessTokenInfo()
143
- mayLog(`...user verified as: ${userInfo.email}`);
144
-
145
- // DWD Subject priority:
146
- // 1. GOOGLE_WORKSPACE_SUBJECT
147
- // 2. Caller identity (from getSourceAccessTokenInfo)
157
+
158
+ // AWS tokens might not have email info
148
159
  const saEmail = targetPrincipal
149
160
  const userEmail = process.env.GOOGLE_WORKSPACE_SUBJECT || userInfo.email
161
+
162
+ if (userEmail) {
163
+ mayLog(`...user verified as: ${userEmail}`);
164
+ } else {
165
+ mayLog(`...warning: user identity could not be verified from token, using fallback`);
166
+ }
150
167
 
151
168
  const dwdClient = new OAuth2Client()
152
169
  dwdClient._token = null
@@ -168,10 +185,7 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
168
185
  scope: scopes.join(' ')
169
186
  }
170
187
 
171
- //mayLog(`[Auth] DWD Scopes: ${payload.scope}`)
172
-
173
188
  // Sign the JWT via IAM API
174
- // Note: The caller must have 'Service Account Token Creator' role on the target SA
175
189
  const signUrl = `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${saEmail}:signJwt`
176
190
  const signResponse = await _sourceClient.request({
177
191
  url: signUrl,
@@ -196,8 +210,6 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
196
210
 
197
211
  if (!tokenResponse.ok) {
198
212
  const errorText = await tokenResponse.text()
199
- console.log ('... it looks like you forgot to enable domain-wide delegation for the service account')
200
- console.log ('... rerun gas-fakes auth and check the instructions about enabling domain-wide delegation')
201
213
  throw new Error(`Failed to exchange JWT for token: ${errorText}`)
202
214
  }
203
215
 
@@ -209,10 +221,9 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
209
221
  return { token: this._token }
210
222
  }
211
223
 
212
- // override request to ensure we use our token but leverage the existing transporter
224
+ // override request
213
225
  const originalRequest = dwdClient.request.bind(dwdClient)
214
226
  dwdClient.request = async function (options) {
215
- // Ensure token is fresh
216
227
  await this.getAccessToken()
217
228
  return originalRequest(options)
218
229
  }
@@ -230,14 +241,18 @@ const setAuth = async (scopes = [], mcpLoading = false) => {
230
241
 
231
242
  mayLog(`...using Domain-Wide Delegation for user: ${userEmail}`)
232
243
 
233
- // check we can get an access token - this will trigger the signJwt flow
234
- const { tokenInfo } = await getAccessTokenInfo()
235
- mayLog(`...sa (acting as user) verified as: ${tokenInfo.email}`);
244
+ // check we can get an access token
245
+ const { tokenInfo: authedTokenInfo } = await getAccessTokenInfo()
246
+ if (authedTokenInfo.email) {
247
+ mayLog(`...sa (acting as user) verified as: ${authedTokenInfo.email}`);
248
+ } else {
249
+ mayLog(`...sa (acting as user) token acquired successfully`);
250
+ }
236
251
  }
237
252
 
238
253
 
239
254
  } catch (error) {
240
- mayLog(`...auth failed - check you are logged in with 'gcloud auth login' and have enabled workload identity: ${error}`)
255
+ mayLog(`...auth failed - check environment variables and workload identity: ${error}`)
241
256
  throw error
242
257
  }
243
258
  return getAuth()
@@ -256,15 +271,6 @@ const invalidateToken = () => {
256
271
  }
257
272
  }
258
273
  }
259
- /**
260
- * we'll be using adc credentials so no need for any special auth here
261
- * the idea here is to keep addign scopes to any auth so we have them all
262
- * @param {string[]} [scopes=[]] the required scopes will be added to existing scopes already asked for
263
- * @param {string} [keyFile=null]
264
- * @param {boolean} [mcpLoading=false] When the MCP server is loading, this value is true. By this, the invalid values can be hidden while the MCP server is loading. This is important for using Google Antigravity.
265
- * @returns {GoogleAuth.auth}
266
- */
267
-
268
274
 
269
275
  /**
270
276
  * if we're doing a fetch on drive API we need a special header
@@ -278,10 +284,6 @@ const googify = (options = {}) => {
278
284
  // if no authorization, we dont need this either
279
285
  if (!Reflect.has(headers, "Authorization")) return options;
280
286
 
281
- // we'll need the projectID for this
282
- // note - you must add the x-goog-user-project header, otherwise it'll use some nonexistent project
283
- // see https://cloud.google.com/docs/authentication/rest#set-billing-project
284
- // this has been syncified
285
287
  const projectId = getProjectId();
286
288
  return {
287
289
  ...options,
@@ -293,7 +295,6 @@ const googify = (options = {}) => {
293
295
  };
294
296
 
295
297
  /**
296
- * this would have been set up when manifest was imported
297
298
  * @returns {string} the project id
298
299
  */
299
300
  const getProjectId = () => {
@@ -318,7 +319,6 @@ const getAuth = () => {
318
319
  if (!hasAuth())
319
320
  throw new Error(`auth hasnt been intialized with setAuth yet`);
320
321
 
321
- // Simply return the client we've already prepared/patched
322
322
  return getAuthClient();
323
323
  };
324
324
 
@@ -326,17 +326,11 @@ const getAuth = () => {
326
326
 
327
327
  /**
328
328
  * why is this here ?
329
- * because when we syncit, we import auth for each method and it needs this
330
- * if it was somewhere else we'd need to import that too.
331
- * we can't serialize a return object
332
- * so we just select a few props from it
333
- * @param {SyncApiResponse} result
334
- * @returns
335
329
  */
336
330
  export const responseSyncify = (result) => {
337
331
  if (!result) {
338
332
  return {
339
- status: 503, // Service Unavailable, a good representation for a worker-level failure
333
+ status: 503,
340
334
  statusCode: 503,
341
335
  statusText: "Worker Error: No response object received from API call",
342
336
  error: {
@@ -357,10 +351,6 @@ export const responseSyncify = (result) => {
357
351
  };
358
352
  };
359
353
 
360
- /**
361
- * these are the ones that have been so far requested
362
- * @returns {Set}
363
- */
364
354
  const getAuthedScopes = () => _authScopes;
365
355
 
366
356
  export const Auth = {